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 logging 

19import warnings 

20import math 

21 

22import astropy.units as u 

23import astropy.io.fits.card 

24from astropy.coordinates import Angle 

25 

26from .properties import PROPERTIES 

27 

28log = logging.getLogger(__name__) 

29 

30# Location of the root of the corrections resource files 

31CORRECTIONS_RESOURCE_ROOT = "corrections" 

32 

33 

34def cache_translation(func, method=None): 

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

36 

37 Especially useful when a translation uses many other translation 

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

39 

40 Parameters 

41 ---------- 

42 func : `function` 

43 Translation method to cache. 

44 method : `str`, optional 

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

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

47 being used in a metaclass. 

48 

49 Returns 

50 ------- 

51 wrapped : `function` 

52 Method wrapped by the caching function. 

53 """ 

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

55 

56 def func_wrapper(self): 

57 if name not in self._translation_cache: 

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

59 return self._translation_cache[name] 

60 func_wrapper.__doc__ = func.__doc__ 

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

62 return func_wrapper 

63 

64 

65class MetadataTranslator: 

66 """Per-instrument metadata translation support 

67 

68 Parameters 

69 ---------- 

70 header : `dict`-like 

71 Representation of an instrument header that can be manipulated 

72 as if it was a `dict`. 

73 filename : `str`, optional 

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

75 datasets with missing header information this can sometimes 

76 allow for some fixups in translations. 

77 """ 

78 

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

80 default_search_path = None 

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

82 

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

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

85 

86 default_resource_root = None 

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

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

89 

90 _trivial_map = {} 

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

92 property to corresponding keyword.""" 

93 

94 _const_map = {} 

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

96 

97 translators = dict() 

98 """All registered metadata translation classes.""" 

99 

100 supported_instrument = None 

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

102 

103 @classmethod 

104 def defined_in_this_class(cls, name): 

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

106 this class. 

107 

108 Parameters 

109 ---------- 

110 name : `str` 

111 Name of the attribute to test. 

112 

113 Returns 

114 ------- 

115 in_class : `bool` 

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

117 specific subclass. 

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

119 but is defined in a parent class. 

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

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

122 typos in their mapping tables). 

123 

124 Notes 

125 ----- 

126 Retrieves the attribute associated with the given name. 

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

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

129 Attributes are compared using `id()`. 

130 """ 

131 # The attribute to compare. 

132 if not hasattr(cls, name): 

133 return None 

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

135 

136 # Get all the classes in the hierarchy 

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

138 

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

140 # current class 

141 mro.pop(0) 

142 

143 for parent in mro: 

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

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

146 if hasattr(parent, name): 

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

148 return False 

149 return True 

150 

151 @staticmethod 

152 def _make_const_mapping(property_key, constant): 

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

154 

155 Parameters 

156 ---------- 

157 property_key : `str` 

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

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

160 Value to return for this translator. 

161 

162 Returns 

163 ------- 

164 f : `function` 

165 Function returning the constant. 

166 """ 

167 def constant_translator(self): 

168 return constant 

169 

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

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

172 else: 

173 return_type = type(constant).__name__ 

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

175 

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

177 

178 Returns 

179 ------- 

180 translation : `{return_type}` 

181 Translated property. 

182 """ 

183 return constant_translator 

184 

185 @staticmethod 

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

187 unit=None, checker=None): 

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

189 

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

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

192 

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

194 of default parameters. 

195 

196 Parameters 

197 ---------- 

198 property_key : `str` 

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

200 header_key : `str` or `list` of `str` 

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

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

203 header styles that evolve over time. 

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

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

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

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

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

209 acceptable for this parameter. 

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

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

212 acceptable for this parameter. 

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

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

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

216 checker : `function`, optional 

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

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

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

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

221 `KeyError`. 

222 

223 Returns 

224 ------- 

225 t : `function` 

226 Function implementing a translator with the specified 

227 parameters. 

228 """ 

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

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

