Coverage for python/astro_metadata_translator/observationInfo.py: 17%

Shortcuts 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

183 statements  

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"""Represent standard metadata from instrument headers""" 

13 

14__all__ = ("ObservationInfo", "makeObservationInfo") 

15 

16import itertools 

17import logging 

18import copy 

19import json 

20import math 

21 

22import astropy.time 

23from astropy.coordinates import SkyCoord, AltAz 

24 

25from .translator import MetadataTranslator 

26from .properties import PROPERTIES 

27from .headers import fix_header 

28 

29log = logging.getLogger(__name__) 

30 

31 

32class ObservationInfo: 

33 """Standardized representation of an instrument header for a single 

34 exposure observation. 

35 

36 Parameters 

37 ---------- 

38 header : `dict`-like 

39 Representation of an instrument header accessible as a `dict`. 

40 May be updated with header corrections if corrections are found. 

41 filename : `str`, optional 

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

43 datasets with missing header information this can sometimes 

44 allow for some fixups in translations. 

45 translator_class : `MetadataTranslator`-class, optional 

46 If not `None`, the class to use to translate the supplied headers 

47 into standard form. Otherwise each registered translator class will 

48 be asked in turn if it knows how to translate the supplied header. 

49 pedantic : `bool`, optional 

50 If True the translation must succeed for all properties. If False 

51 individual property translations must all be implemented but can fail 

52 and a warning will be issued. 

53 search_path : iterable, optional 

54 Override search paths to use during header fix up. 

55 required : `set`, optional 

56 This parameter can be used to confirm that all properties contained 

57 in the set must translate correctly and also be non-None. For the case 

58 where ``pedantic`` is `True` this will still check that the resulting 

59 value is not `None`. 

60 subset : `set`, optional 

61 If not `None`, controls the translations that will be performed 

62 during construction. This can be useful if the caller is only 

63 interested in a subset of the properties and knows that some of 

64 the others might be slow to compute (for example the airmass if it 

65 has to be derived). 

66 

67 Raises 

68 ------ 

69 ValueError 

70 Raised if the supplied header was not recognized by any of the 

71 registered translators. Also raised if the request property subset 

72 is not a subset of the known properties. 

73 TypeError 

74 Raised if the supplied translator class was not a MetadataTranslator. 

75 KeyError 

76 Raised if a required property cannot be calculated, or if pedantic 

77 mode is enabled and any translations fails. 

78 NotImplementedError 

79 Raised if the selected translator does not support a required 

80 property. 

81 

82 Notes 

83 ----- 

84 Headers will be corrected if correction files are located and this will 

85 modify the header provided to the constructor. 

86 """ 

87 

88 _PROPERTIES = PROPERTIES 

89 """All the properties supported by this class with associated 

90 documentation.""" 

91 

92 def __init__(self, header, filename=None, translator_class=None, pedantic=False, 

93 search_path=None, required=None, subset=None): 

94 

95 # Initialize the empty object 

96 self._header = {} 

97 self.filename = filename 

98 self._translator = None 

99 self.translator_class_name = "<None>" 

100 

101 # To allow makeObservationInfo to work, we special case a None 

102 # header 

103 if header is None: 

104 return 

105 

106 # Fix up the header (if required) 

107 fix_header(header, translator_class=translator_class, filename=filename, 

108 search_path=search_path) 

109 

110 # Store the supplied header for later stripping 

111 self._header = header 

112 

113 if translator_class is None: 

114 translator_class = MetadataTranslator.determine_translator(header, filename=filename) 

115 elif not issubclass(translator_class, MetadataTranslator): 

116 raise TypeError(f"Translator class must be a MetadataTranslator, not {translator_class}") 

117 

118 # Create an instance for this header 

119 translator = translator_class(header, filename=filename) 

120 

121 # Store the translator 

122 self._translator = translator 

123 self.translator_class_name = translator_class.__name__ 

124 

125 # Form file information string in case we need an error message 

