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

414 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 09:05 +0000

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 

14from __future__ import annotations 

15 

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

17 

18import copy 

19import functools 

20import itertools 

21import logging 

22from collections.abc import Generator, Mapping, MutableMapping, Sequence 

23from contextlib import contextmanager 

24from typing import Any, NamedTuple, cast, overload 

25 

26import astropy.coordinates 

27import astropy.time 

28import astropy.units 

29import numpy as np 

30from lsst.resources import ResourcePath 

31from pydantic import ( 

32 BaseModel, 

33 ConfigDict, 

34 Field, 

35 GetJsonSchemaHandler, 

36 PrivateAttr, 

37 model_serializer, 

38) 

39from pydantic.json_schema import JsonSchemaValue 

40from pydantic_core import CoreSchema 

41 

42from .headers import fix_header 

43from .properties import ( 

44 PROPERTIES, 

45 AltAzAnnotated, 

46 AngleAnnotated, 

47 EarthLocationAnnotated, 

48 ExposureTimeAnnotated, 

49 FocusZAnnotated, 

50 PressureAnnotated, 

51 PropertyDefinition, 

52 SkyCoordAnnotated, 

53 TemperatureAnnotated, 

54 TimeAnnotated, 

55 TimeDeltaAnnotated, 

56) 

57from .serialize.flat_dict import FlatValue, find_flat_keys, find_translator_name, flatten_simple, unflatten 

58from .translator import MetadataTranslator 

59 

60log = logging.getLogger(__name__) 

61 

62 

63def _wire_doc(key: str, wire_form: str) -> str: 

64 """Append a wire-format note to a property's semantic doc. 

65 

66 Used only for ``Field(description=...)`` in the JSON schema; the 

67 underlying ``PROPERTIES[key].doc`` is intentionally left unit-agnostic 

68 because it is also surfaced as the docstring of auto-generated 

69 translator stub methods, where forcing a wire-format unit would be 

70 misleading. 

71 

72 Parameters 

73 ---------- 

74 key : `str` 

75 Property name in `PROPERTIES`. 

76 wire_form : `str` 

77 Short noun phrase describing the JSON-serialized form, e.g. 

78 ``"a float in meters"``. 

79 

80 Returns 

81 ------- 

82 description : `str` 

83 ``PROPERTIES[key].doc`` followed by ``"Serialized as <wire_form>."``. 

84 """ 

85 return f"{PROPERTIES[key].doc} Serialized as {wire_form}." 

86 

87 

88class ObservationInfo(BaseModel): 

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

90 exposure observation. 

91 

92 Parameters 

93 ---------- 

94 header : `dict`-like 

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

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

97 filename : `str`, optional 

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

99 datasets with missing header information this can sometimes 

100 allow for some fixups in translations. 

101 translator_class : `MetadataTranslator`-class, optional 

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

103 into standard form. Otherwise each registered translator class will 

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

105 pedantic : `bool`, optional 

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

107 individual property translations must all be implemented but can fail 

108 and a warning will be issued. Only used if a ``header`` is specified. 

109 search_path : `~collections.abc.Iterable`, optional 

110 Override search paths to use during header fix up. Only used if a 

111 ``header`` is specified. 

112 required : `set`, optional 

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

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

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

116 value is not `None`. Only used if a ``header`` is specified. 

117 subset : `set`, optional 

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

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

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

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

122 has to be derived). Only used if a ``header`` is specified. 

123 quiet : `bool`, optional 

124 If `True`, warning level log messages that would be issued in non 

125 pedantic mode are converted to debug messages. 

126 **kwargs : `typing.Any` 

127 Property name/value pairs for kwargs-based construction mode. This 

128 mode creates an `ObservationInfo` directly from supplied properties 

129 rather than by translating a header. If ``header`` is provided it is 

130 an error to also provide ``kwargs``. 

131 

132 Raises 

133 ------ 

134 ValueError 

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

136 registered translators. Also raised if the request property subset 

137 is not a subset of the known properties or if a header is given along 

138 with kwargs. 

139 TypeError 

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

141 KeyError 

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

143 mode is enabled and any translations fails. 

144 NotImplementedError 

145 Raised if the selected translator does not support a required 

146 property. 

147 

148 Notes 

149 ----- 

150 There is a core set of instrumental properties that are pre-defined. 

151 Additional properties may be defined, either through the 

152 `makeObservationInfo` factory function by providing the ``extensions`` 

153 definitions, or through the regular `ObservationInfo` constructor when 

154 the extensions have been defined in the `MetadataTranslator` for the 

155 instrument of interest (or in the provided ``translator_class``). 

156 

157 There are two forms of the constructor. If the ``header`` is given 

158 then a translator will be determined and the properties will be populated 

159 accordingly. No generic keyword arguments will be expected and the 

160 remaining parameters control the behavior of the translator. 

161 

162 If the header is not given it is assumed that the keyword arguments 

163 are direct specifications of observation properties. In this mode only 

164 the ``filename`` and ``translator_class`` parameters will be used. The 

165 latter is used to determine any extensions that are being provided, 

166 although when using standard serializations the special ``_translator`` 

167 key will be used instead to specify the name of the registered translator 

168 from which to extract extension definitions. 

169 

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

171 modify the header provided to the constructor. Modifying the supplied 

172 header after construction will modify the internal cached header. 

173 

174 Values of the properties are read-only. 

