Coverage for tests/test_serialization.py: 21%
105 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-30 08:37 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-30 08:37 +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"""Test that archival ObservationInfo JSON files can be read back.
14The JSON files in ``tests/data`` are stored long term, so we need to
15guarantee that future versions of the code can still deserialize them.
16"""
18import os
19import unittest
20from collections.abc import Mapping
21from typing import Any
23import astropy.coordinates
24import astropy.time
25import astropy.units as u
26import pydantic
28from astro_metadata_translator import (
29 ObservationInfo,
30 PropertyDefinition,
31 StubTranslator,
32)
34TESTDIR = os.path.abspath(os.path.dirname(__file__))
35DATADIR = os.path.join(TESTDIR, "data")
38# Auto-registers via StubTranslator.__init_subclass__ when this module is
39# imported, so the "_translator": "fixture" reference in the archival JSON
40# resolves and its extension definitions are available.
41class _FixtureTranslator(StubTranslator):
42 name = "fixture"
43 supported_instrument = "fixture"
44 extensions = dict(
45 number=PropertyDefinition("A number", int),
46 foo=PropertyDefinition("A string", str),
47 )
49 @classmethod
50 def can_translate(cls, header: Mapping[str, Any], filename: str | None = None) -> bool:
51 # Never used for header-based auto-detection; this translator only
52 # exists so the "_translator": "fixture" reference in the archival
53 # JSON can be resolved.
54 return False
57class ObservationInfoSerializationTestCase(unittest.TestCase):
58 """Read archival ObservationInfo JSON fixtures and check the values."""
60 def _check_core_properties(self, obsinfo: ObservationInfo) -> None:
61 self.assertEqual(obsinfo.telescope, "Test Telescope")
62 self.assertEqual(obsinfo.instrument, "TestCam")
63 self.assertEqual(obsinfo.exposure_id, 987654321)
64 self.assertEqual(obsinfo.visit_id, 987654000)
65 self.assertEqual(obsinfo.physical_filter, "r")
66 self.assertEqual(obsinfo.detector_num, 42)
67 self.assertEqual(obsinfo.detector_name, "S00")
68 self.assertEqual(obsinfo.detector_unique_name, "R22_S00")
69 self.assertEqual(obsinfo.detector_serial, "ITL-3800C-145")
70 self.assertEqual(obsinfo.detector_group, "R22")
71 self.assertEqual(obsinfo.detector_exposure_id, 98765432142)
72 self.assertEqual(obsinfo.object, "HD12345")
73 self.assertEqual(obsinfo.science_program, "2025A-001")
74 self.assertEqual(obsinfo.observation_type, "science")
75 self.assertEqual(obsinfo.observation_id, "TC_O_20250521_000123")
76 self.assertEqual(obsinfo.observation_reason, "science")
77 self.assertEqual(obsinfo.exposure_group, "20250521_000123")
78 self.assertEqual(obsinfo.observing_day, 20250520)
79 self.assertEqual(obsinfo.observation_counter, 123)
80 self.assertEqual(obsinfo.group_counter_start, 123)
81 self.assertEqual(obsinfo.group_counter_end, 123)
82 self.assertEqual(obsinfo.boresight_rotation_coord, "sky")
83 self.assertEqual(obsinfo.has_simulated_content, False)
84 self.assertEqual(obsinfo.can_see_sky, True)
86 self.assertAlmostEqual(obsinfo.boresight_airmass, 1.1037)
87 self.assertAlmostEqual(obsinfo.relative_humidity, 42.5)
89 self.assertIsInstance(obsinfo.location, astropy.coordinates.EarthLocation)
90 self.assertAlmostEqual(obsinfo.location.height.to_value(u.m), 2663.0, places=3)
92 self.assertIsInstance(obsinfo.datetime_begin, astropy.time.Time)
93 self.assertEqual(obsinfo.datetime_begin.scale, "tai")
94 self.assertEqual(
95 obsinfo.datetime_begin.tai.isot,
96 "2025-05-21T01:23:45.000",
97 )
98 self.assertEqual(
99 obsinfo.datetime_end.tai.isot,
100 "2025-05-21T01:24:15.000",
101 )
103 self.assertAlmostEqual(obsinfo.exposure_time.to_value(u.s), 30.0)
104 self.assertAlmostEqual(obsinfo.exposure_time_requested.to_value(u.s), 30.0)
105 self.assertAlmostEqual(obsinfo.dark_time.to_value(u.s), 31.0)
106 self.assertAlmostEqual(obsinfo.focus_z.to_value(u.m), 0.001)
107 self.assertAlmostEqual(obsinfo.temperature.to_value(u.K), 283.15)
108 self.assertAlmostEqual(obsinfo.pressure.to_value(u.hPa), 750.0)
109 self.assertAlmostEqual(obsinfo.boresight_rotation_angle.to_value(u.deg), 45.0)
110 self.assertEqual(round(obsinfo.observing_day_offset.to_value(u.s)), 43200)
112 self.assertIsInstance(obsinfo.tracking_radec, astropy.coordinates.SkyCoord)
113 self.assertAlmostEqual(obsinfo.tracking_radec.icrs.ra.to_value(u.deg), 123.456)
114 self.assertAlmostEqual(obsinfo.tracking_radec.icrs.dec.to_value(u.deg), -45.678)
116 self.assertIsInstance(obsinfo.altaz_begin, astropy.coordinates.AltAz)
117 self.assertAlmostEqual(obsinfo.altaz_begin.az.to_value(u.deg), 110.0)
118 self.assertAlmostEqual(obsinfo.altaz_begin.alt.to_value(u.deg), 65.0)
119 self.assertAlmostEqual(obsinfo.altaz_end.az.to_value(u.deg), 110.5)
120 self.assertAlmostEqual(obsinfo.altaz_end.alt.to_value(u.deg), 64.5)
122 def test_read_full(self) -> None:
123 """Read the fixture with every core property populated."""
124 path = os.path.join(DATADIR, "obsinfo-full.json")
125 with open(path) as fh:
126 obsinfo = ObservationInfo.from_json(fh.read())
128 self.assertIsInstance(obsinfo, ObservationInfo)
129 self._check_core_properties(obsinfo)
130 # No extensions present in this fixture.
131 self.assertEqual(obsinfo.extensions, {})
133 # Round-tripping through JSON should be stable.
134 round_tripped = ObservationInfo.from_json(obsinfo.to_json())
135 self.assertEqual(round_tripped, obsinfo)
137 def test_read_full_with_extensions(self) -> None:
138 """Read the fixture that also contains extension keys."""
139 path = os.path.join(DATADIR, "obsinfo-full-extensions.json")
140 with open(path) as fh:
141 obsinfo = ObservationInfo.from_json(fh.read())
143 self.assertIsInstance(obsinfo, ObservationInfo)
144 self._check_core_properties(obsinfo)
146 # Extension properties round-trip via the "fixture" translator name
147 # stored in the JSON.
148 self.assertEqual(set(obsinfo.extensions), {"number", "foo"})
149 self.assertEqual(obsinfo.ext_foo, "bar")
150 self.assertEqual(obsinfo.ext_number, 12345)
152 round_tripped = ObservationInfo.from_json(obsinfo.to_json())
153 self.assertEqual(round_tripped, obsinfo)
154 self.assertEqual(round_tripped.ext_foo, "bar")
155 self.assertEqual(round_tripped.ext_number, 12345)
157 def test_nested_in_pydantic_model(self) -> None:
158 """Nesting in another Pydantic model preserves the wire format.
160 Nested serialization goes through Pydantic directly and does not
161 call ``to_simple`` / ``to_json``, so the alias behavior must come
162 from ``serialize_by_alias`` in the model config rather than from
163 the call-site kwargs.
164 """
166 class Wrapper(pydantic.BaseModel):
167 obs: ObservationInfo
169 path = os.path.join(DATADIR, "obsinfo-full-extensions.json")
170 with open(path) as fh:
171 obsinfo = ObservationInfo.from_json(fh.read())
173 wrapper = Wrapper(obs=obsinfo)
175 nested = wrapper.model_dump()["obs"]
176 self.assertIn("_translator", nested)
177 self.assertNotIn("translator_name", nested)
178 # Extension keys remain flat under the ext_ prefix.
179 self.assertEqual(nested["ext_foo"], "bar")
180 self.assertEqual(nested["ext_number"], 12345)
182 nested_json = wrapper.model_dump_json()
183 round_tripped = Wrapper.model_validate_json(nested_json)
184 self.assertEqual(round_tripped.obs, obsinfo)
187if __name__ == "__main__":
188 unittest.main()