126 if filename: 

127 file_info = f" and file {filename}" 

128 else: 

129 file_info = "" 

130 

131 # Determine the properties of interest 

132 all_properties = set(self._PROPERTIES) 

133 if subset is not None: 

134 if not subset: 

135 raise ValueError("Cannot request no properties be calculated.") 

136 if not subset.issubset(all_properties): 

137 raise ValueError("Requested subset is not a subset of known properties. " 

138 f"Got extra: {subset - all_properties}") 

139 properties = subset 

140 else: 

141 properties = all_properties 

142 

143 if required is None: 

144 required = set() 

145 else: 

146 if not required.issubset(all_properties): 

147 raise ValueError("Requested required properties include unknowns: " 

148 f"{required - all_properties}") 

149 

150 # Loop over each property and request the translated form 

151 for t in properties: 

152 # prototype code 

153 method = f"to_{t}" 

154 property = f"_{t}" 

155 

156 try: 

157 value = getattr(translator, method)() 

158 except NotImplementedError as e: 

159 raise NotImplementedError(f"No translation exists for property '{t}'" 

160 f" using translator {translator.__class__}") from e 

161 except KeyError as e: 

162 err_msg = f"Error calculating property '{t}' using translator {translator.__class__}" \ 

163 f"{file_info}" 

164 if pedantic or t in required: 

165 raise KeyError(err_msg) from e 

166 else: 

167 log.debug("Calculation of property '%s' failed with header: %s", t, header) 

168 log.warning(f"Ignoring {err_msg}: {e}") 

169 continue 

170 

171 if not self._is_property_ok(t, value): 

172 err_msg = f"Value calculated for property '{t}' is wrong type " \ 

173 f"({type(value)} != {self._PROPERTIES[t][1]}) using translator {translator.__class__}" \ 

174 f"{file_info}" 

175 if pedantic or t in required: 

176 raise TypeError(err_msg) 

177 else: 

178 log.debug("Calcuation of property '%s' had unexpected type with header: %s", t, header) 

179 log.warning(f"Ignoring {err_msg}") 

180 

181 if value is None and t in required: 

182 raise KeyError(f"Calculation of required property {t} resulted in a value of None") 

183 

184 setattr(self, property, value) 

185 

186 @classmethod 

187 def _is_property_ok(cls, property, value): 

188 """Compare the supplied value against the expected type as defined 

189 for the corresponding property. 

190 

191 Parameters 

192 ---------- 

193 property : `str` 

194 Name of property. 

195 value : `object` 

196 Value of the property to validate. 

197 

198 Returns 

199 ------- 

200 is_ok : `bool` 

201 `True` if the value is of an appropriate type. 

202 

203 Notes 

204 ----- 

205 Currently only the type of the property is validated. There is no 

206 attempt to check bounds or determine that a Quantity is compatible 

207 with the property. 

208 """ 

209 if value is None: 

210 return True 

211 

212 property_type = cls._PROPERTIES[property][2] 

213 

214 # For AltAz coordinates, they can either arrive as AltAz or 

215 # as SkyCoord(frame=AltAz) so try to find the frame inside 

216 # the SkyCoord. 

217 if issubclass(property_type, AltAz) and isinstance(value, SkyCoord): 

218 value = value.frame 

219 

220 if not isinstance(value, property_type): 

221 return False 

222 

223 return True 

224 

225 @property 

226 def cards_used(self): 

227 """Header cards used for the translation. 

228 

229 Returns 

230 ------- 

231 used : `frozenset` of `str` 

232 Set of card used. 

233 """ 

234 if not self._translator: 

235 return frozenset() 

236 return self._translator.cards_used() 

237 

238 def stripped_header(self): 

239 """Return a copy of the supplied header with used keywords removed. 

240 

241 Returns 

242 ------- 

243 stripped : `dict`-like 

244 Same class as header supplied to constructor, but with the 

245 headers used to calculate the generic information removed. 

246 """ 