175 """ 

176 

177 model_config = ConfigDict( 

178 extra="forbid", 

179 validate_assignment=False, 

180 populate_by_name=True, 

181 serialize_by_alias=True, # Emit the ``_translator`` alias even when nested. 

182 ser_json_inf_nan="constants", # Allow for inf and nan to round trip. 

183 ) 

184 

185 translator_name: str | None = Field( 

186 default=None, 

187 alias="_translator", 

188 description="Name of the registered metadata translator class used for these data.", 

189 ) 

190 

191 telescope: str | None = Field(default=None, description=PROPERTIES["telescope"].doc) 

192 instrument: str | None = Field(default=None, description=PROPERTIES["instrument"].doc) 

193 location: EarthLocationAnnotated | None = Field( 

194 default=None, 

195 description=_wire_doc("location", "a geocentric (x, y, z) tuple of floats in meters"), 

196 ) 

197 exposure_id: int | None = Field(default=None, description=PROPERTIES["exposure_id"].doc) 

198 visit_id: int | None = Field(default=None, description=PROPERTIES["visit_id"].doc) 

199 physical_filter: str | None = Field(default=None, description=PROPERTIES["physical_filter"].doc) 

200 datetime_begin: TimeAnnotated | None = Field( 

201 default=None, 

202 description=_wire_doc("datetime_begin", "a two-element TAI Julian Date [jd1, jd2]"), 

203 ) 

204 datetime_end: TimeAnnotated | None = Field( 

205 default=None, 

206 description=_wire_doc("datetime_end", "a two-element TAI Julian Date [jd1, jd2]"), 

207 ) 

208 exposure_time: ExposureTimeAnnotated | None = Field( 

209 default=None, description=PROPERTIES["exposure_time"].doc 

210 ) 

211 exposure_time_requested: ExposureTimeAnnotated | None = Field( 

212 default=None, description=PROPERTIES["exposure_time_requested"].doc 

213 ) 

214 dark_time: ExposureTimeAnnotated | None = Field(default=None, description=PROPERTIES["dark_time"].doc) 

215 boresight_airmass: float | None = Field(default=None, description=PROPERTIES["boresight_airmass"].doc) 

216 boresight_rotation_angle: AngleAnnotated | None = Field( 

217 default=None, 

218 description=_wire_doc("boresight_rotation_angle", "a float in degrees"), 

219 ) 

220 boresight_rotation_coord: str | None = Field( 

221 default=None, description=PROPERTIES["boresight_rotation_coord"].doc 

222 ) 

223 detector_num: int | None = Field(default=None, description=PROPERTIES["detector_num"].doc) 

224 detector_name: str | None = Field(default=None, description=PROPERTIES["detector_name"].doc) 

225 detector_unique_name: str | None = Field(default=None, description=PROPERTIES["detector_unique_name"].doc) 

226 detector_serial: str | None = Field(default=None, description=PROPERTIES["detector_serial"].doc) 

227 detector_group: str | None = Field(default=None, description=PROPERTIES["detector_group"].doc) 

228 detector_exposure_id: int | None = Field(default=None, description=PROPERTIES["detector_exposure_id"].doc) 

229 focus_z: FocusZAnnotated | None = Field( 

230 default=None, 

231 description=_wire_doc("focus_z", "a float in meters"), 

232 ) 

233 object: str | None = Field(default=None, description=PROPERTIES["object"].doc) 

234 temperature: TemperatureAnnotated | None = Field( 

235 default=None, 

236 description=_wire_doc("temperature", "a float in kelvin"), 

237 ) 

238 pressure: PressureAnnotated | None = Field( 

239 default=None, 

240 description=_wire_doc("pressure", "a float in hPa"), 

241 ) 

242 relative_humidity: float | None = Field(default=None, description=PROPERTIES["relative_humidity"].doc) 

243 tracking_radec: SkyCoordAnnotated | None = Field( 

244 default=None, 

245 description=_wire_doc("tracking_radec", "an ICRS (RA, Dec) tuple of floats in degrees"), 

246 ) 

247 altaz_begin: AltAzAnnotated | None = Field( 

248 default=None, 

249 description=_wire_doc("altaz_begin", "an (azimuth, altitude) tuple of floats in degrees"), 

250 ) 

251 altaz_end: AltAzAnnotated | None = Field( 

252 default=None, 

253 description=_wire_doc("altaz_end", "an (azimuth, altitude) tuple of floats in degrees"), 

254 ) 

255 science_program: str | None = Field(default=None, description=PROPERTIES["science_program"].doc) 

256 observation_type: str | None = Field(default=None, description=PROPERTIES["observation_type"].doc) 

257 observation_id: str | None = Field(default=None, description=PROPERTIES["observation_id"].doc) 

258 observation_reason: str | None = Field(default=None, description=PROPERTIES["observation_reason"].doc) 

259 exposure_group: str | None = Field(default=None, description=PROPERTIES["exposure_group"].doc) 

260 observing_day: int | None = Field(default=None, description=PROPERTIES["observing_day"].doc) 

261 observing_day_offset: TimeDeltaAnnotated | None = Field( 

262 default=None, 

263 description=_wire_doc("observing_day_offset", "integer seconds"), 

264 ) 

265 observation_counter: int | None = Field(default=None, description=PROPERTIES["observation_counter"].doc) 

266 has_simulated_content: bool | None = Field( 

267 default=None, description=PROPERTIES["has_simulated_content"].doc 

268 ) 

269 group_counter_start: int | None = Field(default=None, description=PROPERTIES["group_counter_start"].doc) 

270 group_counter_end: int | None = Field(default=None, description=PROPERTIES["group_counter_end"].doc) 

271 can_see_sky: bool | None = Field(default=None, description=PROPERTIES["can_see_sky"].doc) 

272 

273 # Internal runtime state. These are not part of the wire format and so are 

274 # kept as PrivateAttr to keep them out of the generated JSON Schema. 

275 _filename: str | None = PrivateAttr(default=None) 

276 _translator_class_name: str = PrivateAttr(default="<None>") 

277 _extensions: dict[str, PropertyDefinition] = PrivateAttr(default_factory=dict) 

278 _all_properties: dict[str, PropertyDefinition] = PrivateAttr(default_factory=dict) 

279 _header: MutableMapping[str, Any] = PrivateAttr(default_factory=dict) 

280 _translator: MetadataTranslator | None = PrivateAttr(default=None) 

281 _sealed: bool = PrivateAttr(default=False) 

282 

283 @property 

284 def filename(self) -> str | None: 

285 """Name of the file whose header was translated, if any.""" 

286 return self._filename 

287 

288 @filename.setter 

289 def filename(self, value: str | None) -> None: 

290 self._filename = value 

291 

292 @property 

293 def translator_class_name(self) -> str: 

294 """Name of the metadata translator class used for these data.""" 

295 return self._translator_class_name 

296 

297 @property 

298 def extensions(self) -> dict[str, PropertyDefinition]: 

299 """Definitions of the translator-specific extension properties.""" 

300 return self._extensions 

301 

302 @property 

303 def all_properties(self) -> dict[str, PropertyDefinition]: 

304 """Definitions of all known properties (core plus extensions).""" 

305 return self._all_properties 

306 

307 @overload 

308 def __init__( 308 ↛ exitline 308 didn't return from function '__init__' because

309 self, 

310 header: MutableMapping[str, Any], 

311 filename: str | ResourcePath | None = None, 

312 translator_class: type[MetadataTranslator] | None = None, 

313 pedantic: bool = False, 

314 search_path: Sequence[str] | None = None, 

315 required: set[str] | None = None, 

316 subset: set[str] | None = None, 

317 quiet: bool = False, 

318 ) -> None: ... 

319 

320 @overload 

321 def __init__( 321 ↛ exitline 321 didn't return from function '__init__' because

322 self, 

323 header: None = None, 

324 filename: str | ResourcePath | None = None, 

325 translator_class: type[MetadataTranslator] | None = None, 

326 **kwargs: Any, 

327 ) -> None: ... 

328 

329 def __init__( 

330 self, 

331 header: MutableMapping[str, Any] | None = None, 

332 filename: str | ResourcePath | None = None, 

333 translator_class: type[MetadataTranslator] | None = None, 

334 pedantic: bool = False, 

335 search_path: Sequence[str] | None = None, 

336 required: set[str] | None = None, 

337 subset: set[str] | None = None, 

338 quiet: bool = False, 

339 **kwargs: Any, 

340 ) -> None: 

341 if filename is not None: 

342 filename = str(ResourcePath(filename, forceAbsolute=True)) 

343 if header is not None: 

344 if kwargs: 

345 raise ValueError( 

346 "kwargs not allowed if constructor given a header to translate. " 

347 f"Unrecognized keys: {[k for k in kwargs]}" 

348 ) 

349 self._init_from_header( 

350 header, 

351 filename=filename, 

352 translator_class=translator_class, 

353 pedantic=pedantic, 

354 search_path=search_path, 

355 required=required, 

356 subset=subset, 

357 quiet=quiet, 

358 ) 

359 return 

360 

361 self._init_from_kwargs(filename=filename, translator_class=translator_class, **kwargs) 

362 

363 def _init_from_header( 

364 self, 

365 header: MutableMapping[str, Any], 

366 *, 

367 filename: str | None, 

368 translator_class: type[MetadataTranslator] | None, 

369 pedantic: bool, 

370 search_path: Sequence[str] | None, 

371 required: set[str] | None, 

372 subset: set[str] | None, 

373 quiet: bool, 

374 ) -> None: 

375 super().__init__() 

376 self._filename = filename 

377 self._sealed = False 

378 # Initialize the empty object 

379 self._header = {} 

380 self._translator = None 

381 failure_level = logging.DEBUG if quiet else logging.WARNING 

382 

383 # Look for translator class before header fixup. fix_header calls 

384 # determine_translator immediately on the basis that you need to know 

385 # enough of the header to work out the translator before you can fix 

386 # it up. There is no gain in asking fix_header to determine the 

387 # translator and then trying to work it out again here. 

388 if translator_class is None: 

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

390 elif not issubclass(translator_class, MetadataTranslator): 

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

392 

393 # Fix up the header (if required) 

394 fix_header(header, translator_class=translator_class, filename=filename, search_path=search_path) 

395 

396 # Store the supplied header for later stripping 

397 self._header = header 

398 

399 # This configures both self.extensions and self.all_properties. 

400 self._declare_extensions(translator_class.extensions) 

401 

402 # Create an instance for this header 

403 translator = translator_class(header, filename=filename) 

404 

405 # Store the translator 

406 self._translator = translator 

407 self._translator_class_name = translator_class.__name__ 

408 self.translator_name = translator_class.name 

409 

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

411 if filename: 

412 file_info = f" and file {filename}" 

413 else: 

414 file_info = "" 

415 

416 # Determine the properties of interest 

417 full_set = set(self.all_properties) 

418 if subset is not None: 

419 if not subset: 

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

421 if not subset.issubset(full_set): 

422 raise ValueError( 

423 f"Requested subset is not a subset of known properties. Got extra: {subset - full_set}" 

424 ) 

425 properties = subset 

426 else: 

427 properties = full_set 

428 

429 if required is None: 

430 required = set() 

431 else: 

432 if not required.issubset(full_set): 

433 raise ValueError(f"Requested required properties include unknowns: {required - full_set}") 

434 

435 # Loop over each property and request the translated form 

436 for property in properties: 

437 # prototype code 

438 method = f"to_{property}" 

439 

440 try: 

441 value = getattr(translator, method)() 

442 except NotImplementedError as e: 

443 raise NotImplementedError( 

444 f"No translation exists for property '{property}' using translator {translator.__class__}" 

445 ) from e 

446 except Exception as e: 

447 err_msg = ( 

448 f"Error calculating property '{property}' using " 

449 f"translator {translator.__class__}{file_info}" 

450 ) 

451 if pedantic or property in required: 

452 raise KeyError(err_msg) from e 

453 else: 

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

455 log.log(failure_level, f"Ignoring {err_msg}: {e}") 

456 continue 

457 

458 definition = self.all_properties[property] 

459 # Some translators can return a compatible form that needs to 

460 # be coerced to the correct type (e.g., returning SkyCoord when you 

461 # need AltAz). In theory we could patch the translators to return 

462 # AltAz but code has historically not been as picky about this 

463 # until pydantic turned up. 

464 value = self._coerce_from_simple(definition, value, {}) 

465 if not definition.is_value_conformant(value): 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true

466 err_msg = ( 

467 f"Value calculated for property '{property}' is wrong type " 

468 f"({type(value)} != {definition.str_type}) using translator {translator.__class__}" 

469 f"{file_info}" 

470 ) 

471 if pedantic or property in required: 

472 raise TypeError(err_msg) 

473 else: 

474 log.debug( 

475 "Calculation of property '%s' had unexpected type with header: %s", property, header 

476 ) 

477 log.log(failure_level, f"Ignoring {err_msg}") 

478 

479 if value is None and property in required: 

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

481 

482 object.__setattr__(self, property, value) # allows setting even write-protected extensions 

483 

484 self._sealed = True 

485 

486 def _init_from_kwargs( 

487 self, 

488 *, 

489 filename: str | None, 

490 translator_class: type[MetadataTranslator] | None, 

491 **kwargs: Any, 

492 ) -> None: 

493 supplied_keys = set(kwargs) 

494 # Accept both the wire-format alias ``_translator`` and the field name 

495 # ``translator_name`` for robustness against callers that pass the 

496 # field name directly. 

497 translator_name = kwargs.pop("_translator", None) or kwargs.pop("translator_name", None) 

498 supplied_extensions = kwargs.pop("_extensions", None) 

499 if translator_name is not None and translator_name in MetadataTranslator.translators: 

500 translator_class = MetadataTranslator.translators[translator_name] 

501 

502 if translator_class is not None and not issubclass(translator_class, MetadataTranslator): 

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

504 

505 if supplied_extensions is not None: 

506 if translator_class is not None: 

507 raise ValueError("Provide either translator_class or _extensions, not both.") 

508 if not isinstance(supplied_extensions, dict): 

509 raise TypeError("_extensions must be a dictionary of PropertyDefinition entries.") 

510 extensions = supplied_extensions 

511 else: 

512 extensions = translator_class.extensions if translator_class is not None else {} 

513 

514 all_properties = self._get_all_properties(extensions) 

515 unrecognized = [key for key in kwargs if key not in all_properties] 

516 if unrecognized: 

517 # Extension properties (the ``ext_`` prefixed keys) are defined by 

518 # the translator. When the translator could not be resolved its 

519 # extension definitions are unavailable, so report that as the 

520 # underlying cause rather than an opaque unknown property. 

521 extension_keys = sorted(key for key in unrecognized if key.startswith("ext_")) 

522 if extension_keys and translator_class is None: 

523 reason = ( 

524 f"translator '{translator_name}' is not registered" 

525 if translator_name is not None 

526 else "no translator was specified" 

527 ) 

528 raise KeyError( 

529 f"Unrecognized extension properties {extension_keys}: {reason}, so the " 

530 "extension property definitions are unavailable." 

531 ) 

532 text = ( 

533 f"property ({unrecognized[0]})" 

534 if len(unrecognized) == 1 

535 else f"properties ({', '.join(unrecognized)})" 

536 ) 

537 raise KeyError(f"Unrecognized {text} provided") 

538 

539 processed = {k: v for k, v in kwargs.items() if k in PROPERTIES and v is not None} 

540 processed = self._apply_constructor_defaults(processed, supplied_keys) 

541 

542 super().__init__(**processed) 

543 self._filename = filename 

544 self._sealed = False 

545 

546 # This configures both self.extensions and self.all_properties. 

547 self._declare_extensions(extensions) 

548 

549 # Handle extensions. 

550 ext_input = {k: v for k, v in kwargs.items() if k.startswith("ext_")} 

551 processed_ext = self._validate_property_mapping(ext_input, extensions) 

552 for key, value in processed_ext.items(): 

553 object.__setattr__(self, key, value) 

554 

555 if translator_class is not None: 

556 self._translator = translator_class({}) 

557 self._translator_class_name = translator_class.__name__ 

558 self.translator_name = translator_class.name 

559 elif translator_name is not None: 

560 # The named translator is not registered in this process. The 

561 # translator instance is only needed while constructing from a 

562 # header, so it is left as None and the name from the serialization 

563 # is recorded directly. 

564 self._translator_class_name = translator_name 

565 self.translator_name = translator_name 

566 

567 self._sealed = True 

568 

569 @staticmethod 

570 def _apply_constructor_defaults(processed: dict[str, Any], supplied_keys: set[str]) -> dict[str, Any]: 

571 """Apply derived/default values for kwargs-style construction. 

