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 for name, trans in cls.translators.items(): 

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

467 log.debug(f"Using translation class {name}") 

468 return trans 

469 else: 

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

471 " understood this header") 

472 

473 @classmethod 

474 def translator_version(cls): 

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

476 

477 Returns 

478 ------- 

479 version : `str` 

480 String identifying the version of this translator. 

481 

482 Notes 

483 ----- 

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

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

486 should subclass this method. 

487 """ 

488 if cls in _VERSION_CACHE: 

489 return _VERSION_CACHE[cls] 

490 

491 version = "unknown" 

492 module_name = cls.__module__ 

493 components = module_name.split(".") 

494 while components: 

495 # This class has already been imported so importing it 

496 # should work. 

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

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

499 version = getattr(module, v) 

500 if version == "unknown": 

501 # LSST software will have a fingerprint 

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

503 version = getattr(module, v) 

504 break 

505 else: 

506 # Remove last component from module name and try again 

507 components.pop() 

508 

509 _VERSION_CACHE[cls] = version 

510 return version 

511 

512 @classmethod 

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

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

515 

516 Parameters 

517 ---------- 

518 header : `dict` 

519 The header to correct. Correction is in place. 

520 instrument : `str` 

521 The name of the instrument. 

522 obsid : `str` 

523 Unique observation identifier associated with this header. 

524 Will always be provided. 

525 filename : `str`, optional 

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

527 can be fixed independently of any filename being known. 

528 

529 Returns 

530 ------- 

531 modified : `bool` 

532 `True` if a correction was applied. 

533 

534 Notes 

535 ----- 

536 This method is intended to support major discrepancies in headers 

537 such as: 

538 

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

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

541 the existing value or understanding the that correction is static 

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

543 known. 

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

545 corrected with a new static value regardless of date. 

546 

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

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

549 

550 Usually called from `astro_metadata_translator.fix_header`. 

551 

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

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

554 ``filename`` is `None`. 

555 """ 

556 return False 

557 

558 @staticmethod 

559 def _construct_log_prefix(obsid, filename=None): 

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

561 

562 Parameters 

563 ---------- 

564 obsid : `str` 

565 The observation identifier. 

566 filename : `str`, optional 

567 The filename associated with the header being translated. 

568 Can be `None`. 

569 """ 

570 if filename: 

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

572 return obsid 

573 

574 @property 

575 def _log_prefix(self): 

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

577 useful context. 

578 

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

580 on whether a filename is known. 

581 

582 Returns 

583 ------- 

584 prefix : `str` 

585 The prefix to use. 

586 """ 

587 if self._log_prefix_cache is None: 

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

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

590 # message from appearing. 

591 try: 

592 obsid = self.to_observation_id() 

593 except Exception: 

594 obsid = "unknown_obsid" 

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

596 return self._log_prefix_cache 

597 

598 def _used_these_cards(self, *args): 

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

600 

601 Parameters 

602 ---------- 

603 args : sequence of `str` 

604 Keywords used to process a translation. 

605 """ 

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

607 

608 def cards_used(self): 

609 """Cards used during metadata extraction. 

610 

611 Returns 

612 ------- 

613 used : `frozenset` of `str` 

614 Cards used when extracting metadata. 

615 """ 

616 return frozenset(self._used_cards) 

617 

618 @staticmethod 

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

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

621 

622 Parameters 

623 ---------- 

624 value : `float` 

625 Value to be validated. 

626 default : `float` 

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

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

629 header. 

630 minimum : `float` 

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

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

633 maximum : `float` 

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

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

636 

637 Returns 

638 ------- 

639 value : `float` 

640 Either the supplied value, or a default value. 

641 """ 

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

643 value = default 

644 else: 

645 if minimum is not None and value < minimum: 

646 value = default 

647 elif maximum is not None and value > maximum: 

648 value = default 

649 return value 

650 

651 @staticmethod 

652 def is_keyword_defined(header, keyword): 

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

654 present in the supplied header and defined. 