247 hdr = copy.copy(self._header) 

248 used = self.cards_used 

249 for c in used: 

250 del hdr[c] 

251 return hdr 

252 

253 def __str__(self): 

254 # Put more interesting answers at front of list 

255 # and then do remainder 

256 priority = ("instrument", "telescope", "datetime_begin") 

257 properties = sorted(set(self._PROPERTIES.keys()) - set(priority)) 

258 

259 result = "" 

260 for p in itertools.chain(priority, properties): 

261 value = getattr(self, p) 

262 if isinstance(value, astropy.time.Time): 

263 value.format = "isot" 

264 value = str(value.value) 

265 result += f"{p}: {value}\n" 

266 

267 return result 

268 

269 def __eq__(self, other): 

270 """Compares equal if standard properties are equal 

271 """ 

272 if not isinstance(other, ObservationInfo): 

273 return NotImplemented 

274 

275 # Compare simplified forms. 

276 # Cannot compare directly because nan will not equate as equal 

277 # whereas they should be equal for our purposes 

278 self_simple = self.to_simple() 

279 other_simple = other.to_simple() 

280 

281 for k, self_value in self_simple.items(): 

282 other_value = other_simple[k] 

283 if self_value != other_value: 

284 if math.isnan(self_value) and math.isnan(other_value): 

285 # If both are nan this is fine 

286 continue 

287 return False 

288 return True 

289 

290 def __lt__(self, other): 

291 return self.datetime_begin < other.datetime_begin 

292 

293 def __gt__(self, other): 

294 return self.datetime_begin > other.datetime_begin 

295 

296 def __getstate__(self): 

297 """Get pickleable state 

298 

299 Returns the properties, the name of the translator, and the 

300 cards that were used. Does not return the full header. 

301 

302 Returns 

303 ------- 

304 state : `dict` 

305 Dict containing items that can be persisted. 

306 """ 

307 state = dict() 

308 for p in self._PROPERTIES: 

309 property = f"_{p}" 

310 state[p] = getattr(self, property) 

311 

312 return state 

313 

314 def __setstate__(self, state): 

315 for p in self._PROPERTIES: 

316 property = f"_{p}" 

317 setattr(self, property, state[p]) 

318 

319 def to_simple(self): 

320 """Convert the contents of this object to simple dict form. 

321 

322 The keys of the dict are the standard properties but the values 

323 can be simplified to support JSON serialization. For example a 

324 SkyCoord might be represented as an ICRS RA/Dec tuple rather than 

325 a full SkyCoord representation. 

326 

327 Any properties with `None` value will be skipped. 

328 

329 Can be converted back to an `ObservationInfo` using `from_simple()`. 

330 

331 Returns 

332 ------- 

333 simple : `dict` of [`str`, `Any`] 

334 Simple dict of all properties. 

335 """ 

336 simple = {} 

337 

338 for p in self._PROPERTIES: 

339 property = f"_{p}" 

340 value = getattr(self, property) 

341 if value is None: 

342 continue 

343 

344 # Access the function to simplify the property 

345 simplifier = self._PROPERTIES[p][3] 

346 

347 if simplifier is None: 

348 simple[p] = value 

349 continue 

350 

351 simple[p] = simplifier(value) 

352 

353 return simple 

354 

355 def to_json(self): 

356 """Serialize the object to JSON string. 

357 

358 Returns 

359 ------- 

360 j : `str` 

361 The properties of the ObservationInfo in JSON string form. 

362 """ 

363 return json.dumps(self.to_simple()) 

364 

365 @classmethod 

366 def from_simple(cls, simple): 

367 """Convert the entity returned by `to_simple` back into an 

368 `ObservationInfo`. 

369 

370 Parameters 

371 ---------- 

372 simple : `dict` [`str`, `Any`] 

373 The dict returned by `to_simple()` 

374 

375 Returns 

376 ------- 

377 obsinfo : `ObservationInfo` 

378 New object constructed from the dict. 

379 """ 