572 

573 Parameters 

574 ---------- 

575 processed : `dict` [`str`, `typing.Any`] 

576 Properties validated from kwargs input. 

577 supplied_keys : `set` [`str`] 

578 Property names explicitly supplied by the caller. 

579 

580 Returns 

581 ------- 

582 updated : `dict` [`str`, `typing.Any`] 

583 Updated property mapping with defaults/backfilled values applied. 

584 """ 

585 updated = dict(processed) 

586 for key in ("group_counter_start", "group_counter_end"): 

587 if ( 

588 key not in supplied_keys 

589 and "observation_counter" in supplied_keys 

590 and "observation_counter" in updated 

591 ): 

592 updated[key] = updated["observation_counter"] 

593 if "has_simulated_content" not in supplied_keys: 

594 updated["has_simulated_content"] = False 

595 return updated 

596 

597 @classmethod 

598 def from_header( 

599 cls, 

600 header: MutableMapping[str, Any], 

601 *, 

602 filename: str | None = None, 

603 translator_class: type[MetadataTranslator] | None = None, 

604 pedantic: bool = False, 

605 search_path: Sequence[str] | None = None, 

606 required: set[str] | None = None, 

607 subset: set[str] | None = None, 

608 quiet: bool = False, 

609 ) -> ObservationInfo: 

610 """Create an `ObservationInfo` by translating a metadata header. 