655 

656 Parameters 

657 ---------- 

658 header : `dict`-lik 

659 Header to use as reference. 

660 keyword : `str` 

661 Keyword to check against header. 

662 

663 Returns 

664 ------- 

665 is_defined : `bool` 

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

667 """ 

668 if keyword not in header: 

669 return False 

670 

671 if header[keyword] is None: 

672 return False 

673 

674 # Special case Astropy undefined value 

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

676 return False 

677 

678 return True 

679 

680 def resource_root(self): 

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

682 installed package. 

683 

684 Returns 

685 ------- 

686 resource_package : `str` 

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

688 used. 

689 resource_root : `str` 

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

691 are to be used. 

692 """ 

693 return (self.default_resource_package, self.default_resource_root) 

694 

695 def search_paths(self): 

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

697 files. 

698 

699 Returns 

700 ------- 

701 paths : `list` 

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

703 directories are defined. 

704 

705 Notes 

706 ----- 

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

708 """ 

709 if self.default_search_path is not None: 

710 return [self.default_search_path] 

711 return [] 

712 

713 def is_key_ok(self, keyword): 

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

715 present in this header and defined. 

716 

717 Parameters 

718 ---------- 

719 keyword : `str` 

720 Keyword to check against header. 

721 

722 Returns 

723 ------- 

724 is_ok : `bool` 

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

726 """ 

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

728 

729 def are_keys_ok(self, keywords): 

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

731 

732 Parameters 

733 ---------- 

734 keywords : iterable of `str` 

735 Keywords to test. 

736 

737 Returns 

738 ------- 

739 all_ok : `bool` 

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

741 """ 

742 for k in keywords: 

743 if not self.is_key_ok(k): 

744 return False 

745 return True 

746 

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

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

749 

750 Parameters 

751 ---------- 

752 keywords : `str` or `list` of `str` 

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

754 in turn until one matches. 

755 unit : `astropy.units.UnitBase` 

756 Unit of the item in the header. 

757 default : `float`, optional 

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

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

760 None, no default value is used. 

761 minimum : `float`, optional 

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

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

764 maximum : `float`, optional 

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

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

767 checker : `function`, optional 

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

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

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

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

772 `KeyError`. 

773 

774 Returns 

775 ------- 

776 q : `astropy.units.Quantity` 

777 Quantity representing the header value. 

778 

779 Raises 

780 ------ 

781 KeyError 

782 The supplied header key is not present. 

783 """ 

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

785 for k in keywords: 

786 if self.is_key_ok(k): 

787 value = self._header[k] 

788 keyword = k 

789 break 

790 else: 

791 if checker is not None: 

792 try: 

793 checker(self) 

794 value = default 

795 if value is not None: 

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

797 return value 

798 except Exception: 

799 pass 

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

801 if isinstance(value, str): 

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

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

804 value = float(value) 

805 self._used_these_cards(keyword) 

806 if default is not None: 

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

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

809 

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

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

812 

813 Parameters 

814 ---------- 

815 keywords : iterable of `str` 

816 Keywords to look for in header. 

817 delim : `str`, optional 

818 Character to use to join the values together. 

819 

820 Returns 

821 ------- 

822 joined : `str` 

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

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

825 defined keywords found. 

826 """ 

827 values = [] 

828 for k in keywords: 

829 if self.is_key_ok(k): 

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

831 self._used_these_cards(k) 

832 

833 if values: 

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

835 else: 

836 joined = "" 

837 

838 return joined 

839 

840 @cache_translation 

841 def to_detector_unique_name(self): 

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

843 

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

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

846 

847 Can be over-ridden by specialist translator class. 

848 

849 Returns 

850 ------- 

851 name : `str` 

852 ``detector_group``_``detector_name`` if ``detector_group`` is 

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

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

855 

856 Raises 

857 ------ 

858 NotImplementedError 

859 Raised if neither detector_name nor detector_group is defined. 

860 """ 

861 name = self.to_detector_name() 

