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 part of astro_metadata_translator. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

6# See the LICENSE file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

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

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

11 

12"""Classes and support code for metadata translation""" 

13 

14__all__ = ("MetadataTranslator", "StubTranslator", "cache_translation") 

15 

16from abc import abstractmethod 

17import inspect 

18import importlib 

19import logging 

20import warnings 

21import math 

22 

23import astropy.units as u 

24import astropy.io.fits.card 

25from astropy.coordinates import Angle 

26 

27from .properties import PROPERTIES 

28 

29log = logging.getLogger(__name__) 

30 

31# Location of the root of the corrections resource files 

32CORRECTIONS_RESOURCE_ROOT = "corrections" 

33 

34"""Cache of version strings indexed by class.""" 

35_VERSION_CACHE = dict() 

36 

37 

38def cache_translation(func, method=None): 

39 """Decorator to cache the result of a translation method. 

40 

41 Especially useful when a translation uses many other translation 

42 methods. Should be used only on ``to_x()`` methods. 

43 

44 Parameters 

45 ---------- 

46 func : `function` 

47 Translation method to cache. 

48 method : `str`, optional 

49 Name of the translation method to cache. Not needed if the decorator 

50 is used around a normal method, but necessary when the decorator is 

51 being used in a metaclass. 

52 

53 Returns 

54 ------- 

55 wrapped : `function` 

56 Method wrapped by the caching function. 

57 """ 

58 name = func.__name__ if method is None else method 

59 

60 def func_wrapper(self): 

61 if name not in self._translation_cache: 

62 self._translation_cache[name] = func(self) 

63 return self._translation_cache[name] 

64 func_wrapper.__doc__ = func.__doc__ 

65 func_wrapper.__name__ = f"{name}_cached" 

66 return func_wrapper 

67 

68 

69class MetadataTranslator: 

70 """Per-instrument metadata translation support 

71 

72 Parameters 

73 ---------- 

74 header : `dict`-like 

75 Representation of an instrument header that can be manipulated 

76 as if it was a `dict`. 

77 filename : `str`, optional 

78 Name of the file whose header is being translated. For some 

79 datasets with missing header information this can sometimes 

80 allow for some fixups in translations. 

81 """ 

82 

83 # These are all deliberately empty in the base class. 

84 default_search_path = None 

85 """Default search path to use to locate header correction files.""" 

86 

87 default_resource_package = __name__.split(".")[0] 

88 """Module name to use to locate the correction resources.""" 

89 

90 default_resource_root = None 

91 """Default package resource path root to use to locate header correction 

92 files within the ``default_resource_package`` package.""" 

93 

94 _trivial_map = {} 

95 """Dict of one-to-one mappings for header translation from standard 

96 property to corresponding keyword.""" 

97 

98 _const_map = {} 

99 """Dict defining a constant for specified standard properties.""" 

100 

101 translators = dict() 

102 """All registered metadata translation classes.""" 

103 

104 supported_instrument = None 

105 """Name of instrument understood by this translation class.""" 

106 

107 @classmethod 

108 def defined_in_this_class(cls, name): 

109 """Report if the specified class attribute is defined specifically in 

110 this class. 

111 

112 Parameters 

113 ---------- 

114 name : `str` 

115 Name of the attribute to test. 

116 

117 Returns 

118 ------- 

119 in_class : `bool` 

120 `True` if there is a attribute of that name defined in this 

121 specific subclass. 

122 `False` if the method is not defined in this specific subclass 

123 but is defined in a parent class. 

124 Returns `None` if the attribute is not defined anywhere 

125 in the class hierarchy (which can happen if translators have 

126 typos in their mapping tables). 

127 

128 Notes 

129 ----- 

130 Retrieves the attribute associated with the given name. 

131 Then looks in all the parent classes to determine whether that 

132 attribute comes from a parent class or from the current class. 

133 Attributes are compared using `id()`. 

134 """ 

135 # The attribute to compare. 

136 if not hasattr(cls, name): 

137 return None 

138 attr_id = id(getattr(cls, name)) 

139 

140 # Get all the classes in the hierarchy 

141 mro = list(inspect.getmro(cls)) 

142 

143 # Remove the first entry from the list since that will be the 

144 # current class 

145 mro.pop(0) 

146 

147 for parent in mro: 

148 # Some attributes may only exist in subclasses. Skip base classes 

149 # that are missing the attribute (such as object). 

150 if hasattr(parent, name): 

151 if id(getattr(parent, name)) == attr_id: 

152 return False 

153 return True 

154 

155 @staticmethod 

156 def _make_const_mapping(property_key, constant): 