611 

612 Parameters 

613 ---------- 

614 header : `dict`-like 

615 Header mapping to translate. 

616 filename : `str`, optional 

617 Name of file associated with this header. 

618 translator_class : `MetadataTranslator`-class, optional 

619 Translator class to use. If `None`, translator will be 

620 auto-determined. 

621 pedantic : `bool`, optional 

622 If `True`, translation failures are fatal. 

623 search_path : `~collections.abc.Sequence` [`str`], optional 

624 Search paths for header corrections. 

625 required : `set` [`str`], optional 

626 Properties that must be translated and non-`None`. 

627 subset : `set` [`str`], optional 

628 Restrict translation to this subset of properties. 

629 quiet : `bool`, optional 

630 If `True`, warning level log messages that would be issued in non 

631 pedantic mode are converted to debug messages. 

632 

633 Returns 

634 ------- 

635 obsinfo : `ObservationInfo` 

636 Translated observation metadata. 

637 """ 

638 return cls( 

639 header=header, 

640 filename=filename, 

641 translator_class=translator_class, 

642 pedantic=pedantic, 

643 search_path=search_path, 

644 required=required, 

645 subset=subset, 

646 quiet=quiet, 

647 ) 

648 

649 @staticmethod 

650 def _get_all_properties( 

651 extensions: dict[str, PropertyDefinition] | None = None, 

652 ) -> dict[str, PropertyDefinition]: 

653 """Return the definitions of all properties. 

654 

655 Parameters 

656 ---------- 

657 extensions : `dict` [`str`: `PropertyDefinition`] 

658 List of extension property definitions, indexed by name (with no 

659 "ext_" prefix). 

660 

661 Returns 

662 ------- 

663 properties : `dict` [`str`: `PropertyDefinition`] 

664 Merged list of all property definitions, indexed by name. Extension 

665 properties will be listed with an ``ext_`` prefix. 

666 """ 

667 properties = dict(PROPERTIES) 

668 if extensions: 

669 properties.update({"ext_" + pp: dd for pp, dd in extensions.items()}) 

670 return properties 

671 

672 def _declare_extensions(self, extensions: dict[str, PropertyDefinition] | None) -> None: 

673 """Declare and set up extension properties. 

674 

675 This should always be called internally as part of the creation of a 

676 new `ObservationInfo`. 

677 

678 The core set of properties are declared as model fields at import 