862 group = self.to_detector_group() 

863 

864 if group is None and name is None: 

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

866 

867 if group is not None: 

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

869 

870 return name 

871 

872 @cache_translation 

873 def to_exposure_group(self): 

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

875 

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

877 form. A subclass may do something different. 

878 

879 Returns 

880 ------- 

881 name : `str` 

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

883 """ 

884 exposure_id = self.to_exposure_id() 

885 if exposure_id is None: 

886 return None 

887 else: 

888 return str(exposure_id) 

889 

890 @cache_translation 

891 def to_observation_reason(self): 

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

893 

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

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

896 A subclass may do something different. 

897 

898 Returns 

899 ------- 

900 name : `str` 

901 The reason for this observation. 

902 """ 

903 obstype = self.to_observation_type() 

904 if obstype == "science": 

905 return "science" 

906 return "unknown" 

907 

908 @cache_translation 

909 def to_observing_day(self): 

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

911 

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

913 observation. 

914 

915 Returns 

916 ------- 

917 day : `int` 

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

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

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

921 be caught elsewhere. 

922 """ 

923 datetime_begin = self.to_datetime_begin() 

924 if datetime_begin is None: 

925 return 0 

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

927 

928 @cache_translation 

929 def to_observation_counter(self): 

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

931 to other observations. 

932 

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

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

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

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

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

938 

939 Returns 

940 ------- 

941 sequence : `int` 

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

943 """ 

944 return 0 

945 

946 

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

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

949 

950 Parameters 

951 ---------- 

952 property : `str` 

953 Name of the translator for property to be created. 

954 doc : `str` 

955 Description of the property. 

956 return_typedoc : `str` 

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

958 return_type : `class` 

959 Type of this property. 

960 

961 Returns 

962 ------- 

963 m : `function` 

964 Translator method for this property. 

965 """ 

966 def to_property(self): 

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

968 

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

970 

971 {doc} 

972 

973 Returns 

974 ------- 

975 {property} : `{return_typedoc}` 

976 The translated property. 

977 """ 

978 return to_property 

979 

980 

981# Make abstract methods for all the translators methods. 

982# Unfortunately registering them as abstractmethods does not work 

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

984# Assigning to __abstractmethods__ directly does work but interacts 

985# poorly with the metaclass automatically generating methods from 

986# _trivialMap and _constMap. 

987 

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

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

990CONCRETE = set() 

991 

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

993 method = f"to_{name}" 

994 if not MetadataTranslator.defined_in_this_class(method): 

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

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

997 else: 

998 CONCRETE.add(method) 

999 

1000 

1001class StubTranslator(MetadataTranslator): 

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

1003 warnings. 

1004 

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

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

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

1008 removed from the inheritance tree. 

1009 

1010 """ 

1011 pass 

1012 

1013 

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

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

1016 base method and catches `NotImplementedError`. 

1017 

1018 Parameters 

1019 ---------- 

1020 cls : `class` 

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

1022 `StubTranslator`. 

1023 property : `str` 

1024 Name of the translator for property to be created. 

1025 doc : `str` 

1026 Description of the property. 

1027 return_typedoc : `str` 

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

1029 return_type : `class` 

1030 Type of this property. 

1031 

1032 Returns 

1033 ------- 

1034 m : `function` 

1035 Stub translator method for this property. 

1036 """ 

1037 method = f"to_{property}" 

1038 

1039 def to_stub(self): 

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

1041 try: 

1042 if parent is not None: 

1043 return parent() 

1044 except NotImplementedError: 

1045 pass 

1046 

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

1048 stacklevel=3) 

1049 return None 

1050 

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

1052 

1053 {doc} 

1054 

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

1056 `NotImplementedError` issues a warning reminding the implementer to 

1057 override this method. 

1058 

1059 Returns 

1060 ------- 

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

1062 Always returns `None`. 

1063 """ 

1064 return to_stub 

1065 

1066 

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

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

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

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

1071 name, *description[:3]))