157 """Make a translator method that returns a constant value. 

158 

159 Parameters 

160 ---------- 

161 property_key : `str` 

162 Name of the property to be calculated (for the docstring). 

163 constant : `str` or `numbers.Number` 

164 Value to return for this translator. 

165 

166 Returns 

167 ------- 

168 f : `function` 

169 Function returning the constant. 

170 """ 

171 def constant_translator(self): 

172 return constant 

173 

174 if property_key in PROPERTIES: 174 ↛ 177line 174 didn't jump to line 177, because the condition on line 174 was never false

175 property_doc, return_type = PROPERTIES[property_key][:2] 

176 else: 

177 return_type = type(constant).__name__ 

178 property_doc = f"Returns constant value for '{property_key}' property" 

179 

180 constant_translator.__doc__ = f"""{property_doc} 

181 

182 Returns 

183 ------- 

184 translation : `{return_type}` 

185 Translated property. 

186 """ 

187 return constant_translator 

188 

189 @staticmethod 

190 def _make_trivial_mapping(property_key, header_key, default=None, minimum=None, maximum=None, 

191 unit=None, checker=None): 

192 """Make a translator method returning a header value. 

193 

194 The header value can be converted to a `~astropy.units.Quantity` 

195 if desired, and can also have its value validated. 

196 

197 See `MetadataTranslator.validate_value()` for details on the use 

198 of default parameters. 

199 

200 Parameters 

201 ---------- 

202 property_key : `str` 

203 Name of the translator to be constructed (for the docstring). 

204 header_key : `str` or `list` of `str` 

205 Name of the key to look up in the header. If a `list` each 

206 key will be tested in turn until one matches. This can deal with 

207 header styles that evolve over time. 

208 default : `numbers.Number` or `astropy.units.Quantity`, `str`, optional 

209 If not `None`, default value to be used if the parameter read from 

210 the header is not defined or if the header is missing. 

211 minimum : `numbers.Number` or `astropy.units.Quantity`, optional 

212 If not `None`, and if ``default`` is not `None`, minimum value 

213 acceptable for this parameter. 

214 maximum : `numbers.Number` or `astropy.units.Quantity`, optional 

215 If not `None`, and if ``default`` is not `None`, maximum value 

216 acceptable for this parameter. 

217 unit : `astropy.units.Unit`, optional 

218 If not `None`, the value read from the header will be converted 

219 to a `~astropy.units.Quantity`. Only supported for numeric values. 

220 checker : `function`, optional 

221 Callback function to be used by the translator method in case the 

222 keyword is not present. Function will be executed as if it is 

223 a method of the translator class. Running without raising an 

224 exception will allow the default to be used. Should usually raise 

225 `KeyError`. 

226 

227 Returns 

228 ------- 

229 t : `function` 

230 Function implementing a translator with the specified 

231 parameters. 

232 """ 

233 if property_key in PROPERTIES: 233 ↛ 236line 233 didn't jump to line 236, because the condition on line 233 was never false

234 property_doc, return_type = PROPERTIES[property_key][:2] 

235 else: 

236 return_type = "str` or `numbers.Number" 

237 property_doc = f"Map '{header_key}' header keyword to '{property_key}' property" 

238 

239 def trivial_translator(self): 

240 if unit is not None: 

241 q = self.quantity_from_card(header_key, unit, 

242 default=default, minimum=minimum, maximum=maximum, 

243 checker=checker) 

244 # Convert to Angle if this quantity is an angle 

245 if return_type == "astropy.coordinates.Angle": 

246 q = Angle(q) 

247 return q 

248 

249 keywords = header_key if isinstance(header_key, list) else [header_key] 

250 for key in keywords: 

251 if self.is_key_ok(key): 

252 value = self._header[key] 

253 if default is not None and not isinstance(value, str): 

254 value = self.validate_value(value, default, minimum=minimum, maximum=maximum) 

255 self._used_these_cards(key) 

256 break 

257 else: 

258 # No keywords found, use default, checking first, or raise 

259 # A None default is only allowed if a checker is provided. 

260 if checker is not None: 

261 try: 

262 checker(self) 

263 return default 

264 except Exception: 

265 raise KeyError(f"Could not find {keywords} in header") 

266 value = default 

267 elif default is not None: 

268 value = default 

269 else: 

270 raise KeyError(f"Could not find {keywords} in header") 

271 

272 # If we know this is meant to be a string, force to a string. 

273 # Sometimes headers represent items as integers which generically 

274 # we want as strings (eg OBSID). Sometimes also floats are 

275 # written as "NaN" strings. 

276 casts = {"str": str, "float": float, "int": int} 

277 if return_type in casts and not isinstance(value, casts[return_type]) and value is not None: 

278 value = casts[return_type](value) 

279 

280 return value 

281 

282 # Docstring inheritance means it is confusing to specify here 

283 # exactly which header value is being used. 