231 else: 

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

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

234 

235 def trivial_translator(self): 

236 if unit is not None: 

237 q = self.quantity_from_card(header_key, unit, 

238 default=default, minimum=minimum, maximum=maximum, 

239 checker=checker) 

240 # Convert to Angle if this quantity is an angle 

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

242 q = Angle(q) 

243 return q 

244 

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

246 for key in keywords: 

247 if self.is_key_ok(key): 

248 value = self._header[key] 

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

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

251 self._used_these_cards(key) 

252 break 

253 else: 

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

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

256 if checker is not None: 

257 try: 

258 checker(self) 

259 return default 

260 except Exception: 

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

262 value = default 

263 elif default is not None: 

264 value = default 

265 else: 

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

267 

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

269 # Sometimes headers represent items as integers which generically 

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

271 # written as "NaN" strings. 

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

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

274 value = casts[return_type](value) 

275 

276 return value 

277 

278 # Docstring inheritance means it is confusing to specify here 

279 # exactly which header value is being used. 

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

281 

282 Returns 

283 ------- 

284 translation : `{return_type}` 

285 Translated value derived from the header. 

286 """ 

287 return trivial_translator 

288 

289 @classmethod 

290 def __init_subclass__(cls, **kwargs): 

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

292 translator methods. 

293 

294 The method provides two facilities. Firstly, every subclass 

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

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

297 header translation is attempted. Only name translator subclasses that 

298 correspond to a complete instrument. Translation classes providing 

299 generic translation support for multiple instrument translators should 

300 not be named. 

301 

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

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

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

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

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

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

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

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

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

311 the `MetadataTranslator.quantity_from_card()` method. 

312 """ 

313 super().__init_subclass__(**kwargs) 

314 

315 # Only register classes with declared names 

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

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

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

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

320 MetadataTranslator.translators[cls.name] = cls 

321 

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

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

324 # assumed okay 

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

326 trivial_map = cls._trivial_map \ 

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

328 

329 # Check for shadowing 

330 trivials = set(trivial_map.keys()) 

331 constants = set(const_map.keys()) 

332 both = trivials & constants 

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

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

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

336 

337 all = trivials | constants 

338 for name in all: 

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

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

341 # overrides trivial. 

342 location = "by _trivial_map" 

343 if name in constants: 

344 location = "by _const_map" 

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

346 cls.__name__, name, location) 

347 

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

349 # corresponding translator methods 

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

351 kwargs = {} 

352 if type(header_key) == tuple: 

353 kwargs = header_key[1] 

354 header_key = header_key[0] 

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

356 method = f"to_{property_key}" 

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

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

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

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

361 

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

363 # corresponding translator methods 

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

365 translator = cls._make_const_mapping(property_key, constant) 

366 method = f"to_{property_key}" 

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

368 setattr(cls, method, translator) 

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

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

371 

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

373 self._header = header 

374 self.filename = filename 

375 self._used_cards = set() 

376 

377 # Prefix to use for warnings about failed translations 

378 self._log_prefix_cache = None 

379 

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

381 self._translation_cache = {} 

382 

383 @classmethod 

384 @abstractmethod 

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

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

387 supplied header. 

388 

389 Parameters 

390 ---------- 

391 header : `dict`-like 

392 Header to convert to standardized form. 

393 filename : `str`, optional 

394 Name of file being translated. 

395 

396 Returns 

397 ------- 

398 can : `bool` 

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

400 otherwise. 

401 """ 

402 raise NotImplementedError() 

403 

404 @classmethod 

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

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

407 

408 Parameters 

409 ---------- 

410 header : `dict`-like 

411 Header to convert to standardized form. 

412 options : `dict` 

413 Headers to try to determine whether this header can 

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

415 be compared with the expected value and will return that 

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

417 found. 

418 filename : `str`, optional 

419 Name of file being translated. 

420 

421 Returns 

422 ------- 

423 can : `bool` 

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

425 otherwise. 

426 

427 Notes 

428 ----- 

429 Intended to be used from within `can_translate` implementations 

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

431 from `determine_translator`. 

432 """ 

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

