Coverage for tests/test_astropy_header_conversion.py: 96%
71 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 22:37 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 22:37 +0000
1# This file is part of daf_base
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 COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
22import unittest
24import astropy.io.fits
26import lsst.utils.tests
27from lsst.daf.base import PropertyList
30def _card80(image: str) -> str:
31 """Pad a FITS card image to exactly 80 characters."""
32 return image.ljust(80)[:80]
35class AstropyHeaderConversionTestCase(lsst.utils.tests.TestCase):
36 """Tests for PropertyList.from_astropy_header and to_astropy_header."""
38 def test_header_round_trip_through_property_list(self):
39 """Test that an Astropy header converted to PropertyList and back
40 keeps all of its cards, including duplicated keywords, comments, and
41 HISTORY and COMMENT cards.
42 """
43 header = astropy.io.fits.Header()
44 header.append(("PLATFORM", "lsstcam"), end=True)
45 header.append(("BGMEAN", 1.5), end=True)
46 header.append(("BGMEAN", 2.5), end=True)
47 header.append(astropy.io.fits.Card("ZPT", 27.3, "derived zeropoint"), end=True)
48 header.add_history("made by lsst_pipe")
49 header.add_history("processed by cp_pipe")
50 # Text containing "=" makes astropy treat this as a valueless card.
51 header.add_comment("derived zeropoint = 27.3")
52 header.append(astropy.io.fits.Card("", ""), end=True) # blank card
53 header["EXPTIME"] = 30.0
55 pl = PropertyList.from_astropy_header(header)
56 # Duplicated keys keep all of their values in the PropertyList.
57 self.assertEqual(pl.getArray("BGMEAN"), [1.5, 2.5])
58 self.assertEqual(
59 pl.getArray("HISTORY"), ["made by lsst_pipe", "processed by cp_pipe"]
60 )
61 self.assertEqual(pl.getComment("ZPT"), "derived zeropoint")
63 round_tripped = pl.to_astropy_header()
64 # Blank cards are dropped; everything else keeps its keyword, value,
65 # and comment, in order.
66 expected = [
67 (card.keyword, card.value, card.comment) for card in header.cards if card.keyword
68 ]
69 self.assertEqual(
70 [(card.keyword, card.value, card.comment) for card in round_tripped.cards],
71 expected,
72 )
73 # Test that HISTORY and COMMENT cards render without "= value" to make
74 # sure we passed them back to Astropy correctly.
75 self.assertEqual(
76 [str(card).rstrip() for card in round_tripped.cards if card.keyword == "HISTORY"],
77 ["HISTORY made by lsst_pipe", "HISTORY processed by cp_pipe"],
78 )
79 self.assertEqual(
80 [str(card).rstrip() for card in round_tripped.cards if card.keyword == "COMMENT"],
81 ["COMMENT derived zeropoint = 27.3"],
82 )
84 def test_property_list_metadata_round_trip_through_header(self):
85 """Test that a PropertyList converted to an Astropy header and back
86 keeps all of its values and comments, including duplicated keys and
87 HISTORY and COMMENT entries.
88 """
89 metadata = PropertyList()
90 metadata["PLATFORM"] = "lsstcam"
91 metadata.add("BGMEAN", 1.5)
92 metadata.add("BGMEAN", 2.5)
93 metadata.add("SEEING", 1.2, "arcsec")
94 metadata.add("HISTORY", "made by lsst_pipe")
95 metadata.add("HISTORY", "processed by cp_pipe")
96 metadata.add("COMMENT", "derived zeropoint = 27.3")
97 metadata.add("BGMEAN", 3.5)
99 header = metadata.to_astropy_header()
100 self.assertEqual(
101 [(card.keyword, card.value) for card in header.cards],
102 [
103 ("PLATFORM", "lsstcam"),
104 ("BGMEAN", 1.5),
105 ("BGMEAN", 2.5),
106 ("BGMEAN", 3.5),
107 ("SEEING", 1.2),
108 ("HISTORY", "made by lsst_pipe"),
109 ("HISTORY", "processed by cp_pipe"),
110 ("COMMENT", "derived zeropoint = 27.3"),
111 ],
112 )
113 self.assertEqual(
114 [card.comment for card in header.cards if card.keyword == "SEEING"], ["arcsec"]
115 )
116 self.assertEqual(
117 [str(card).rstrip() for card in header.cards if card.keyword == "HISTORY"],
118 ["HISTORY made by lsst_pipe", "HISTORY processed by cp_pipe"],
119 )
121 round_tripped = PropertyList.from_astropy_header(header)
122 self.assertEqual(round_tripped.getArray("BGMEAN"), [1.5, 2.5, 3.5])
123 self.assertEqual(
124 round_tripped.getArray("HISTORY"), ["made by lsst_pipe", "processed by cp_pipe"]
125 )
126 self.assertEqual(round_tripped.getArray("COMMENT"), ["derived zeropoint = 27.3"])
127 self.assertEqual(round_tripped["PLATFORM"], "lsstcam")
128 self.assertEqual(round_tripped.getScalar("SEEING"), 1.2)
129 self.assertEqual(round_tripped.getComment("SEEING"), "arcsec")
131 def test_header_to_property_list_undef_and_skips(self):
132 """Test how cards with values are handled: unparsable values (real
133 raws have headers with unterminated quoted strings) are skipped,
134 while `Undefined` values (e.g. an empty ``SEEING =``) and value-less
135 cards with comments are stored as undefined properties.
136 """
137 header = astropy.io.fits.Header.fromstring(
138 "".join(
139 [
140 _card80("PLATFORM= 'lsstcam'"),
141 _card80("SEEING ="),
142 _card80("FOO = / comment only"),
143 _card80("PROGRAM = 'HITS: real-time detection of &"),
144 _card80("CONTINUE 'stellar explosions&'"),
145 _card80("BGMEAN = 1.5"),
146 _card80("BGMEAN = 2.5"),
147 ]
148 )
149 )
150 # The unparsable and undefined cards must at least be present.
151 self.assertIn("PROGRAM", header)
152 self.assertIsInstance(
153 header.cards["SEEING"].value, astropy.io.fits.card.Undefined
154 )
156 pl = PropertyList.from_astropy_header(header)
157 self.assertEqual(pl["PLATFORM"], "lsstcam")
158 self.assertEqual(pl.getArray("BGMEAN"), [1.5, 2.5])
159 # Undefined cards are stored as undefined, not skipped.
160 self.assertTrue(pl.exists("SEEING"))
161 self.assertTrue(pl.isUndefined("SEEING"))
162 self.assertTrue(pl.exists("FOO"))
163 self.assertTrue(pl.isUndefined("FOO"))
164 self.assertEqual(pl.getComment("FOO"), "comment only")
165 # Unparsable values have no PropertyList representation at all.
166 self.assertFalse(pl.exists("PROGRAM"))
168 round_tripped = pl.to_astropy_header()
169 self.assertEqual(
170 [
171 (card.keyword, isinstance(card.value, astropy.io.fits.card.Undefined))
172 for card in round_tripped.cards
173 ],
174 [
175 ("PLATFORM", False),
176 ("SEEING", True),
177 ("FOO", True),
178 ("BGMEAN", False),
179 ("BGMEAN", False),
180 ],
181 )
182 self.assertEqual(
183 [card.comment for card in round_tripped.cards if card.keyword == "FOO"],
184 ["comment only"],
185 )
188class TestMemory(lsst.utils.tests.MemoryTestCase):
189 pass
192def setup_module(module):
193 lsst.utils.tests.init()
196if __name__ == "__main__": 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true
197 lsst.utils.tests.init()
198 unittest.main()