284 trivial_translator.__doc__ = f"""{property_doc} 

285 

286 Returns 

287 ------- 

288 translation : `{return_type}` 

289 Translated value derived from the header. 

290 """ 

291 return trivial_translator 

292 

293 @classmethod 

294 def __init_subclass__(cls, **kwargs): 

295 """Register all subclasses with the base class and create dynamic 

296 translator methods. 

297 

298 The method provides two facilities. Firstly, every subclass 

299 of `MetadataTranslator` that includes a ``name`` class property is 

300 registered as a translator class that could be selected when automatic 

301 header translation is attempted. Only name translator subclasses that 

302 correspond to a complete instrument. Translation classes providing 

303 generic translation support for multiple instrument translators should 

304 not be named. 

305 

306 The second feature of this method is to convert simple translations 

307 to full translator methods. Sometimes a translation is fixed (for 

308 example a specific instrument name should be used) and rather than 

309 provide a full ``to_property()`` translation method the mapping can be 

310 defined in a class variable named ``_constMap``. Similarly, for 

311 one-to-one trivial mappings from a header to a property, 

312 ``_trivialMap`` can be defined. Trivial mappings are a dict mapping a 

313 generic property to either a header keyword, or a tuple consisting of 

314 the header keyword and a dict containing key value pairs suitable for 

315 the `MetadataTranslator.quantity_from_card()` method. 

316 """ 

317 super().__init_subclass__(**kwargs) 

318 

319 # Only register classes with declared names 

320 if hasattr(cls, "name") and cls.name is not None: 

321 if cls.name in MetadataTranslator.translators: 321 ↛ 322line 321 didn't jump to line 322, because the condition on line 321 was never true

322 log.warning("%s: Replacing %s translator with %s", 

323 cls.name, MetadataTranslator.translators[cls.name], cls) 

324 MetadataTranslator.translators[cls.name] = cls 

325 

326 # Check that we have not inherited constant/trivial mappings from 

327 # parent class that we have already applied. Empty maps are always 

328 # assumed okay 

329 const_map = cls._const_map if cls._const_map and cls.defined_in_this_class("_const_map") else {} 

330 trivial_map = cls._trivial_map \ 

331 if cls._trivial_map and cls.defined_in_this_class("_trivial_map") else {} 

332 

333 # Check for shadowing 

334 trivials = set(trivial_map.keys()) 

335 constants = set(const_map.keys()) 

336 both = trivials & constants 

337 if both: 337 ↛ 338line 337 didn't jump to line 338, because the condition on line 337 was never true

338 log.warning("%s: defined in both const_map and trivial_map: %s", 

339 cls.__name__, ", ".join(both)) 

340 

341 all = trivials | constants 

342 for name in all: 

343 if cls.defined_in_this_class(f"to_{name}"): 343 ↛ 346line 343 didn't jump to line 346, because the condition on line 343 was never true

344 # Must be one of trivial or constant. If in both then constant 

345 # overrides trivial. 

346 location = "by _trivial_map" 

347 if name in constants: 

348 location = "by _const_map" 

349 log.warning("%s: %s is defined explicitly but will be replaced %s", 

350 cls.__name__, name, location) 

351 

352 # Go through the trival mappings for this class and create 

353 # corresponding translator methods 

354 for property_key, header_key in trivial_map.items(): 

355 kwargs = {} 

356 if type(header_key) == tuple: 

357 kwargs = header_key[1] 

358 header_key = header_key[0] 

359 translator = cls._make_trivial_mapping(property_key, header_key, **kwargs) 

360 method = f"to_{property_key}" 

361 translator.__name__ = f"{method}_trivial_in_{cls.__name__}" 

362 setattr(cls, method, cache_translation(translator, method=method)) 

363 if property_key not in PROPERTIES: 363 ↛ 364line 363 didn't jump to line 364, because the condition on line 363 was never true

364 log.warning(f"Unexpected trivial translator for '{property_key}' defined in {cls}") 

365 

366 # Go through the constant mappings for this class and create 

367 # corresponding translator methods 

368 for property_key, constant in const_map.items(): 

369 translator = cls._make_const_mapping(property_key, constant) 

370 method = f"to_{property_key}" 

371 translator.__name__ = f"{method}_constant_in_{cls.__name__}" 

372 setattr(cls, method, translator) 

373 if property_key not in PROPERTIES: 373 ↛ 374line 373 didn't jump to line 374, because the condition on line 373 was never true

374 log.warning(f"Unexpected constant translator for '{property_key}' defined in {cls}") 

375 

376 def __init__(self, header, filename=None): 

377 self._header = header 

378 self.filename = filename 

379 self._used_cards = set() 

380 

381 # Prefix to use for warnings about failed translations 