434 if card in header: 

435 return header[card] == value 

436 return False 

437 

438 @classmethod 

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

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

441 

442 Parameters 

443 ---------- 

444 header : `dict`-like 

445 Representation of a header. 

446 filename : `str`, optional 

447 Name of file being translated. 

448 

449 Returns 

450 ------- 

451 translator : `MetadataTranslator` 

452 Translation class that knows how to extract metadata from 

453 the supplied header. 

454 

455 Raises 

456 ------ 

457 ValueError 

458 None of the registered translation classes understood the supplied 

459 header. 

460 """ 

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

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

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

464 return trans 

465 else: 

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

467 " understood this header") 

468 

469 @classmethod 

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

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

472 

473 Parameters 

474 ---------- 

475 header : `dict` 

476 The header to correct. Correction is in place. 

477 instrument : `str` 

478 The name of the instrument. 

479 obsid : `str` 

480 Unique observation identifier associated with this header. 

481 Will always be provided. 

482 filename : `str`, optional 

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

484 can be fixed independently of any filename being known. 

485 

486 Returns 

487 ------- 

488 modified : `bool` 

489 `True` if a correction was applied. 

490 

491 Notes 

492 ----- 

493 This method is intended to support major discrepancies in headers 

494 such as: 

495 

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

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

498 the existing value or understanding the that correction is static 

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

500 known. 

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

502 corrected with a new static value regardless of date. 

503 

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

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

506 

507 Usually called from `astro_metadata_translator.fix_header`. 

508 

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

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

511 ``filename`` is `None`. 

512 """ 

513 return False 

514 

515 @staticmethod 

516 def _construct_log_prefix(obsid, filename=None): 

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

518 

519 Parameters 

520 ---------- 

521 obsid : `str` 

522 The observation identifier. 

523 filename : `str`, optional 

524 The filename associated with the header being translated. 

525 Can be `None`. 

526 """ 

527 if filename: 

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

529 return obsid 

530 

531 @property 

532 def _log_prefix(self): 

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

534 useful context. 

535 

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

537 on whether a filename is known. 

538 

539 Returns 

540 ------- 

541 prefix : `str` 

542 The prefix to use. 

543 """ 

544 if self._log_prefix_cache is None: 

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

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

547 # message from appearing. 

548 try: 

549 obsid = self.to_observation_id() 

550 except Exception: 

551 obsid = "unknown_obsid" 

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

553 return self._log_prefix_cache 

554 

555 def _used_these_cards(self, *args): 

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

557 

558 Parameters 

559 ---------- 

560 args : sequence of `str` 

561 Keywords used to process a translation. 

562 """ 

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

564 

565 def cards_used(self): 

566 """Cards used during metadata extraction. 

567 

568 Returns 

569 ------- 

570 used : `frozenset` of `str` 

571 Cards used when extracting metadata. 

572 """ 

573 return frozenset(self._used_cards) 

574 

575 @staticmethod 

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

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

578 

579 Parameters 

580 ---------- 

581 value : `float` 

582 Value to be validated. 

583 default : `float` 

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

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

586 header. 

587 minimum : `float` 

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

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

590 maximum : `float` 

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

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

593 

594 Returns 

595 ------- 

596 value : `float` 

597 Either the supplied value, or a default value. 

598 """ 

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

600 value = default 

601 else: 

602 if minimum is not None and value < minimum: 

603 value = default 

604 elif maximum is not None and value > maximum: 

605 value = default 

606 return value 

607 

608 @staticmethod 

609 def is_keyword_defined(header, keyword): 

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

611 present in the supplied header and defined. 

612 

613 Parameters 

614 ---------- 