380 processed = {} 

381 for k, v in simple.items(): 

382 

383 if v is None: 

384 continue 

385 

386 # Access the function to convert from simple form 

387 complexifier = cls._PROPERTIES[k][4] 

388 

389 if complexifier is not None: 

390 v = complexifier(v, **processed) 

391 

392 processed[k] = v 

393 

394 return cls.makeObservationInfo(**processed) 

395 

396 @classmethod 

397 def from_json(cls, json_str): 

398 """Create `ObservationInfo` from JSON string. 

399 

400 Parameters 

401 ---------- 

402 json_str : `str` 

403 The JSON representation. 

404 

405 Returns 

406 ------- 

407 obsinfo : `ObservationInfo` 

408 Reconstructed object. 

409 """ 

410 simple = json.loads(json_str) 

411 return cls.from_simple(simple) 

412 

413 @classmethod 

414 def makeObservationInfo(cls, **kwargs): # noqa: N802 

415 """Construct an `ObservationInfo` from the supplied parameters. 

416 

417 Notes 

418 ----- 

419 The supplied parameters should use names matching the property. 

420 The type of the supplied value will be checked against the property. 

421 Any properties not supplied will be assigned a value of `None`. 

422 

423 Raises 

424 ------ 

425 KeyError 

426 Raised if a supplied parameter key is not a known property. 

427 TypeError 

428 Raised if a supplied value does not match the expected type 

429 of the property. 

430 """ 

431 

432 obsinfo = cls(None) 

433 

434 unused = set(kwargs) 

435 

436 for p in cls._PROPERTIES: 

437 if p in kwargs: 

438 property = f"_{p}" 

439 value = kwargs[p] 

440 if not cls._is_property_ok(p, value): 

441 raise TypeError(f"Supplied value {value} for property {p} " 

442 f"should be of class {cls._PROPERTIES[p][1]} not {value.__class__}") 

443 setattr(obsinfo, property, value) 

444 unused.remove(p) 

445 

446 if unused: 

447 n = len(unused) 

448 raise KeyError(f"Unrecognized propert{'y' if n == 1 else 'ies'} provided: {', '.join(unused)}") 

449 

450 return obsinfo 

451 

452 

453# Method to add the standard properties 

454def _make_property(property, doc, return_typedoc, return_type): 

455 """Create a getter method with associated docstring. 

456 

457 Parameters 

458 ---------- 

459 property : `str` 

460 Name of the property getter to be created. 

461 doc : `str` 

462 Description of this property. 

463 return_typedoc : `str` 

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

465 return_type : `class` 

466 Type of this property. 

467 

468 Returns 

469 ------- 

470 p : `function` 

471 Getter method for this property. 

472 """ 

473 def getter(self): 

474 return getattr(self, f"_{property}") 

475 

476 getter.__doc__ = f"""{doc} 

477 

478 Returns 

479 ------- 

480 {property} : `{return_typedoc}` 

481 Access the property. 

482 """ 

483 return getter 

484 

485 

486# Initialize the internal properties (underscored) and add the associated 

487# getter methods. 

488for name, description in ObservationInfo._PROPERTIES.items(): 

489 setattr(ObservationInfo, f"_{name}", None) 

490 setattr(ObservationInfo, name, property(_make_property(name, *description[:3]))) 

491 

492 

493def makeObservationInfo(**kwargs): # noqa: N802 

494 """Construct an `ObservationInfo` from the supplied parameters. 

495 

496 Notes 

497 ----- 

498 The supplied parameters should use names matching the property. 

499 The type of the supplied value will be checked against the property. 

500 Any properties not supplied will be assigned a value of `None`. 

501 

502 Raises 

503 ------ 

504 KeyError 

505 Raised if a supplied parameter key is not a known property. 

506 TypeError 

507 Raised if a supplied value does not match the expected type 

508 of the property. 

509 """ 

510 return ObservationInfo.makeObservationInfo(**kwargs)