382 self._log_prefix_cache = None 

383 

384 # Cache assumes header is read-only once stored in object 

385 self._translation_cache = {} 

386 

387 @classmethod 

388 @abstractmethod 

389 def can_translate(cls, header, filename=None): 

390 """Indicate whether this translation class can translate the 

391 supplied header. 

392 

393 Parameters 

394 ---------- 

395 header : `dict`-like 

396 Header to convert to standardized form. 

397 filename : `str`, optional 

398 Name of file being translated. 

399 

400 Returns 

401 ------- 

402 can : `bool` 

403 `True` if the header is recognized by this class. `False` 

404 otherwise. 

405 """ 

406 raise NotImplementedError() 

407 

408 @classmethod 

409 def can_translate_with_options(cls, header, options, filename=None): 

410 """Helper method for `can_translate` allowing options. 

411 

412 Parameters 

413 ---------- 

414 header : `dict`-like 

415 Header to convert to standardized form. 

416 options : `dict` 

417 Headers to try to determine whether this header can 

418 be translated by this class. If a card is found it will 

419 be compared with the expected value and will return that 

420 comparison. Each card will be tried in turn until one is 

421 found. 

422 filename : `str`, optional 

423 Name of file being translated. 

424 

425 Returns 

426 ------- 

427 can : `bool` 

428 `True` if the header is recognized by this class. `False` 

429 otherwise. 

430 

431 Notes 

432 ----- 

433 Intended to be used from within `can_translate` implementations 

434 for specific translators. Is not intended to be called directly 

435 from `determine_translator`. 

436 """ 

437 for card, value in options.items(): 

438 if card in header: 

439 return header[card] == value 

440 return False 

441 

442 @classmethod 

443 def determine_translator(cls, header, filename=None): 

444 """Determine a translation class by examining the header 

445 

446 Parameters 

447 ---------- 

448 header : `dict`-like 

449 Representation of a header. 

450 filename : `str`, optional 

451 Name of file being translated. 

452 

453 Returns 

454 ------- 

455 translator : `MetadataTranslator` 

456 Translation class that knows how to extract metadata from 

457 the supplied header. 

458 

459 Raises 

460 ------ 

461 ValueError 

462 None of the registered translation classes understood the supplied 

463 header. 

464 """ 

465 file_msg = "" 

466 if filename is not None: 

467 file_msg = f" from {filename}" 

468 for name, trans in cls.translators.items(): 

469 if trans.can_translate(header, filename=filename): 

470 log.debug("Using translation class %s%s", name, file_msg) 

471 return trans 

472 else: 

473 raise ValueError(f"None of the registered translation classes {list(cls.translators.keys())}" 

474 f" understood this header{file_msg}") 

475 

476 @classmethod 

477 def translator_version(cls): 

478 """Return the version string for this translator class. 

479 

480 Returns 

481 ------- 

482 version : `str` 

483 String identifying the version of this translator. 

484 

485 Notes 

486 ----- 

487 Assumes that the version is available from the ``__version__`` 

488 variable in the parent module. If this is not the case a translator 

489 should subclass this method. 

490 """ 

491 if cls in _VERSION_CACHE: 

492 return _VERSION_CACHE[cls] 

493 

494 version = "unknown" 

495 module_name = cls.__module__ 

496 components = module_name.split(".") 

497 while components: 

498 # This class has already been imported so importing it 

499 # should work. 

500 module = importlib.import_module(".".join(components)) 

501 if hasattr(module, v := "__version__"): 

502 version = getattr(module, v) 

503 if version == "unknown": 

504 # LSST software will have a fingerprint 

505 if hasattr(module, v := "__fingerprint__"): 

506 version = getattr(module, v) 

507 break 

508 else: 

509 # Remove last component from module name and try again 

510 components.pop() 

511 

512 _VERSION_CACHE[cls] = version 

513 return version 

514 

515 @classmethod 

516 def fix_header(cls, header, instrument, obsid, filename=None): 

517 """Apply global fixes to a supplied header. 

518 

519 Parameters 

520 ---------- 

521 header : `dict` 

522 The header to correct. Correction is in place. 

523 instrument : `str` 

524 The name of the instrument. 

525 obsid : `str` 

526 Unique observation identifier associated with this header. 

527 Will always be provided. 

528 filename : `str`, optional 

529 Filename associated with this header. May not be set since headers 

530 can be fixed independently of any filename being known. 

531 

532 Returns 

533 ------- 

534 modified : `bool` 

535 `True` if a correction was applied. 

536 

537 Notes 

538 ----- 

539 This method is intended to support major discrepancies in headers 

540 such as: 

541 

542 * Periods of time where headers are known to be incorrect in some 

543 way that can be fixed either by deriving the correct value from 

544 the existing value or understanding the that correction is static 

545 for the given time. This requires that the date header is 

546 known. 

547 * The presence of a certain value is always wrong and should be 

548 corrected with a new static value regardless of date. 

549 

550 It is assumed that one off problems with headers have been applied 

551 before this method is called using the per-obsid correction system. 

552 

553 Usually called from `astro_metadata_translator.fix_header`. 

554 

555 For log messages, do not assume that the filename will be present. 

556 Always write log messages to fall back on using the ``obsid`` if 

557 ``filename`` is `None`. 

558 """ 