615 header : `dict`-lik 

616 Header to use as reference. 

617 keyword : `str` 

618 Keyword to check against header. 

619 

620 Returns 

621 ------- 

622 is_defined : `bool` 

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

624 """ 

625 if keyword not in header: 

626 return False 

627 

628 if header[keyword] is None: 

629 return False 

630 

631 # Special case Astropy undefined value 

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

633 return False 

634 

635 return True 

636 

637 def resource_root(self): 

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

639 installed package. 

640 

641 Returns 

642 ------- 

643 resource_package : `str` 

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

645 used. 

646 resource_root : `str` 

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

648 are to be used. 

649 """ 

650 return (self.default_resource_package, self.default_resource_root) 

651 

652 def search_paths(self): 

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

654 files. 

655 

656 Returns 

657 ------- 

658 paths : `list` 

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

660 directories are defined. 

661 

662 Notes 

663 ----- 

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

665 """ 

666 if self.default_search_path is not None: 

667 return [self.default_search_path] 

668 return [] 

669 

670 def is_key_ok(self, keyword): 

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

672 present in this header and defined. 

673 

674 Parameters 

675 ---------- 

676 keyword : `str` 

677 Keyword to check against header. 

678 

679 Returns 

680 ------- 

681 is_ok : `bool` 

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

683 """ 

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

685 

686 def are_keys_ok(self, keywords): 

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

688 

689 Parameters 

690 ---------- 

691 keywords : iterable of `str` 

692 Keywords to test. 

693 

694 Returns 

695 ------- 

696 all_ok : `bool` 

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

698 """ 

699 for k in keywords: 

700 if not self.is_key_ok(k): 

701 return False 

702 return True 

703 

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

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

706 

707 Parameters 

708 ---------- 

709 keywords : `str` or `list` of `str` 

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

711 in turn until one matches. 

712 unit : `astropy.units.UnitBase` 

713 Unit of the item in the header. 

714 default : `float`, optional 

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

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

717 None, no default value is used. 

718 minimum : `float`, optional 

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

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

721 maximum : `float`, optional 

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

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

724 checker : `function`, optional 

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

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

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

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

729 `KeyError`. 

730 

731 Returns 

732 ------- 

733 q : `astropy.units.Quantity` 

734 Quantity representing the header value. 

735 

736 Raises 

737 ------ 

738 KeyError 

739 The supplied header key is not present. 

740 """ 

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

742 for k in keywords: 

743 if self.is_key_ok(k): 

744 value = self._header[k] 

745 keyword = k 

746 break 

747 else: 

748 if checker is not None: 

749 try: 

750 checker(self) 

751 value = default 

752 if value is not None: 

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

754 return value 

755 except Exception: 

756 pass 

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

758 if isinstance(value, str): 

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

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

761 value = float(value) 

762 self._used_these_cards(keyword) 

763 if default is not None: 

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

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

766 

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

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

769 

770 Parameters 

771 ---------- 

772 keywords : iterable of `str` 

773 Keywords to look for in header. 

774 delim : `str`, optional 

775 Character to use to join the values together. 

776 

777 Returns 

778 ------- 

779 joined : `str` 

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

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

782 defined keywords found. 

783 """ 

784 values = [] 

785 for k in keywords: 

786 if self.is_key_ok(k): 

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

788 self._used_these_cards(k) 

789 

790 if values: 

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

792 else: 

793 joined = "" 

794 

795 return joined 

796 

797 @cache_translation 

798 def to_detector_unique_name(self): 

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

800 

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

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

803 

804 Can be over-ridden by specialist translator class. 

805 

806 Returns 

807 ------- 

808 name : `str` 

809 ``detector_group``_``detector_name`` if ``detector_group`` is 

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

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

812 

813 Raises 

814 ------ 

815 NotImplementedError 

816 Raised if neither detector_name nor detector_group is defined. 

817 """ 

818 name = self.to_detector_name() 

