Coverage for tests/test_flat_dict.py: 96%
455 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 09:05 +0000
« 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.
12"""Tests for the flat dict serialization of ObservationInfo."""
14import math
15import os
16import tempfile
17import unittest
18from collections.abc import Mapping
19from typing import Any
21import astropy.time
22import astropy.units as u
23from astropy.coordinates import EarthLocation
24from astropy.io import fits
26from astro_metadata_translator import (
27 FITS_FLAT_DICT_PREFIX,
28 FITS_FLAT_DICT_SEP,
29 ObservationInfo,
30 PropertyDefinition,
31 StubTranslator,
32 makeObservationInfo,
33)
34from astro_metadata_translator.serialize.flat_dict import (
35 detect_prefix_and_sep,
36 find_flat_keys,
37 find_translator_name,
38 flatten_simple,
39 make_flat_key,
40 unflatten,
41 validate_sep,
42)
43from astro_metadata_translator.tests import read_test_file
45try:
46 from lsst.daf.base import PropertyList
47except ImportError:
48 PropertyList = None
50try:
51 import lsst.afw.fits as afw_fits
52 import lsst.afw.image as afw_image
53except ImportError:
54 afw_fits = afw_image = None
56TESTDIR = os.path.abspath(os.path.dirname(__file__))
57DATADIR = os.path.join(TESTDIR, "data")
59HEADER_FILES = (
60 "fitsheader-decam.yaml",
61 "fitsheader-hsc.yaml",
62 "fitsheader-megaprime.yaml",
63 "fitsheader-sdss-fpC-006377-g4-0399.yaml",
64 "fitsheader-suprimecam-CORR40535770.yaml",
65)
67PREFIX_FORMS = (("", "."), ("obs_info", "."), (FITS_FLAT_DICT_PREFIX, FITS_FLAT_DICT_SEP))
70# Registers itself through StubTranslator.__init_subclass__ so that
71# ``_translator: "flatdict"`` resolves and its extensions are available.
72class _FlatDictTranslator(StubTranslator):
73 name = "flatdict"
74 supported_instrument = "flatdict"
75 extensions = dict(
76 number=PropertyDefinition("A number", int),
77 foo=PropertyDefinition("A string", str),
78 pair=PropertyDefinition("A pair of floats", tuple),
79 )
81 @classmethod
82 def can_translate(cls, header: Mapping[str, Any], filename: str | None = None) -> bool:
83 return False
86def _read_obsinfo(filename: str) -> ObservationInfo:
87 return ObservationInfo(read_test_file(filename, dir=DATADIR))
90class FlattenTestCase(unittest.TestCase):
91 """Test flattening of the simple form."""
93 def test_constants(self) -> None:
94 self.assertEqual(FITS_FLAT_DICT_PREFIX, "OBS_INFO")
95 self.assertEqual(FITS_FLAT_DICT_SEP, " ")
97 def test_validate_sep(self) -> None:
98 for good in (".", " ", "-", ":"):
99 validate_sep(good)
100 for bad in ("", "..", "a", "Z", "1", "_"):
101 with self.assertRaises(ValueError):
102 validate_sep(bad)
104 def test_make_flat_key(self) -> None:
105 self.assertEqual(make_flat_key("instrument", prefix="", sep=".", use_upper=False), "instrument")
106 self.assertEqual(make_flat_key("instrument", prefix="", sep=".", use_upper=True), "INSTRUMENT")
107 self.assertEqual(
108 make_flat_key("datetime_begin_0", prefix="obs_info", sep=".", use_upper=False),
109 "obs_info.datetime_begin_0",
110 )
111 self.assertEqual(
112 make_flat_key("datetime_begin_0", prefix="OBS_INFO", sep=" ", use_upper=True),
113 "OBS_INFO DATETIME_BEGIN_0",
114 )
115 # The prefix is emitted exactly as given.
116 self.assertEqual(
117 make_flat_key("instrument", prefix="obs_info", sep=" ", use_upper=True),
118 "obs_info INSTRUMENT",
119 )
121 def test_flatten_scalars_no_prefix(self) -> None:
122 simple = {
123 "instrument": "HSC",
124 "exposure_id": 42,
125 "boresight_airmass": 1.2,
126 "can_see_sky": True,
127 "_translator": "HSC",
128 }
129 self.assertEqual(
130 flatten_simple(simple),
131 {
132 "instrument": "HSC",
133 "exposure_id": 42,
134 "boresight_airmass": 1.2,
135 "can_see_sky": True,
136 "translator": "HSC",
137 },
138 )
140 def test_flatten_sequences(self) -> None:
141 simple = {"datetime_begin": (2460000.5, 0.25), "location": [1.0, 2.0, 3.0], "ext_pair": (1, 2)}
142 self.assertEqual(
143 flatten_simple(simple),
144 {
145 "datetime_begin_0": 2460000.5,
146 "datetime_begin_1": 0.25,
147 "location_0": 1.0,
148 "location_1": 2.0,
149 "location_2": 3.0,
150 "ext_pair_0": 1,
151 "ext_pair_1": 2,
152 },
153 )
155 def test_flatten_non_finite(self) -> None:
156 simple = {
157 "temperature": math.inf,
158 "pressure": -math.inf,
159 "relative_humidity": math.nan,
160 "location": (math.nan, 1.0, math.inf),
161 "detector_num": 1,
162 "object": "NAN",
163 }
164 # By default non-finite floats pass through untouched.
165 flat = flatten_simple(simple)
166 self.assertTrue(math.isnan(flat["relative_humidity"]))
167 self.assertEqual(flat["temperature"], math.inf)
168 self.assertTrue(math.isnan(flat["location_0"]))
170 flat = flatten_simple(simple, encode_non_finite=True)
171 self.assertEqual(flat["temperature"], "+INFINITY")
172 self.assertEqual(flat["pressure"], "-INFINITY")
173 self.assertEqual(flat["relative_humidity"], "NAN")
174 self.assertEqual(flat["location_0"], "NAN")
175 self.assertEqual(flat["location_1"], 1.0)
176 self.assertEqual(flat["location_2"], "+INFINITY")
177 self.assertEqual(flat["detector_num"], 1)
178 self.assertEqual(flat["object"], "NAN")
180 def test_flatten_prefix_and_case(self) -> None:
181 simple = {"datetime_begin": (1.0, 2.0), "instrument": "X"}
182 self.assertEqual(
183 flatten_simple(simple, prefix="obs_info"),
184 {
185 "obs_info.datetime_begin_0": 1.0,
186 "obs_info.datetime_begin_1": 2.0,
187 "obs_info.instrument": "X",
188 },
189 )
190 self.assertEqual(
191 flatten_simple(simple, prefix="OBS_INFO", sep=" "),
192 {
193 "OBS_INFO DATETIME_BEGIN_0": 1.0,
194 "OBS_INFO DATETIME_BEGIN_1": 2.0,
195 "OBS_INFO INSTRUMENT": "X",
196 },
197 )
198 self.assertEqual(
199 flatten_simple(simple, prefix="OBS_INFO", sep=" ", use_upper=False),
200 {
201 "OBS_INFO datetime_begin_0": 1.0,
202 "OBS_INFO datetime_begin_1": 2.0,
203 "OBS_INFO instrument": "X",
204 },
205 )
206 self.assertEqual(
207 flatten_simple(simple, use_upper=True),
208 {"DATETIME_BEGIN_0": 1.0, "DATETIME_BEGIN_1": 2.0, "INSTRUMENT": "X"},
209 )
211 def test_flatten_rejects_bad_values(self) -> None:
212 with self.assertRaises(TypeError):
213 flatten_simple({"ext_bad": {"a": 1}})
214 with self.assertRaises(TypeError):
215 flatten_simple({"ext_nested": [[1.0, 2.0]]})
216 with self.assertRaises(TypeError):
217 flatten_simple({"ext_none": None})
219 def test_flatten_rejects_bad_sep(self) -> None:
220 with self.assertRaises(ValueError):
221 flatten_simple({"instrument": "X"}, prefix="p", sep="_")
222 with self.assertRaises(ValueError):
223 flatten_simple({"instrument": "X"}, sep="ab")
226class DetectTestCase(unittest.TestCase):
227 """Test prefix detection and key collection."""
229 PREFIXED = {
230 "OBS_INFO DATETIME_BEGIN_0": 2460000.5,
231 "OBS_INFO DATETIME_BEGIN_1": 0.25,
232 "OBS_INFO INSTRUMENT": "HSC",
233 "OBS_INFO OBJECT": "real",
234 "OBS_INFO TRANSLATOR": "HSC",
235 "OBS_INFO EXT_FOO": "bar",
236 "OBS_INFO EXT_PAIR_0": 1,
237 "OBJECT": "decoy",
238 "TEMPERATURE": 3.0,
239 "EXPTIME": 30.0,
240 }
242 def test_detect_nothing(self) -> None:
243 self.assertIsNone(detect_prefix_and_sep({}))
244 self.assertIsNone(detect_prefix_and_sep({"EXPTIME": 30.0, "FILTER": "g"}))
246 def test_detect_decoys_without_anchor(self) -> None:
247 # Core names appear but nothing indexed, so this is not content.
248 self.assertIsNone(detect_prefix_and_sep({"OBJECT": "M31", "TEMPERATURE": 3.0}))
249 self.assertIsNone(detect_prefix_and_sep({"instrument": "X", "object": "Y"}))
251 def test_detect_anchor_names(self) -> None:
252 # By default any indexed core name anchors detection.
253 flat = {"OBJECT": "M31", "OBJECT_1": "x", "TEMPERATURE": 3.0}
254 self.assertEqual(detect_prefix_and_sep(flat), ("", ""))
255 # With explicit anchor names a stray indexed scalar does not.
256 self.assertIsNone(detect_prefix_and_sep(flat, anchor_names=("datetime_begin", "location")))
257 self.assertEqual(find_flat_keys(flat, anchor_names=("datetime_begin",)), {})
258 flat["datetime_begin_0"] = 1.0
259 flat["datetime_begin_1"] = 2.0
260 self.assertEqual(detect_prefix_and_sep(flat, anchor_names=("datetime_begin",)), ("", ""))
262 def test_detect_plain(self) -> None:
263 flat = {"datetime_begin_0": 1.0, "datetime_begin_1": 2.0, "instrument": "X"}
264 self.assertEqual(detect_prefix_and_sep(flat), ("", ""))
266 def test_detect_prefixed_with_decoys(self) -> None:
267 self.assertEqual(detect_prefix_and_sep(self.PREFIXED), ("obs_info", " "))
269 def test_detect_prefix_containing_sep(self) -> None:
270 flat = {"LSST OBS_INFO DATETIME_BEGIN_0": 1.0, "LSST OBS_INFO EXPOSURE_ID": 5}
271 self.assertEqual(detect_prefix_and_sep(flat), ("lsst obs_info", " "))
273 def test_detect_dotted(self) -> None:
274 flat = {"lsst.obs_info.datetime_begin_0": 1.0, "lsst.obs_info.exposure_id": 5}
275 self.assertEqual(detect_prefix_and_sep(flat), ("lsst.obs_info", "."))
277 def test_detect_majority(self) -> None:
278 flat = {"a.datetime_begin_0": 1.0, "a.instrument": "x", "b.datetime_begin_0": 1.0}
279 self.assertEqual(detect_prefix_and_sep(flat), ("a", "."))
281 def test_detect_tie(self) -> None:
282 flat = {"a.datetime_begin_0": 1.0, "b.datetime_begin_0": 1.0}
283 with self.assertRaises(ValueError):
284 detect_prefix_and_sep(flat)
286 def test_find_keys_auto(self) -> None:
287 found = find_flat_keys(self.PREFIXED)
288 self.assertEqual(
289 found,
290 {
291 "OBS_INFO DATETIME_BEGIN_0": "datetime_begin_0",
292 "OBS_INFO DATETIME_BEGIN_1": "datetime_begin_1",
293 "OBS_INFO INSTRUMENT": "instrument",
294 "OBS_INFO OBJECT": "object",
295 "OBS_INFO TRANSLATOR": "translator",
296 "OBS_INFO EXT_FOO": "ext_foo",
297 "OBS_INFO EXT_PAIR_0": "ext_pair_0",
298 },
299 )
301 def test_find_keys_auto_nothing(self) -> None:
302 self.assertEqual(find_flat_keys({"OBJECT": "M31"}), {})
304 def test_find_keys_explicit(self) -> None:
305 found = find_flat_keys(self.PREFIXED, prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
306 self.assertEqual(len(found), 7)
307 self.assertNotIn("OBJECT", found)
308 # Explicit empty prefix needs no indexed anchor.
309 self.assertEqual(
310 find_flat_keys({"instrument": "X", "EXPTIME": 1}, prefix=""), {"instrument": "instrument"}
311 )
312 # Explicit prefix with the wrong separator finds nothing.
313 self.assertEqual(find_flat_keys(self.PREFIXED, prefix="OBS_INFO", sep="."), {})
314 # Explicit prefix is matched case-insensitively.
315 found = find_flat_keys(self.PREFIXED, prefix="obs_info", sep=" ")
316 self.assertEqual(len(found), 7)
318 def test_find_keys_ignores_non_string_keys(self) -> None:
319 flat: dict[Any, Any] = {1: "x", "datetime_begin_0": 1.0, "datetime_begin_1": 2.0}
320 expected = {"datetime_begin_0": "datetime_begin_0", "datetime_begin_1": "datetime_begin_1"}
321 self.assertEqual(find_flat_keys(flat), expected)
322 self.assertEqual(find_flat_keys(flat, prefix=""), expected)
324 def test_find_keys_ignores_unknown_names(self) -> None:
325 flat = {"obs_info.datetime_begin_0": 1.0, "obs_info.not_a_property": 2, "obs_info.ext_": 3}
326 self.assertEqual(
327 find_flat_keys(flat, prefix="obs_info"), {"obs_info.datetime_begin_0": "datetime_begin_0"}
328 )
330 def test_find_keys_duplicate(self) -> None:
331 flat = {"OBS_INFO INSTRUMENT": "a", "obs_info instrument": "b", "OBS_INFO DATETIME_BEGIN_0": 1.0}
332 with self.assertRaises(ValueError):
333 find_flat_keys(flat, prefix="OBS_INFO", sep=" ")
335 def test_find_keys_duplicates_allowed(self) -> None:
336 flat = {"OBS_INFO INSTRUMENT": "a", "obs_info instrument": "b", "OBS_INFO DATETIME_BEGIN_0": 1.0}
337 found = find_flat_keys(flat, prefix="OBS_INFO", sep=" ", unique=False)
338 self.assertEqual(set(found), set(flat))
339 self.assertEqual(found["obs_info instrument"], "instrument")
341 def test_find_keys_bad_sep(self) -> None:
342 with self.assertRaises(ValueError):
343 find_flat_keys({"p_instrument": "x"}, prefix="p", sep="_")
346class UnflattenTestCase(unittest.TestCase):
347 """Test rebuilding the simple form from flat keys."""
349 def test_unflatten_core(self) -> None:
350 flat = {
351 "OBS_INFO DATETIME_BEGIN_1": 0.25,
352 "OBS_INFO DATETIME_BEGIN_0": 2460000.5,
353 "OBS_INFO LOCATION_0": 1.0,
354 "OBS_INFO LOCATION_1": 2.0,
355 "OBS_INFO LOCATION_2": 3.0,
356 "OBS_INFO INSTRUMENT": "HSC",
357 "OBS_INFO TRANSLATOR": "HSC",
358 "OBJECT": "decoy",
359 }
360 flat_keys = find_flat_keys(flat)
361 self.assertEqual(find_translator_name(flat, flat_keys), "HSC")
362 self.assertEqual(
363 unflatten(flat, flat_keys),
364 {
365 "datetime_begin": (2460000.5, 0.25),
366 "location": (1.0, 2.0, 3.0),
367 "instrument": "HSC",
368 "_translator": "HSC",
369 },
370 )
372 def test_unflatten_no_translator(self) -> None:
373 flat = {"datetime_begin_0": 1.0, "datetime_begin_1": 2.0}
374 flat_keys = find_flat_keys(flat)
375 self.assertIsNone(find_translator_name(flat, flat_keys))
376 self.assertEqual(unflatten(flat, flat_keys), {"datetime_begin": (1.0, 2.0)})
378 def test_unflatten_gap(self) -> None:
379 flat = {"datetime_begin_0": 1.0, "datetime_begin_2": 2.0}
380 with self.assertRaises(ValueError):
381 unflatten(flat, find_flat_keys(flat))
382 flat = {"datetime_begin_1": 2.0}
383 with self.assertRaises(ValueError):
384 unflatten(flat, find_flat_keys(flat))
386 def test_unflatten_expected_lengths(self) -> None:
387 flat = {"datetime_begin_0": 1.0, "datetime_begin_1": 2.0, "location_0": 1.0, "location_1": 2.0}
388 flat_keys = find_flat_keys(flat)
389 lengths = {"datetime_begin": 2, "location": 3}
390 # A truncated tuple is incomplete even though its indexes are
391 # contiguous from 0.
392 with self.assertRaises(ValueError):
393 unflatten(flat, flat_keys, expected_lengths=lengths)
394 # Without a declared length only contiguity is checked.
395 self.assertEqual(unflatten(flat, flat_keys), {"datetime_begin": (1.0, 2.0), "location": (1.0, 2.0)})
396 del flat["location_0"], flat["location_1"]
397 self.assertEqual(
398 unflatten(flat, find_flat_keys(flat), expected_lengths=lengths), {"datetime_begin": (1.0, 2.0)}
399 )
401 def test_unflatten_scalar_and_indexed_conflict(self) -> None:
402 flat = {"datetime_begin": 1.0, "datetime_begin_0": 1.0, "datetime_begin_1": 2.0}
403 with self.assertRaises(ValueError):
404 unflatten(flat, find_flat_keys(flat))
406 def test_unflatten_extensions(self) -> None:
407 flat = {
408 "obs_info.datetime_begin_0": 1.0,
409 "obs_info.datetime_begin_1": 2.0,
410 "obs_info.ext_foo": "bar",
411 "obs_info.ext_pair_0": 1,
412 "obs_info.ext_pair_1": 2,
413 "obs_info.ext_other_1": 3,
414 }
415 flat_keys = find_flat_keys(flat, prefix="obs_info")
416 # Known extension names restore their spelling and regroup.
417 simple = unflatten(flat, flat_keys, extension_names=("Foo", "pair"))
418 self.assertEqual(
419 simple,
420 {
421 "datetime_begin": (1.0, 2.0),
422 "ext_Foo": "bar",
423 "ext_pair": (1, 2),
424 "ext_other_1": 3,
425 },
426 )
427 # Unknown extension names pass through as plain lower-case names.
428 simple = unflatten(flat, flat_keys)
429 self.assertEqual(
430 simple,
431 {
432 "datetime_begin": (1.0, 2.0),
433 "ext_foo": "bar",
434 "ext_pair_0": 1,
435 "ext_pair_1": 2,
436 "ext_other_1": 3,
437 },
438 )
440 def test_unflatten_float_names(self) -> None:
441 flat = {
442 "temperature": "+INFINITY",
443 "pressure": "-Infinity",
444 "relative_humidity": "NAN",
445 "boresight_airmass": "nan",
446 "location_0": "NAN",
447 "location_1": 1.0,
448 "location_2": "+INFINITY",
449 "object": "NAN",
450 "ext_foo": "NAN",
451 }
452 flat_keys = find_flat_keys(flat, prefix="")
453 float_names = {
454 "temperature",
455 "pressure",
456 "relative_humidity",
457 "boresight_airmass",
458 "location",
459 "ext_Foo",
460 }
461 simple = unflatten(flat, flat_keys, extension_names=("Foo",), float_names=float_names)
462 self.assertEqual(simple["temperature"], math.inf)
463 self.assertEqual(simple["pressure"], -math.inf)
464 self.assertTrue(math.isnan(simple["relative_humidity"]))
465 self.assertTrue(math.isnan(simple["boresight_airmass"]))
466 self.assertTrue(math.isnan(simple["location"][0]))
467 self.assertEqual(simple["location"][1:], (1.0, math.inf))
468 self.assertEqual(simple["object"], "NAN")
469 self.assertTrue(math.isnan(simple["ext_Foo"]))
471 # Without float names the strings are left alone.
472 simple = unflatten(flat, flat_keys)
473 self.assertEqual(simple["temperature"], "+INFINITY")
475 # A string that is not a number is an error for a float property.
476 with self.assertRaises(ValueError):
477 unflatten({"temperature": "warm"}, {"temperature": "temperature"}, float_names={"temperature"})
480class ObservationInfoFlatDictTestCase(unittest.TestCase):
481 """Test the flat dict methods on ObservationInfo."""
483 def assert_round_trip(self, original: ObservationInfo, restored: ObservationInfo) -> None:
484 # Compare the full simple forms rather than relying on ``==``,
485 # which only compares the properties present on both sides.
486 self.assertEqual(original.to_simple(), restored.to_simple())
488 def test_round_trip_headers(self) -> None:
489 for filename in HEADER_FILES:
490 obsinfo = _read_obsinfo(filename)
491 for prefix, sep in PREFIX_FORMS:
492 with self.subTest(filename=filename, prefix=prefix):
493 flat = obsinfo.to_flat_dict(prefix=prefix, sep=sep)
494 for value in flat.values():
495 self.assertIsInstance(value, str | int | float | bool)
496 self.assertTrue(ObservationInfo.has_flat_dict(flat))
497 self.assert_round_trip(obsinfo, ObservationInfo.from_flat_dict(flat))
498 restored = ObservationInfo.from_flat_dict(flat, prefix=prefix, sep=sep)
499 self.assert_round_trip(obsinfo, restored)
501 def test_fits_form_keys(self) -> None:
502 obsinfo = _read_obsinfo("fitsheader-decam.yaml")
503 flat = obsinfo.to_flat_dict(prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
504 self.assertIn("OBS_INFO DATETIME_BEGIN_0", flat)
505 self.assertIn("OBS_INFO DATETIME_BEGIN_1", flat)
506 self.assertIn("OBS_INFO TRACKING_RADEC_0", flat)
507 self.assertIn("OBS_INFO LOCATION_2", flat)
508 self.assertEqual(flat["OBS_INFO TRANSLATOR"], "DECam")
509 self.assertNotIn("OBS_INFO _TRANSLATOR", flat)
510 for key in flat:
511 self.assertEqual(key, key.upper())
513 def test_round_trip_hand_built(self) -> None:
514 begin = astropy.time.Time("2025-01-01T00:00:00", scale="tai")
515 obsinfo = makeObservationInfo(observation_id="A", exposure_time=30.0 * u.s, datetime_begin=begin)
516 flat = obsinfo.to_flat_dict()
517 self.assertNotIn("translator", flat)
518 self.assert_round_trip(obsinfo, ObservationInfo.from_flat_dict(flat))
520 # Without any tuple-valued property there is no indexed anchor so
521 # auto-detection finds nothing and an explicit prefix is needed.
522 obsinfo = makeObservationInfo(observation_id="A", exposure_time=30.0 * u.s)
523 flat = obsinfo.to_flat_dict()
524 self.assertFalse(ObservationInfo.has_flat_dict(flat))
525 with self.assertRaises(ValueError):
526 ObservationInfo.from_flat_dict(flat)
527 self.assertTrue(ObservationInfo.has_flat_dict(flat, prefix=""))
528 self.assert_round_trip(obsinfo, ObservationInfo.from_flat_dict(flat, prefix=""))
530 def test_round_trip_extensions(self) -> None:
531 begin = astropy.time.Time("2025-01-01T00:00:00", scale="tai")
532 obsinfo = makeObservationInfo(
533 translator_class=_FlatDictTranslator,
534 instrument="flatdict",
535 datetime_begin=begin,
536 ext_number=42,
537 ext_foo="bar",
538 ext_pair=(1.5, 2.5),
539 )
540 for prefix, sep in PREFIX_FORMS:
541 with self.subTest(prefix=prefix):
542 flat = obsinfo.to_flat_dict(prefix=prefix, sep=sep)
543 restored = ObservationInfo.from_flat_dict(flat)
544 self.assertEqual(restored.translator_name, "flatdict")
545 self.assertEqual(restored.ext_number, 42)
546 self.assertEqual(restored.ext_foo, "bar")
547 self.assertEqual(restored.ext_pair, (1.5, 2.5))
548 self.assert_round_trip(obsinfo, restored)
549 flat = obsinfo.to_flat_dict(prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
550 self.assertEqual(flat["OBS_INFO EXT_PAIR_0"], 1.5)
551 self.assertEqual(flat["OBS_INFO EXT_PAIR_1"], 2.5)
553 def test_non_finite_round_trip(self) -> None:
554 obsinfo = makeObservationInfo(
555 observation_id="A",
556 object="NAN",
557 relative_humidity=math.nan,
558 temperature=math.inf * u.K,
559 pressure=-math.inf * u.hPa,
560 location=EarthLocation(math.nan, 1.0, math.inf, unit=u.m),
561 )
562 for prefix, sep in PREFIX_FORMS:
563 with self.subTest(prefix=prefix):
564 flat = obsinfo.to_flat_dict(prefix=prefix, sep=sep, encode_non_finite=True)
565 self.assertTrue(all(math.isfinite(v) for v in flat.values() if isinstance(v, float)))
566 strings = {v for v in flat.values() if isinstance(v, str)}
567 self.assertTrue({"NAN", "+INFINITY", "-INFINITY"} <= strings)
569 restored = ObservationInfo.from_flat_dict(flat, prefix=prefix, sep=sep)
570 self.assertEqual(restored.object, "NAN")
571 self.assertTrue(math.isnan(restored.relative_humidity))
572 self.assertEqual(restored.temperature.value, math.inf)
573 self.assertEqual(restored.pressure.value, -math.inf)
574 self.assertTrue(math.isnan(restored.location.x.value))
575 self.assertEqual(restored.location.y.value, 1.0)
576 self.assertEqual(restored.location.z.value, math.inf)
577 self.assertEqual(restored, obsinfo)
579 def test_from_flat_dict_decodes_json_spellings(self) -> None:
580 # The spellings pydantic uses when writing JSON are accepted too.
581 flat = {
582 "observation_id": "A",
583 "datetime_begin_0": 2460000.5,
584 "datetime_begin_1": 0.25,
585 "temperature": "Infinity",
586 "pressure": "-Infinity",
587 "relative_humidity": "NaN",
588 }
589 restored = ObservationInfo.from_flat_dict(flat, prefix="")
590 self.assertEqual(restored.temperature.value, math.inf)
591 self.assertEqual(restored.pressure.value, -math.inf)
592 self.assertTrue(math.isnan(restored.relative_humidity))
594 def test_has_and_strip(self) -> None:
595 obsinfo = _read_obsinfo("fitsheader-hsc.yaml")
596 flat = obsinfo.to_flat_dict(prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
597 n_flat = len(flat)
598 header = {"SIMPLE": True, "OBJECT": "decoy", "TEMPERATURE": 3.0, **flat}
599 self.assertTrue(ObservationInfo.has_flat_dict(header))
600 self.assertTrue(
601 ObservationInfo.has_flat_dict(header, prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
602 )
603 ObservationInfo.strip_from_flat_dict(header)
604 self.assertEqual(header, {"SIMPLE": True, "OBJECT": "decoy", "TEMPERATURE": 3.0})
605 self.assertEqual(len(flat), n_flat)
606 self.assertFalse(ObservationInfo.has_flat_dict(header))
607 # Stripping again is a no-op.
608 ObservationInfo.strip_from_flat_dict(header)
609 self.assertEqual(len(header), 3)
611 def test_scalar_index_is_not_an_anchor(self) -> None:
612 header = {"OBJECT": "M31", "OBJECT_1": "x", "TEMPERATURE": 3.0}
613 self.assertFalse(ObservationInfo.has_flat_dict(header))
614 ObservationInfo.strip_from_flat_dict(header)
615 self.assertEqual(len(header), 3)
616 with self.assertRaises(ValueError):
617 ObservationInfo.from_flat_dict(header)
619 def test_case_duplicates(self) -> None:
620 obsinfo = _read_obsinfo("fitsheader-hsc.yaml")
621 flat = obsinfo.to_flat_dict(prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
622 header = {"OBJECT": "decoy", "obs_info object": "stale", **flat}
623 for kwargs in ({}, {"prefix": FITS_FLAT_DICT_PREFIX, "sep": FITS_FLAT_DICT_SEP}):
624 with self.subTest(kwargs=kwargs):
625 self.assertTrue(ObservationInfo.has_flat_dict(header, **kwargs))
626 with self.assertRaises(ValueError):
627 ObservationInfo.from_flat_dict(header, **kwargs)
628 ObservationInfo.strip_from_flat_dict(header, prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
629 self.assertEqual(header, {"OBJECT": "decoy"})
631 def test_strip_explicit_prefix_without_content(self) -> None:
632 header = {"OBJECT": "M31", "EXPTIME": 30.0}
633 self.assertFalse(
634 ObservationInfo.has_flat_dict(header, prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
635 )
636 ObservationInfo.strip_from_flat_dict(header, prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
637 self.assertEqual(header, {"OBJECT": "M31", "EXPTIME": 30.0})
639 def test_from_flat_dict_errors(self) -> None:
640 with self.assertRaises(ValueError):
641 ObservationInfo.from_flat_dict({})
642 with self.assertRaises(ValueError):
643 ObservationInfo.from_flat_dict({"OBJECT": "M31"})
645 obsinfo = _read_obsinfo("fitsheader-decam.yaml")
646 flat = obsinfo.to_flat_dict(prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
648 gap = dict(flat)
649 del gap["OBS_INFO DATETIME_BEGIN_1"]
650 with self.assertRaises(ValueError):
651 ObservationInfo.from_flat_dict(gap)
653 corrupt = dict(flat)
654 corrupt["OBS_INFO EXPOSURE_ID"] = "not an integer"
655 # Validation errors propagate rather than being mistaken for absence.
656 with self.assertRaises(ValueError):
657 ObservationInfo.from_flat_dict(corrupt)
658 self.assertTrue(ObservationInfo.has_flat_dict(corrupt))
660 def test_explicit_prefix_wrong_sep(self) -> None:
661 obsinfo = _read_obsinfo("fitsheader-decam.yaml")
662 flat = obsinfo.to_flat_dict(prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
663 self.assertFalse(ObservationInfo.has_flat_dict(flat, prefix=FITS_FLAT_DICT_PREFIX, sep="."))
664 with self.assertRaises(ValueError):
665 ObservationInfo.from_flat_dict(flat, prefix=FITS_FLAT_DICT_PREFIX, sep=".")
667 def test_to_flat_dict_bad_sep(self) -> None:
668 obsinfo = _read_obsinfo("fitsheader-decam.yaml")
669 with self.assertRaises(ValueError):
670 obsinfo.to_flat_dict(prefix="p", sep="_")
673class FitsHeaderTestCase(unittest.TestCase):
674 """Test the flat dict through a real FITS header file."""
676 def assert_fits_round_trip(self, original: ObservationInfo, restored: ObservationInfo) -> None:
677 # FITS writers keep at least 15 significant digits of a float
678 # rather than its shortest exact representation, so floats are
679 # compared to that precision and everything else exactly.
680 before = original.to_simple()
681 after = restored.to_simple()
682 self.assertEqual(set(before), set(after))
683 for key, value in before.items():
684 if isinstance(value, float):
685 self.assertTrue(math.isclose(after[key], value, rel_tol=1e-14), key)
686 elif isinstance(value, tuple) and isinstance(value[0], float):
687 for element_after, element_before in zip(after[key], value, strict=True):
688 self.assertTrue(math.isclose(element_after, element_before, rel_tol=1e-14), key)
689 else:
690 self.assertEqual(after[key], value, key)
692 def _write_and_read(self, header: fits.Header) -> fits.Header:
693 with tempfile.TemporaryDirectory() as tmpdir:
694 path = os.path.join(tmpdir, "test.fits")
695 fits.PrimaryHDU(header=header).writeto(path)
696 return fits.getheader(path)
698 def test_fits_header_round_trip(self) -> None:
699 obsinfo = _read_obsinfo("fitsheader-decam.yaml")
700 flat = obsinfo.to_flat_dict(prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
702 header = fits.Header()
703 header["OBJECT"] = "decoy"
704 header["HIERARCH TEMPERATURE"] = 3.0
705 for key, value in flat.items():
706 header[f"HIERARCH {key}"] = value
707 read = self._write_and_read(header)
709 self.assertTrue(ObservationInfo.has_flat_dict(read))
710 self.assertTrue(
711 ObservationInfo.has_flat_dict(read, prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
712 )
714 restored = ObservationInfo.from_flat_dict(read)
715 self.assert_fits_round_trip(obsinfo, restored)
716 self.assertEqual(restored.object, obsinfo.object)
717 self.assertNotEqual(restored.object, "decoy")
718 self.assertIsInstance(restored.can_see_sky, bool)
719 self.assertIsInstance(restored.has_simulated_content, bool)
721 restored = ObservationInfo.from_flat_dict(read, prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
722 self.assert_fits_round_trip(obsinfo, restored)
724 ObservationInfo.strip_from_flat_dict(read)
725 self.assertFalse(ObservationInfo.has_flat_dict(read))
726 self.assertFalse(any(key.startswith("OBS_INFO") for key in read))
727 self.assertEqual(read["OBJECT"], "decoy")
728 self.assertEqual(read["TEMPERATURE"], 3.0)
730 def test_fits_header_without_content(self) -> None:
731 header = fits.Header()
732 header["OBJECT"] = "M31"
733 header["HIERARCH TEMPERATURE"] = 3.0
734 header["HIERARCH PRESSURE"] = 700.0
735 read = self._write_and_read(header)
736 n_cards = len(read)
737 self.assertFalse(ObservationInfo.has_flat_dict(read))
738 ObservationInfo.strip_from_flat_dict(read)
739 self.assertEqual(len(read), n_cards)
740 with self.assertRaises(ValueError):
741 ObservationInfo.from_flat_dict(read)
743 def test_fits_header_non_finite(self) -> None:
744 obsinfo = makeObservationInfo(
745 observation_id="A",
746 datetime_begin=astropy.time.Time("2020-01-01T00:00:00", scale="tai"),
747 relative_humidity=math.nan,
748 temperature=math.inf * u.K,
749 )
750 flat = obsinfo.to_flat_dict(
751 prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP, encode_non_finite=True
752 )
753 header = fits.Header()
754 for key, value in flat.items():
755 header[f"HIERARCH {key}"] = value
756 read = self._write_and_read(header)
757 self.assertEqual(read["OBS_INFO RELATIVE_HUMIDITY"], "NAN")
758 self.assertEqual(read["OBS_INFO TEMPERATURE"], "+INFINITY")
760 restored = ObservationInfo.from_flat_dict(read)
761 self.assertTrue(math.isnan(restored.relative_humidity))
762 self.assertEqual(restored.temperature.value, math.inf)
764 def test_nan_passes_through_plain_dict(self) -> None:
765 obsinfo = makeObservationInfo(observation_id="A", relative_humidity=math.nan)
766 flat = obsinfo.to_flat_dict()
767 self.assertTrue(math.isnan(flat["relative_humidity"]))
768 restored = ObservationInfo.from_flat_dict(flat, prefix="")
769 self.assertIsNotNone(restored.relative_humidity)
770 self.assertTrue(math.isnan(restored.relative_humidity))
773@unittest.skipUnless(PropertyList is not None, "lsst.daf.base is not available")
774class PropertyListTestCase(unittest.TestCase):
775 """Test the flat dict with the daf_base PropertyList used downstream."""
777 def test_property_list_round_trip(self) -> None:
778 obsinfo = _read_obsinfo("fitsheader-decam.yaml")
779 flat = obsinfo.to_flat_dict(prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
780 pl = PropertyList()
781 pl["OBJECT"] = "decoy"
782 for key, value in flat.items():
783 pl[key] = value
785 self.assertTrue(ObservationInfo.has_flat_dict(pl))
786 restored = ObservationInfo.from_flat_dict(pl, prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
787 self.assertEqual(obsinfo.to_simple(), restored.to_simple())
788 self.assertEqual(obsinfo.to_simple(), ObservationInfo.from_flat_dict(pl).to_simple())
790 ObservationInfo.strip_from_flat_dict(pl, prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP)
791 self.assertEqual(list(pl), ["OBJECT"])
794if __name__ == "__main__":
795 unittest.main()
798@unittest.skipUnless(afw_image is not None, "lsst.afw is not available")
799class AfwFitsTestCase(unittest.TestCase):
800 """Test a FITS file written by afw, which encodes non-finite floats
801 itself, read back by both afw and astropy.
802 """
804 def test_afw_written_non_finite(self) -> None:
805 obsinfo = makeObservationInfo(
806 observation_id="A",
807 datetime_begin=astropy.time.Time("2020-01-01T00:00:00", scale="tai"),
808 relative_humidity=math.nan,
809 temperature=math.inf * u.K,
810 )
811 pl = PropertyList()
812 for key, value in obsinfo.to_flat_dict(prefix=FITS_FLAT_DICT_PREFIX, sep=FITS_FLAT_DICT_SEP).items():
813 pl[key] = value
815 with tempfile.TemporaryDirectory() as tmpdir:
816 path = os.path.join(tmpdir, "test.fits")
817 afw_image.ImageF(2, 2).writeFits(path, pl)
818 afw_header = afw_fits.readMetadata(path)
819 astropy_header = fits.getheader(path)
821 self.assertEqual(astropy_header["OBS_INFO RELATIVE_HUMIDITY"], "NAN")
822 for header in (afw_header, astropy_header):
823 restored = ObservationInfo.from_flat_dict(header)
824 self.assertTrue(math.isnan(restored.relative_humidity))
825 self.assertEqual(restored.temperature.value, math.inf)