559 return False 

560 

561 @staticmethod 

562 def _construct_log_prefix(obsid, filename=None): 

563 """Construct a log prefix string from the obsid and filename. 

564 

565 Parameters 

566 ---------- 

567 obsid : `str` 

568 The observation identifier. 

569 filename : `str`, optional 

570 The filename associated with the header being translated. 

571 Can be `None`. 

572 """ 

573 if filename: 

574 return f"{filename}({obsid})" 

575 return obsid 

576 

577 @property 

578 def _log_prefix(self): 

579 """Standard prefix that can be used for log messages to report 

580 useful context. 

581 

582 Will be either the filename and obsid, or just the obsid depending 

583 on whether a filename is known. 

584 

585 Returns 

586 ------- 

587 prefix : `str` 

588 The prefix to use. 

589 """ 

590 if self._log_prefix_cache is None: 

591 # Protect against the unfortunate event of the obsid failing to 

592 # be calculated. This should be rare but should not prevent a log 

593 # message from appearing. 

594 try: 

595 obsid = self.to_observation_id() 

596 except Exception: 

597 obsid = "unknown_obsid" 

598 self._log_prefix_cache = self._construct_log_prefix(obsid, self.filename) 

599 return self._log_prefix_cache 

600 

601 def _used_these_cards(self, *args): 

602 """Indicate that the supplied cards have been used for translation. 

603 

604 Parameters 

605 ---------- 

606 args : sequence of `str` 

607 Keywords used to process a translation. 

608 """ 

609 self._used_cards.update(set(args)) 

610 

611 def cards_used(self): 

612 """Cards used during metadata extraction. 

613 

614 Returns 

615 ------- 

616 used : `frozenset` of `str` 

617 Cards used when extracting metadata. 

618 """ 

619 return frozenset(self._used_cards) 

620 

621 @staticmethod 

622 def validate_value(value, default, minimum=None, maximum=None): 

623 """Validate the supplied value, returning a new value if out of range 

624 

625 Parameters 

626 ---------- 

627 value : `float` 

628 Value to be validated. 

629 default : `float` 

630 Default value to use if supplied value is invalid or out of range. 

631 Assumed to be in the same units as the value expected in the 

632 header. 

633 minimum : `float` 

634 Minimum possible valid value, optional. If the calculated value 

635 is below this value, the default value will be used. 

636 maximum : `float` 

637 Maximum possible valid value, optional. If the calculated value 

638 is above this value, the default value will be used. 

639 

640 Returns 

641 ------- 

642 value : `float` 

643 Either the supplied value, or a default value. 

644 """ 

645 if value is None or math.isnan(value): 

646 value = default 

647 else: 

648 if minimum is not None and value < minimum: 

649 value = default 

650 elif maximum is not None and value > maximum: 

651 value = default 

652 return value 

653 

654 @staticmethod 

655 def is_keyword_defined(header, keyword): 

656 """Return `True` if the value associated with the named keyword is 

657 present in the supplied header and defined. 

658 

659 Parameters 

660 ---------- 

661 header : `dict`-lik 

662 Header to use as reference. 

663 keyword : `str` 

664 Keyword to check against header. 

665 

666 Returns 

667 ------- 

668 is_defined : `bool` 

669 `True` if the header is present and not-`None`. `False` otherwise. 

670 """ 

671 if keyword is None or keyword not in header: 

672 return False 

673 

674 if header[keyword] is None: 

675 return False 

676 

677 # Special case Astropy undefined value 

678 if isinstance(header[keyword], astropy.io.fits.card.Undefined): 

679 return False 

680 

681 return True 

682 

683 def resource_root(self): 

684 """Package resource to use to locate correction resources within an 

685 installed package. 

686 

687 Returns 

688 ------- 

689 resource_package : `str` 

690 Package resource name. `None` if no package resource are to be 

691 used. 

692 resource_root : `str` 

693 The name of the resource root. `None` if no package resources 

694 are to be used. 

695 """ 

696 return (self.default_resource_package, self.default_resource_root) 

697 

698 def search_paths(self): 

