Coverage for tests/test_serialization.py: 100%
134 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 01:55 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 01:55 -0700
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 # Translator class is empty.
134 self.assertIsNone(obsinfo._translator)
135 self.assertEqual(obsinfo.translator_class_name, "<None>")
137 # Round-tripping through JSON should be stable.
138 round_tripped = ObservationInfo.from_json(obsinfo.to_json())
139 self.assertEqual(round_tripped, obsinfo)
141 # Should not include a spurious translator name.
142 self.assertNotIn("_translator", obsinfo.model_dump())
144 def test_read_full_with_extensions(self) -> None:
145 """Read the fixture that also contains extension keys."""
146 path = os.path.join(DATADIR, "obsinfo-full-extensions.json")
147 with open(path) as fh:
148 obsinfo = ObservationInfo.from_json(fh.read())
150 self.assertIsInstance(obsinfo, ObservationInfo)
151 self._check_core_properties(obsinfo)
153 # Extension properties round-trip via the "fixture" translator name
154 # stored in the JSON.
155 self.assertEqual(set(obsinfo.extensions), {"number", "foo"})
156 self.assertEqual(obsinfo.ext_foo, "bar")
157 self.assertEqual(obsinfo.ext_number, 12345)
159 self.assertIsInstance(obsinfo._translator, _FixtureTranslator)
160 self.assertEqual(obsinfo.translator_name, "fixture")
162 round_tripped = ObservationInfo.from_json(obsinfo.to_json())
163 self.assertEqual(round_tripped, obsinfo)
164 self.assertEqual(round_tripped.ext_foo, "bar")
165 self.assertEqual(round_tripped.ext_number, 12345)
167 # Should dump the translator name.
168 self.assertIsInstance(round_tripped._translator, _FixtureTranslator)
169 self.assertEqual(round_tripped.translator_name, "fixture")
170 self.assertIn("_translator", obsinfo.model_dump())
172 # If there are extension properties the translator must be registered
173 # so that the extension definitions are available. The error must
174 # identify the missing translator as the cause rather than simply
175 # reporting an unknown property.
176 path = os.path.join(DATADIR, "obsinfo-extensions-unknown-translator.json")
177 with open(path) as fh:
178 json_str = fh.read()
179 with self.assertRaises(KeyError) as cm:
180 ObservationInfo.from_json(json_str)
181 message = str(cm.exception)
182 self.assertIn("fixture_unknown", message)
183 self.assertIn("not registered", message)
184 self.assertIn("ext_foo", message)
186 def test_read_unregistered(self) -> None:
187 """Read a serialization with unregistered translator name."""
188 path = os.path.join(DATADIR, "obsinfo-unregistered.json")
189 with open(path) as fh:
190 obsinfo = ObservationInfo.from_json(fh.read())
192 self.assertIsInstance(obsinfo, ObservationInfo)
193 self._check_core_properties(obsinfo)
194 # No extensions present in this fixture.
195 self.assertEqual(obsinfo.extensions, {})
197 self.assertIsNone(obsinfo._translator, None)
198 self.assertEqual(obsinfo.translator_name, "Unregistered")
200 # Round-tripping through JSON should be stable.
201 round_tripped = ObservationInfo.from_json(obsinfo.to_json())
202 self.assertEqual(round_tripped, obsinfo)
203 self.assertEqual(round_tripped.translator_name, "Unregistered")
205 def test_nested_in_pydantic_model(self) -> None:
206 """Nesting in another Pydantic model preserves the wire format.
208 Nested serialization goes through Pydantic directly and does not
209 call ``to_simple`` / ``to_json``, so the alias behavior must come
210 from ``serialize_by_alias`` in the model config rather than from
211 the call-site kwargs.
212 """
214 class Wrapper(pydantic.BaseModel):
215 obs: ObservationInfo
217 path = os.path.join(DATADIR, "obsinfo-full-extensions.json")
218 with open(path) as fh:
219 obsinfo = ObservationInfo.from_json(fh.read())
221 wrapper = Wrapper(obs=obsinfo)
223 nested = wrapper.model_dump()["obs"]
224 self.assertIn("_translator", nested)
225 self.assertNotIn("translator_name", nested)
226 # Extension keys remain flat under the ext_ prefix.
227 self.assertEqual(nested["ext_foo"], "bar")
228 self.assertEqual(nested["ext_number"], 12345)
230 nested_json = wrapper.model_dump_json()
231 round_tripped = Wrapper.model_validate_json(nested_json)
232 self.assertEqual(round_tripped.obs, obsinfo)
235if __name__ == "__main__":
236 unittest.main()