819 group = self.to_detector_group() 

820 

821 if group is None and name is None: 

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

823 

824 if group is not None: 

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

826 

827 return name 

828 

829 @cache_translation 

830 def to_exposure_group(self): 

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

832 

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

834 form. A subclass may do something different. 

835 

836 Returns 

837 ------- 

838 name : `str` 

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

840 """ 

841 exposure_id = self.to_exposure_id() 

842 if exposure_id is None: 

843 return None 

844 else: 

845 return str(exposure_id) 

846 

847 @cache_translation 

848 def to_observation_reason(self): 

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

850 

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

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

853 A subclass may do something different. 

854 

855 Returns 

856 ------- 

857 name : `str` 

858 The reason for this observation. 

859 """ 

860 obstype = self.to_observation_type() 

861 if obstype == "science": 

862 return "science" 

863 return "unknown" 

864 

865 @cache_translation 

866 def to_observing_day(self): 

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

868 

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

870 observation. 

871 

872 Returns 

873 ------- 

874 day : `int` 

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

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

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

878 be caught elsewhere. 

879 """ 

880 datetime_begin = self.to_datetime_begin() 

881 if datetime_begin is None: 

882 return 0 

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

884 

885 @cache_translation 

886 def to_observation_counter(self): 

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

888 to other observations. 

889 

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

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

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

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

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

895 

896 Returns 

897 ------- 

898 sequence : `int` 

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

900 """ 

901 return 0 

902 

903 

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

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

906 

907 Parameters 

908 ---------- 

909 property : `str` 

910 Name of the translator for property to be created. 

911 doc : `str` 

912 Description of the property. 

913 return_typedoc : `str` 

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

915 return_type : `class` 

916 Type of this property. 

917 

918 Returns 

919 ------- 

920 m : `function` 

921 Translator method for this property. 

922 """ 

923 def to_property(self): 

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

925 

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

927 

928 {doc} 

929 

930 Returns 

931 ------- 

932 {property} : `{return_typedoc}` 

933 The translated property. 

934 """ 

935 return to_property 

936 

937 

938# Make abstract methods for all the translators methods. 

939# Unfortunately registering them as abstractmethods does not work 

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

941# Assigning to __abstractmethods__ directly does work but interacts 

942# poorly with the metaclass automatically generating methods from 

943# _trivialMap and _constMap. 

944 

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

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

947CONCRETE = set() 

948 

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

950 method = f"to_{name}" 

951 if not MetadataTranslator.defined_in_this_class(method): 

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

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

954 else: 

955 CONCRETE.add(method) 

956 

957 

958class StubTranslator(MetadataTranslator): 

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

960 warnings. 

961 

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

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

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

965 removed from the inheritance tree. 

966 

967 """ 

968 pass 

969 

970 

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

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

973 base method and catches `NotImplementedError`. 

974 

975 Parameters 

976 ---------- 

977 cls : `class` 

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

979 `StubTranslator`. 

980 property : `str` 

981 Name of the translator for property to be created. 

982 doc : `str` 

983 Description of the property. 

984 return_typedoc : `str` 

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

986 return_type : `class` 

987 Type of this property. 

988 

989 Returns 

990 ------- 

991 m : `function` 

992 Stub translator method for this property. 

993 """ 

994 method = f"to_{property}" 

995 

996 def to_stub(self): 

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

998 try: 

999 if parent is not None: 

1000 return parent() 

1001 except NotImplementedError: 

1002 pass 

1003 

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

1005 stacklevel=3) 

1006 return None 

1007 

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

1009 

1010 {doc} 

1011 

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

1013 `NotImplementedError` issues a warning reminding the implementer to 

1014 override this method. 

1015 

1016 Returns 

1017 ------- 

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

1019 Always returns `None`. 

1020 """ 

1021 return to_stub 

1022 

1023 

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

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

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

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

1028 name, *description[:3]))