699 """Search paths to use when searching for header fix up correction 

700 files. 

701 

702 Returns 

703 ------- 

704 paths : `list` 

705 Directory paths to search. Can be an empty list if no special 

706 directories are defined. 

707 

708 Notes 

709 ----- 

710 Uses the classes ``default_search_path`` property if defined. 

711 """ 

712 if self.default_search_path is not None: 

713 return [self.default_search_path] 

714 return [] 

715 

716 def is_key_ok(self, keyword): 

717 """Return `True` if the value associated with the named keyword is 

718 present in this header and defined. 

719 

720 Parameters 

721 ---------- 

722 keyword : `str` 

723 Keyword to check against header. 

724 

725 Returns 

726 ------- 

727 is_ok : `bool` 

728 `True` if the header is present and not-`None`. `False` otherwise. 

729 """ 

730 return self.is_keyword_defined(self._header, keyword) 

731 

732 def are_keys_ok(self, keywords): 

733 """Are the supplied keys all present and defined? 

734 

735 Parameters 

736 ---------- 

737 keywords : iterable of `str` 

738 Keywords to test. 

739 

740 Returns 

741 ------- 

742 all_ok : `bool` 

743 `True` if all supplied keys are present and defined. 

744 """ 

745 for k in keywords: 

746 if not self.is_key_ok(k): 

747 return False 

748 return True 

749 

750 def quantity_from_card(self, keywords, unit, default=None, minimum=None, maximum=None, checker=None): 

751 """Calculate a Astropy Quantity from a header card and a unit. 

752 

753 Parameters 

754 ---------- 

755 keywords : `str` or `list` of `str` 

756 Keyword to use from header. If a list each keyword will be tried 

757 in turn until one matches. 

758 unit : `astropy.units.UnitBase` 

759 Unit of the item in the header. 

760 default : `float`, optional 

761 Default value to use if the header value is invalid. Assumed 

762 to be in the same units as the value expected in the header. If 

763 None, no default value is used. 

764 minimum : `float`, optional 

765 Minimum possible valid value, optional. If the calculated value 

766 is below this value, the default value will be used. 

767 maximum : `float`, optional 

768 Maximum possible valid value, optional. If the calculated value 

769 is above this value, the default value will be used. 

770 checker : `function`, optional 

771 Callback function to be used by the translator method in case the 

772 keyword is not present. Function will be executed as if it is 

773 a method of the translator class. Running without raising an 

774 exception will allow the default to be used. Should usually raise 

775 `KeyError`. 

776 

777 Returns 

778 ------- 

779 q : `astropy.units.Quantity` 

780 Quantity representing the header value. 

781 

782 Raises 

783 ------ 

784 KeyError 

785 The supplied header key is not present. 

786 """ 

787 keywords = keywords if isinstance(keywords, list) else [keywords] 

788 for k in keywords: 

789 if self.is_key_ok(k): 

790 value = self._header[k] 

791 keyword = k 

792 break 

793 else: 

794 if checker is not None: 

795 try: 

796 checker(self) 

797 value = default 

798 if value is not None: 

799 value = u.Quantity(value, unit=unit) 

800 return value 

801 except Exception: 

802 pass 

803 raise KeyError(f"Could not find {keywords} in header") 

804 if isinstance(value, str): 

805 # Sometimes the header has the wrong type in it but this must 

806 # be a number if we are creating a quantity. 

807 value = float(value) 

808 self._used_these_cards(keyword) 

809 if default is not None: 

810 value = self.validate_value(value, default, maximum=maximum, minimum=minimum) 

811 return u.Quantity(value, unit=unit) 

812 

813 def _join_keyword_values(self, keywords, delim="+"): 

814 """Join values of all defined keywords with the specified delimiter. 

815 

816 Parameters 

817 ---------- 

818 keywords : iterable of `str` 

819 Keywords to look for in header. 

820 delim : `str`, optional 

821 Character to use to join the values together. 

822 

823 Returns 

824 ------- 

825 joined : `str` 

826 String formed from all the keywords found in the header with 

827 defined values joined by the delimiter. Empty string if no 

828 defined keywords found. 

829 """ 

830 values = [] 

831 for k in keywords: 

832 if self.is_key_ok(k): 

833 values.append(self._header[k]) 

834 self._used_these_cards(k) 

835 

836 if values: 

837 joined = delim.join(str(v) for v in values) 

838 else: 

839 joined = "" 

840 

841 return joined 

842 

843 @cache_translation 

844 def to_detector_unique_name(self): 

845 """Return a unique name for the detector. 

846 

847 Base class implementation attempts to combine ``detector_name`` with 

848 ``detector_group``. Group is only used if not `None`. 

849 

850 Can be over-ridden by specialist translator class. 

851 

852 Returns 

853 ------- 

854 name : `str` 

855 ``detector_group``_``detector_name`` if ``detector_group`` is 

856 defined, else the ``detector_name`` is assumed to be unique. 

857 If neither return a valid value an exception is raised. 

858 

859 Raises 

860 ------ 

861 NotImplementedError 

862 Raised if neither detector_name nor detector_group is defined. 

863 """ 