679 time. Extension properties have to be configured at runtime (because 

680 we don't know what they will be until we look at the header and figure 

681 out what instrument we're dealing with), so we add them to the model 

682 and then use ``__setattr__`` to protect them as read-only. All 

683 extension properties are set to `None`. 

684 

685 Parameters 

686 ---------- 

687 extensions : `dict` [`str`: `PropertyDefinition`] 

688 List of extension property definitions, indexed by name (with no 

689 "ext_" prefix). 

690 """ 

691 if extensions: 

692 for name in extensions: 

693 field_name = "ext_" + name 

694 if not hasattr(self, field_name): 694 ↛ 692line 694 didn't jump to line 692 because the condition on line 694 was always true

695 object.__setattr__(self, field_name, None) 

696 self._extensions = extensions 

697 self._all_properties = self._get_all_properties(self._extensions) 

698 

699 def __setattr__(self, name: str, value: Any) -> Any: 

700 """Set attribute. 

701 

702 This provides read-only protection for all properties once the 

703 instance has been sealed. 

704 

705 Parameters 

706 ---------- 

707 name : `str` 

708 Name of attribute to set. 

709 value : `typing.Any` 

710 Value to set it to. 

711 """ 

712 if ( 

713 getattr(self, "_sealed", False) 

714 and hasattr(self, "all_properties") 

715 and name in self.all_properties 

716 ): 

717 raise AttributeError(f"Attribute {name} is read-only") 

718 return super().__setattr__(name, value) 

719 

720 @classmethod 

721 def __get_pydantic_json_schema__( 

722 cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler 

723 ) -> JsonSchemaValue: 

724 # The model_serializer below is mode="wrap" returning dict[str, Any]; 

725 # in serialization mode pydantic would otherwise emit a trivial 

726 # "object" schema. Hand the inner model-fields schema to the handler 

727 # so the field-level WithJsonSchema/PlainSerializer annotations are 

728 # honored. 

729 inner = core_schema.get("schema", core_schema) 

730 schema = handler(inner) 

731 schema = handler.resolve_ref_schema(schema) 

732 # Allow translator-specific extension properties under the ext_ prefix. 

733 schema["patternProperties"] = { 

734 "^ext_": {"description": "Extension property value (translator-specific)."}, 

735 } 

736 # Anything other than declared properties and ext_* keys is rejected. 

737 schema["additionalProperties"] = False 

738 return schema 

739 

740 @model_serializer(mode="wrap") 

741 def _serialize(self, handler: Any) -> dict[str, Any]: 

742 # Field serialization uses the per-field PlainSerializer annotations. 

743 # ``serialize_by_alias=True`` in model_config emits the ``_translator`` 

744 # alias instead of ``translator_name`` (and applies when nested). 

745 result: dict[str, Any] = handler(self) 

746 # Strip None entries; the wire format omits unset properties. 

747 result = {k: v for k, v in result.items() if v is not None} 

748 # Append values for any extension properties (dynamic ext_* attrs). 

749 # Kept flat for backwards compatibility with the existing wire format. 

750 for ext_name, ext_def in self._extensions.items(): 

751 field_name = f"ext_{ext_name}" 

752 value = getattr(self, field_name, None) 

753 if value is None: 753 ↛ 754line 753 didn't jump to line 754 because the condition on line 753 was never true

754 continue 

755 simplifier = ext_def.to_simple 

756 result[field_name] = simplifier(value) if simplifier else value 

757 return result 

758 

759 @classmethod 

760 def _validate_property_mapping( 

761 cls, 

762 data: MutableMapping[str, Any], 

763 extensions: dict[str, PropertyDefinition] | None, 

764 ) -> dict[str, Any]: 

765 # Validate extension properties. 

766 properties = {f"ext_{name}": definition for name, definition in (extensions or {}).items()} 

767 processed: dict[str, Any] = {} 

768 

769 for key, value in data.items(): 

770 if key not in properties: 770 ↛ 771line 770 didn't jump to line 771 because the condition on line 770 was never true

771 raise KeyError(f"Unrecognized property '{key}' provided") 

772 if value is None: 

773 continue 

774 processed[key] = cls._coerce_property_value(key, value, properties, processed) 

775 return processed 

776 

777 @classmethod 

778 def _coerce_property_value( 

779 cls, 

780 key: str, 

781 value: Any, 

782 properties: dict[str, PropertyDefinition], 

783 processed: dict[str, Any], 

784 ) -> Any: 

785 definition = properties[key] 

786 converted = cls._coerce_from_simple(definition, value, processed) 

787 if not definition.is_value_conformant(converted): 

788 raise TypeError( 

789 f"Supplied value {value} for property {key} " 

790 f"should be of class {definition.str_type} not {converted.__class__}" 

791 ) 

792 return converted 

793 

794 @classmethod 

795 def _coerce_from_simple( 

796 cls, 

797 definition: PropertyDefinition, 

798 value: Any, 

799 processed: dict[str, Any], 

800 ) -> Any: 

801 if definition.is_value_conformant(value): 

802 return value 

803 complexifier = definition.from_simple 

804 if complexifier is None: 804 ↛ 807line 804 didn't jump to line 807 because the condition on line 804 was always true

805 # Not the correct type, assumes caller will check. 

806 return value 

807 return complexifier(value, **processed) 

808 

809 @property 

810 def cards_used(self) -> frozenset[str]: 

811 """Header cards used for the translation. 

812 

813 Returns 

814 ------- 

815 used : `frozenset` of `str` 

816 Set of card used. 

817 """ 

818 if not self._translator: 

819 return frozenset() 

820 return self._translator.cards_used() 

821 

822 def stripped_header(self) -> MutableMapping[str, Any]: 

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

824 

825 Returns 

826 ------- 

827 stripped : `dict`-like 

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

829 headers used to calculate the generic information removed. 

830 """ 

831 hdr = copy.copy(self._header) 

832 used = self.cards_used 

833 for c in used: 

834 if c in hdr: 

835 del hdr[c] 

836 return hdr 

837 

838 def __str__(self) -> str: 

839 # Put more interesting answers at front of list 

840 # and then do remainder 

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

842 properties = sorted(set(self.all_properties) - set(priority)) 

843 

844 result = "" 

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

846 value = getattr(self, p) 

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

848 value.format = "isot" 

849 value = str(value.value) 

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

851 

852 return result 

853 

854 def __eq__(self, other: Any) -> bool: 

855 """Check equality with another object. 

856 

857 Compares equal if standard properties are equal. 

858 

859 Parameters 

860 ---------- 

861 other : `typing.Any` 

862 Thing to compare with. 

863 """ 

864 if not isinstance(other, ObservationInfo): 

865 return NotImplemented 

866 

867 # Compare simplified forms. 

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

869 # whereas they should be equal for our purposes 

870 self_simple = self.to_simple() 

871 other_simple = other.to_simple() 

872 

873 # We don't care about the translator internal detail 

874 self_simple.pop("_translator", None) 

875 other_simple.pop("_translator", None) 

876 

877 for k in self_simple.keys() & other_simple.keys(): 

878 self_value = self_simple[k] 

879 other_value = other_simple[k] 

880 if self_value != other_value: 

881 if isinstance(self_value, float) or ( 

882 isinstance(self_value, tuple) and isinstance(self_value[0], float) 

883 ): 

884 close = np.allclose(self_value, other_value, equal_nan=True) 

885 if close: 

886 continue 

887 return False 

888 return True 

889 

890 def __lt__(self, other: Any) -> bool: 

891 if not isinstance(other, ObservationInfo): 

892 return NotImplemented 

893 if self.datetime_begin is None or other.datetime_begin is None: 

894 raise TypeError("Cannot compare ObservationInfo without datetime_begin values") 

895 return self.datetime_begin < other.datetime_begin 

896 

897 def __gt__(self, other: Any) -> bool: 

898 if not isinstance(other, ObservationInfo): 

899 return NotImplemented 

900 if self.datetime_begin is None or other.datetime_begin is None: 

901 raise TypeError("Cannot compare ObservationInfo without datetime_begin values") 

902 return self.datetime_begin > other.datetime_begin 

903 

904 def __getstate__(self) -> dict[str, Any]: 

905 """Get pickleable state. 

906 

907 Returns the properties. Deliberately does not preserve the full 

908 current state; in particular, does not return the full header or 

909 translator. 

910 

911 Returns 

912 ------- 

913 state : `dict` 

914 Pickled state. 

915 """ 

916 state: dict[str, Any] = {} 

917 for p in self.all_properties: 

918 state[p] = getattr(self, p) 

919 

920 return {"state": state, "extensions": self.extensions} 

921 

922 def __setstate__(self, state: dict[Any, Any]) -> None: 

923 """Set object state from pickle. 

924 

925 Parameters 

926 ---------- 

927 state : `dict` 

928 Pickled state. 

929 """ 

930 state_any = cast(Any, state) 

931 if isinstance(state_any, dict) and "state" in state_any: 931 ↛ 935line 931 didn't jump to line 935 because the condition on line 931 was always true

932 state = state_any["state"] 

933 extensions = state_any.get("extensions", {}) 

934 else: 

935 try: 

936 state, extensions = state_any 

937 except ValueError: 

938 # Backwards compatibility for pickles generated before DM-34175 

939 extensions = {} 

940 super().__init__() 

941 self._sealed = False 

942 self._declare_extensions(extensions) 

943 for p in self.all_properties: 

944 # allows setting even write-protected extensions 

945 object.__setattr__(self, p, state[p]) 

946 self._sealed = True 

947 

948 def to_simple(self) -> MutableMapping[str, Any]: 

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

950 

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

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

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

954 a full SkyCoord representation. 

955 

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

957 

958 Can be converted back to an `ObservationInfo` using `from_simple`. 

959 

960 Returns 

961 ------- 

962 simple : `dict` of [`str`, `~typing.Any`] 

963 Simple dict of all properties. 

964 

965 Notes 

966 ----- 

967 Round-tripping of extension properties requires that the 

968 `ObservationInfo` was created with the help of a registered 

969 `MetadataTranslator` (which contains the extension property 

970 definitions). 

971 """ 

972 return self.model_dump(mode="python") 

973 

974 def to_json(self) -> str: 

975 """Serialize the object to JSON string. 

976 

977 Returns 

978 ------- 

979 j : `str` 

980 The properties of the ObservationInfo in JSON string form. 

981 

982 Notes 

983 ----- 

984 Round-tripping of extension properties requires that the 

985 `ObservationInfo` was created with the help of a registered 

986 `MetadataTranslator` (which contains the extension property 

987 definitions). 

988 """ 

989 return self.model_dump_json() 

990 

991 @classmethod 

992 def from_simple(cls, simple: MutableMapping[str, Any]) -> ObservationInfo: 

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

994 `ObservationInfo`. 

995 

996 Parameters 

997 ---------- 

998 simple : `dict` [`str`, `~typing.Any`] 

999 The dict returned by `to_simple`. 

1000 

1001 Returns 

1002 ------- 

1003 obsinfo : `ObservationInfo` 

1004 New object constructed from the dict. 

1005 

1006 Notes 

1007 ----- 

1008 Round-tripping of extension properties requires that the 

1009 `ObservationInfo` was created with the help of a registered 

1010 `MetadataTranslator` (which contains the extension property 

1011 definitions). 

1012 """ 

1013 return cls.model_validate(simple) 

1014 

1015 @classmethod 

1016 def from_json(cls, json_str: str) -> ObservationInfo: 

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

1018 

1019 Parameters 

1020 ---------- 

1021 json_str : `str` 

1022 The JSON representation. 

1023 

1024 Returns 

1025 ------- 

1026 obsinfo : `ObservationInfo` 

1027 Reconstructed object. 

1028 

1029 Notes 

1030 ----- 

1031 Round-tripping of extension properties requires that the 

1032 `ObservationInfo` was created with the help of a registered 

1033 `MetadataTranslator` (which contains the extension property 

1034 definitions). 

1035 """ 

1036 return cls.model_validate_json(json_str) 

1037 

1038 def to_flat_dict( 

1039 self, 

1040 *, 

1041 prefix: str = "", 

1042 sep: str = ".", 

1043 use_upper: bool | None = None, 

1044 encode_non_finite: bool = False, 

1045 ) -> dict[str, FlatValue]: 

1046 """Convert the contents of this object to a flat dict of scalars. 

1047 

1048 Parameters 

1049 ---------- 

1050 prefix : `str`, optional 

1051 Prefix placed before every key. Emitted exactly as 

1052 given. Use ``FITS_FLAT_DICT_PREFIX`` for FITS headers. 

1053 sep : `str`, optional 

1054 Separator between the prefix and the property name. Must be 

1055 one character that is not a letter, digit, or underscore. 

1056 Unused when ``prefix`` is empty. Use ``FITS_FLAT_DICT_SEP`` 

1057 for FITS headers. 

1058 use_upper : `bool` or `None`, optional 

1059 If `None` the property names are upper-cased when the first 

1060 character of ``prefix`` is upper case and left lower case 

1061 otherwise. `True` or `False` force the case. 

1062 encode_non_finite : `bool`, optional 

1063 If `True`, ``NaN`` and infinite floats are written as the 

1064 strings ``NAN``, ``+INFINITY``, and ``-INFINITY``. FITS can 

1065 not hold such floats, so set this when the result is going 

1066 into a FITS header. By default they are left as floats. 

1067 

1068 Returns 

1069 ------- 

1070 flat : `dict` [`str`, `str` | `int` | `float` | `bool`] 

1071 Flat mapping of all properties that are not `None`. 

1072 

1073 Raises 

1074 ------ 

1075 ValueError 

1076 Raised if ``sep`` is not valid. 

1077 TypeError 

1078 Raised if a property value can not be represented as a 

1079 scalar or a flat sequence of scalars. 

1080 

1081 Notes 

1082 ----- 

1083 Each key is the property name as used by `to_simple`, optionally 

1084 preceded by the prefix and separator. A property whose simple 

1085 form is a tuple, such as ``datetime_begin``, is written as one 

1086 key per element with an index suffix: ``datetime_begin_0`` and 

1087 ``datetime_begin_1``. The ``_translator`` entry is written as 

1088 ``translator``. With ``prefix="OBS_INFO"`` and ``sep=" "`` the 

1089 keys are suitable for FITS ``HIERARCH`` cards, for example 

1090 ``OBS_INFO DATETIME_BEGIN_0``. 

1091 

1092 Values are passed through unchanged unless ``encode_non_finite`` 

1093 is set. The strings it writes are the ones cfitsio and afw use 

1094 for non-finite floats, and `from_flat_dict` converts them back 

1095 to floats. FITS does not preserve trailing spaces in string 

1096 values. 

1097 

1098 The result can be converted back with `from_flat_dict`. 

1099 Round-tripping of extension properties requires that the 

1100 translator is registered, and an extension name must not end in 

1101 an underscore followed by digits if a tuple-valued extension 

1102 with the shorter name also exists. 

1103 """ 

1104 return flatten_simple( 

1105 self.to_simple(), 

1106 prefix=prefix, 

1107 sep=sep, 

1108 use_upper=use_upper, 

1109 encode_non_finite=encode_non_finite, 

1110 ) 

1111 

1112 @classmethod 

1113 def has_flat_dict(cls, flat: Mapping[str, Any], *, prefix: str | None = None, sep: str = ".") -> bool: 

1114 """Report whether a mapping contains keys written by `to_flat_dict`. 

1115 

1116 Parameters 

1117 ---------- 

1118 flat : `~collections.abc.Mapping` [`str`, `~typing.Any`] 

1119 Mapping to inspect, for example a FITS header. Keys are 

1120 matched case-insensitively. 

1121 prefix : `str` or `None`, optional 

1122 Prefix given to `to_flat_dict`. `None` detects the prefix 

1123 and separator automatically, in which case ``sep`` is 

1124 ignored. 

1125 sep : `str`, optional 

1126 Separator given to `to_flat_dict`. 

1127 

1128 Returns 

1129 ------- 

1130 present : `bool` 

1131 `True` if at least one key was found. 

1132 

1133 Raises 

1134 ------ 

1135 ValueError 

1136 Raised if automatic detection finds two prefixes that match 

1137 equally well. 

1138 

1139 Notes 

1140 ----- 

1141 Use this before `from_flat_dict` to tell the absence of content 

1142 from content that fails validation. Automatic detection requires 

1143 an indexed tuple-valued property such as ``datetime_begin_0`` to 

1144 be present. Keys that differ only in case count as present even 

1145 though `from_flat_dict` will reject them. 

1146 """ 

1147 return bool( 

1148 find_flat_keys( 

1149 flat, prefix=prefix, sep=sep, anchor_names=_flat_dict_schema().tuple_lengths, unique=False 

1150 ) 

1151 ) 

1152 

1153 @classmethod 

1154 def strip_from_flat_dict( 

1155 cls, flat: MutableMapping[str, Any], *, prefix: str | None = None, sep: str = "." 

1156 ) -> None: 

1157 """Remove keys written by `to_flat_dict` from a mapping in place. 

1158 

1159 Parameters 

1160 ---------- 

1161 flat : `~collections.abc.MutableMapping` [`str`, `~typing.Any`] 

1162 Mapping to modify, for example a FITS header. Keys are 

1163 matched case-insensitively. 

1164 prefix : `str` or `None`, optional 

1165 Prefix given to `to_flat_dict`. `None` detects the prefix 

1166 and separator automatically, in which case ``sep`` is 

1167 ignored. 

1168 sep : `str`, optional 

1169 Separator given to `to_flat_dict`. 

1170 

1171 Raises 

1172 ------ 

1173 ValueError 

1174 Raised if ``sep`` is invalid or detection is ambiguous. 

1175 

1176 Notes 

1177 ----- 

1178 Nothing is removed if no keys are found. Other keys in the 

1179 mapping are left untouched. Keys that differ only in case are 

1180 all removed. 

1181 """ 

1182 found = find_flat_keys( 

1183 flat, prefix=prefix, sep=sep, anchor_names=_flat_dict_schema().tuple_lengths, unique=False 

1184 ) 

1185 for key in found: 

1186 del flat[key] 

1187 

1188 @classmethod 

1189 def from_flat_dict( 

1190 cls, flat: Mapping[str, Any], *, prefix: str | None = None, sep: str = "." 

1191 ) -> ObservationInfo: 

1192 """Create an `ObservationInfo` from the output of `to_flat_dict`. 

1193 

1194 Parameters 

1195 ---------- 

1196 flat : `~collections.abc.Mapping` [`str`, `~typing.Any`] 

1197 Mapping containing the flat keys, for example a FITS header. 

1198 Keys are matched case-insensitively and unrelated keys are 

1199 ignored. 

1200 prefix : `str` or `None`, optional 

1201 Prefix given to `to_flat_dict`. `None` detects the prefix 

1202 and separator automatically, in which case ``sep`` is 

1203 ignored. 

1204 sep : `str`, optional 

1205 Separator given to `to_flat_dict`. 

1206 

1207 Returns 

1208 ------- 

1209 obsinfo : `ObservationInfo` 

1210 New object constructed from the mapping. 

1211 

1212 Raises 

1213 ------ 

1214 ValueError 

1215 Raised if no keys are found, if automatic detection is 

1216 ambiguous, if two keys differ only in case, if the indexed 

1217 values of a property are incomplete, or if a float property 

1218 holds a string that is not a number. Also raised, as 

1219 `pydantic.ValidationError`, if a value fails validation. 

1220 KeyError 

1221 Raised if extension properties are present but the named 

1222 translator is not registered, so their definitions are 

1223 unknown. 

1224 

1225 Notes 

1226 ----- 

1227 A string found for a float property is converted with `float`. 

1228 This restores the ``NAN``, ``+INFINITY``, and ``-INFINITY`` 

1229 strings written by `to_flat_dict` with ``encode_non_finite``, and 

1230 by cfitsio and afw for non-finite floats, as well as the ``NaN`` 

1231 and ``Infinity`` spellings used in JSON. 

1232 

1233 Round-tripping of extension properties requires that the 

1234 translator named in the mapping is registered, because its 

1235 extension definitions are needed to interpret ``ext_`` keys. 

1236 """ 

1237 schema = _flat_dict_schema() 

1238 flat_keys = find_flat_keys(flat, prefix=prefix, sep=sep, anchor_names=schema.tuple_lengths) 

1239 if not flat_keys: 

1240 raise ValueError("No ObservationInfo content found in the given mapping.") 

1241 extension_names: tuple[str, ...] = () 

1242 float_names = set(schema.float_names) 

1243 translator_name = find_translator_name(flat, flat_keys) 

1244 if translator_name is not None: 

1245 translator_class = MetadataTranslator.translators.get(translator_name) 

1246 if translator_class is not None: 1246 ↛ 1253line 1246 didn't jump to line 1253 because the condition on line 1246 was always true

1247 extension_names = tuple(translator_class.extensions) 

1248 float_names.update( 

1249 f"ext_{name}" 

1250 for name, definition in translator_class.extensions.items() 

1251 if definition.py_type is float 

1252 ) 

1253 simple = unflatten( 

1254 flat, 

1255 flat_keys, 

1256 extension_names=extension_names, 

1257 expected_lengths=schema.tuple_lengths, 

1258 float_names=float_names, 

1259 ) 

1260 return cls.from_simple(simple) 

1261 

1262 @classmethod 

1263 def makeObservationInfo( # noqa: N802 

1264 cls, 

1265 *, 

1266 extensions: dict[str, PropertyDefinition] | None = None, 

1267 translator_class: type[MetadataTranslator] | None = None, 

1268 **kwargs: Any, 

1269 ) -> ObservationInfo: 

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

1271 

1272 Parameters 

1273 ---------- 

1274 extensions : `dict` [`str`: `PropertyDefinition`], optional 

1275 Optional extension definitions, indexed by extension name (without 

1276 the ``ext_`` prefix, which will be added by `ObservationInfo`). 

1277 translator_class : `MetadataTranslator`-class, optional 

1278 Optional translator class defining the extension properties. If 

1279 provided, this can be used instead of ``extensions`` and will be 

1280 stored in the instance for JSON round-tripping. 

1281 **kwargs 

1282 Name-value pairs for any properties to be set. In the case of 

1283 extension properties, the names should include the ``ext_`` prefix. 

1284 

1285 Notes 

1286 ----- 

1287 The supplied parameters should use names matching the property. 

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

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

1290 

1291 Raises 

1292 ------ 

1293 KeyError 

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

1295 TypeError 

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

1297 of the property. 

1298 """ 

1299 return cls(filename=None, translator_class=translator_class, _extensions=extensions, **kwargs) 

1300 

1301 @contextmanager 

1302 def edit_copy(self) -> Generator[ObservationInfo]: # numpydoc ignore=YD01 

1303 """Edit a copy of this struct, via a context manager that marks it 

1304 as read-only when it exits. 

1305 """ 

1306 copy = self.model_copy() 

1307 copy._sealed = False 

1308 yield copy 

1309 copy._sealed = True 

1310 

1311 

1312class _FlatDictSchema(NamedTuple): 

1313 """Facts about the simple form of `ObservationInfo` needed to rebuild 

1314 it from a flat dict. 

1315 """ 

1316 

1317 tuple_lengths: dict[str, int] 

1318 """Fixed tuple length of each tuple-valued core property.""" 

1319 

1320 float_names: frozenset[str] 

1321 """Core properties whose simple form is a float or a tuple of floats.""" 

1322 

1323 

1324@functools.cache 

1325def _flat_dict_schema() -> _FlatDictSchema: 

1326 """Derive the flat dict facts from the JSON schema of `ObservationInfo`. 

1327 

1328 The simple form of a tuple-valued property is declared there as a 

1329 fixed-size array, and float values as ``number``. 

1330 """ 

1331 lengths: dict[str, int] = {} 

1332 floats: set[str] = set() 

1333 for name, prop in ObservationInfo.model_json_schema()["properties"].items(): 

1334 for option in prop.get("anyOf", [prop]): 

1335 if option.get("type") == "number": 

1336 floats.add(name) 

1337 elif ( 

1338 option.get("type") == "array" and option.get("minItems") == option.get("maxItems") is not None 

1339 ): 

1340 lengths[name] = option["maxItems"] 

1341 if all(item.get("type") == "number" for item in option.get("prefixItems", [])): 1341 ↛ 1334line 1341 didn't jump to line 1334 because the condition on line 1341 was always true

1342 floats.add(name) 

1343 return _FlatDictSchema(lengths, frozenset(floats)) 

1344 

1345 

1346def makeObservationInfo( # noqa: N802 

1347 *, 

1348 extensions: dict[str, PropertyDefinition] | None = None, 

1349 translator_class: type[MetadataTranslator] | None = None, 

1350 **kwargs: Any, 

1351) -> ObservationInfo: 

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

1353 

1354 Parameters 

1355 ---------- 

1356 extensions : `dict` [`str`: `PropertyDefinition`], optional 

1357 Optional extension definitions, indexed by extension name (without 

1358 the ``ext_`` prefix, which will be added by `ObservationInfo`). 

1359 translator_class : `MetadataTranslator`-class, optional 

1360 Optional translator class defining the extension properties. If 

1361 provided, this can be used instead of ``extensions`` and will be 

1362 stored in the instance for JSON round-tripping. 

1363 **kwargs 

1364 Name-value pairs for any properties to be set. In the case of 

1365 extension properties, the names should include the ``ext_`` prefix. 

1366 

1367 Notes 

1368 ----- 

1369 The supplied parameters should use names matching the property. 

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

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

1372 

1373 Raises 

1374 ------ 

1375 KeyError 

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

1377 TypeError 

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

1379 of the property. 

1380 """ 

1381 return ObservationInfo.makeObservationInfo( 

1382 extensions=extensions, translator_class=translator_class, **kwargs 

1383 )