864 name = self.to_detector_name() 

865 group = self.to_detector_group() 

866 

867 if group is None and name is None: 

868 raise NotImplementedError("Can not determine unique name from detector_group and detector_name") 

869 

870 if group is not None: 

871 return f"{group}_{name}" 

872 

873 return name 

874 

875 @cache_translation 

876 def to_exposure_group(self): 

877 """Return the group label associated with this exposure. 

878 

879 Base class implementation returns the ``exposure_id`` in string 

880 form. A subclass may do something different. 

881 

882 Returns 

883 ------- 

884 name : `str` 

885 The ``exposure_id`` converted to a string. 

886 """ 

887 exposure_id = self.to_exposure_id() 

888 if exposure_id is None: 

889 return None 

890 else: 

891 return str(exposure_id) 

892 

893 @cache_translation 

894 def to_observation_reason(self): 

895 """Return the reason this observation was taken. 

896 

897 Base class implementation returns the ``science`` if the 

898 ``observation_type`` is science, else ``unknown``. 

899 A subclass may do something different. 

900 

901 Returns 

902 ------- 

903 name : `str` 

904 The reason for this observation. 

905 """ 

906 obstype = self.to_observation_type() 

907 if obstype == "science": 

908 return "science" 

909 return "unknown" 

910 

911 @cache_translation 

912 def to_observing_day(self): 

913 """Return the YYYYMMDD integer corresponding to the observing day. 

914 

915 Base class implementation uses the TAI date of the start of the 

916 observation. 

917 

918 Returns 

919 ------- 

920 day : `int` 

921 The observing day as an integer of form YYYYMMDD. If the header 

922 is broken and is unable to obtain a date of observation, ``0`` 

923 is returned and the assumption is made that the problem will 

924 be caught elsewhere. 

925 """ 

926 datetime_begin = self.to_datetime_begin() 

927 if datetime_begin is None: 

928 return 0 

929 return int(datetime_begin.tai.strftime("%Y%m%d")) 

930 

931 @cache_translation 

932 def to_observation_counter(self): 

933 """Return an integer corresponding to how this observation relates 

934 to other observations. 

935 

936 Base class implementation returns ``0`` to indicate that it is not 

937 known how an observatory will define a counter. Some observatories 

938 may not use the concept, others may use a counter that increases 

939 for every observation taken for that instrument, and others may 

940 define it to be a counter within an observing day. 

941 

942 Returns 

943 ------- 

944 sequence : `int` 

945 The observation counter. Always ``0`` for this implementation. 

946 """ 

947 return 0 

948 

949 @classmethod 

950 def determine_translatable_headers(cls, filename, primary=None): 

951 """Given a file return all the headers usable for metadata translation. 

952 

953 This method can optionally be given a header from the file. This 

954 header will generally be the primary header or a merge of the first 

955 two headers. 

956 

957 In the base class implementation it is assumed that 

958 this supplied header is the only useful header for metadata translation 

959 and it will be returned unchanged if given. This can avoid 

960 unnecesarily re-opening the file and re-reading the header when the 

961 content is already known. 

962 

963 If no header is supplied, a header will be read from the supplied 

964 file using `read_basic_metadata_from_file`, allowing it to merge 

965 the primary and secondary header of a multi-extension FITS file. 

966 Subclasses can read the header from the data file using whatever 

967 technique is best for that instrument. 

968 

969 Subclasses can return multiple headers and ignore the externally 

970 supplied header. They can also merge it with another header and return 

971 a new derived header if that is required by the particular data file. 

972 There is no requirement for the supplied header to be used. 

973 

974 Parameters 

975 ---------- 

976 filename : `str` 

977 Path to a file in a format understood by this translator. 

978 primary : `dict`-like, optional 

979 The primary header obtained by the caller. This is sometimes 

980 already known, for example if a system is trying to bootstrap 

981 without already knowing what data is in the file. For many 

982 instruments where the primary header is the only relevant 

983 header, the primary header will be returned with no further 

984 action. 

985 

986 Yields 

987 ------ 

988 headers : iterator of `dict`-like 

989 A header usable for metadata translation. For this base 

990 implementation it will be either the supplied primary header 

991 or a header read from the file. This implementation will only 

992 ever yield a single header. 

993 

994 Notes 

995 ----- 

996 Each translator class can have code specifically tailored to its 

997 own file format. It is important not to call this method with 

998 an incorrect translator class. The normal paradigm is for the 

999 caller to have read the first header and then called 

1000 `determine_translator()` on the result to work out which translator 

1001 class to then call to obtain the real headers to be used for 

1002 translation. 

1003 """ 

1004 if primary is not None: 

1005 yield primary 

1006 else: 

1007 # Prevent circular import by deferring 

1008 from .file_helpers import read_basic_metadata_from_file 

1009 

1010 # Merge primary and secondary header if they exist. 

1011 yield read_basic_metadata_from_file(filename, -1) 

1012 

1013 

1014def _make_abstract_translator_method(property, doc, return_typedoc, return_type): 

1015 """Create a an abstract translation method for this property. 

1016 

1017 Parameters 

1018 ---------- 

1019 property : `str` 

1020 Name of the translator for property to be created. 

1021 doc : `str` 

1022 Description of the property. 

1023 return_typedoc : `str` 

1024 Type string of this property (used in the doc string). 

1025 return_type : `class` 

1026 Type of this property. 

1027 

1028 Returns 

1029 ------- 

1030 m : `function` 

1031 Translator method for this property. 

1032 """ 

1033 def to_property(self): 

1034 raise NotImplementedError(f"Translator for '{property}' undefined.") 

1035 

1036 to_property.__doc__ = f"""Return value of {property} from headers. 

1037 

1038 {doc} 

1039 

1040 Returns 

1041 ------- 

1042 {property} : `{return_typedoc}` 

1043 The translated property. 

1044 """ 

1045 return to_property 

1046 

1047 

1048# Make abstract methods for all the translators methods. 

1049# Unfortunately registering them as abstractmethods does not work 

1050# as these assignments come after the class has been created. 

1051# Assigning to __abstractmethods__ directly does work but interacts 

1052# poorly with the metaclass automatically generating methods from 

1053# _trivialMap and _constMap. 

1054 

1055# Allow for concrete translator methods to exist in the base class 

1056# These translator methods can be defined in terms of other properties 

1057CONCRETE = set() 

1058 

1059for name, description in PROPERTIES.items(): 

1060 method = f"to_{name}" 

1061 if not MetadataTranslator.defined_in_this_class(method): 

1062 setattr(MetadataTranslator, f"to_{name}", 

1063 abstractmethod(_make_abstract_translator_method(name, *description[:3]))) 

1064 else: 

1065 CONCRETE.add(method) 

1066 

1067 

1068class StubTranslator(MetadataTranslator): 

1069 """Translator where all the translations are stubbed out and issue 

1070 warnings. 

1071 

1072 This translator can be used as a base class whilst developing a new 

1073 translator. It allows testing to proceed without being required to fully 

1074 define all translation methods. Once complete the class should be 

1075 removed from the inheritance tree. 

1076 

1077 """ 

1078 pass 

1079 

1080 

1081def _make_forwarded_stub_translator_method(cls, property, doc, return_typedoc, return_type): 

1082 """Create a stub translation method for this property that calls the 

1083 base method and catches `NotImplementedError`. 

1084 

1085 Parameters 

1086 ---------- 

1087 cls : `class` 

1088 Class to use when referencing `super()`. This would usually be 

1089 `StubTranslator`. 

1090 property : `str` 

1091 Name of the translator for property to be created. 

1092 doc : `str` 

1093 Description of the property. 

1094 return_typedoc : `str` 

1095 Type string of this property (used in the doc string). 

1096 return_type : `class` 

1097 Type of this property. 

1098 

1099 Returns 

1100 ------- 

1101 m : `function` 

1102 Stub translator method for this property. 

1103 """ 

1104 method = f"to_{property}" 

1105 

1106 def to_stub(self): 

1107 parent = getattr(super(cls, self), method, None) 

1108 try: 

1109 if parent is not None: 

1110 return parent() 

1111 except NotImplementedError: 

1112 pass 

1113 

1114 warnings.warn(f"Please implement translator for property '{property}' for translator {self}", 

1115 stacklevel=3) 

1116 return None 

1117 

1118 to_stub.__doc__ = f"""Unimplemented forwarding translator for {property}. 

1119 

1120 {doc} 

1121 

1122 Calls the base class translation method and if that fails with 

1123 `NotImplementedError` issues a warning reminding the implementer to 

1124 override this method. 

1125 

1126 Returns 

1127 ------- 

1128 {property} : `None` or `{return_typedoc}` 

1129 Always returns `None`. 

1130 """ 

1131 return to_stub 

1132 

1133 

1134# Create stub translation methods for each property. These stubs warn 

1135# rather than fail and should be overridden by translators. 

1136for name, description in PROPERTIES.items(): 

1137 setattr(StubTranslator, f"to_{name}", _make_forwarded_stub_translator_method(StubTranslator, 

1138 name, *description[:3]))