Coverage for tests/test_astropy_header_conversion.py: 97%
93 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 16:36 -0800
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 16:36 -0800
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 math
23import unittest
25import astropy.io.fits
27import lsst.utils.tests
28from lsst.daf.base import PropertyList
31def _card80(image: str) -> str:
32 """Pad a FITS card image to exactly 80 characters."""
33 return image.ljust(80)[:80]
36class AstropyHeaderConversionTestCase(lsst.utils.tests.TestCase):
37 """Tests for PropertyList.from_astropy_header and to_astropy_header."""
39 def test_header_round_trip_through_property_list(self):
40 """Test that an Astropy header converted to PropertyList and back
41 keeps all of its cards, including duplicated keywords, comments, and
42 HISTORY and COMMENT cards.
43 """
44 header = astropy.io.fits.Header()
45 header.append(("PLATFORM", "lsstcam"), end=True)
46 header.append(("BGMEAN", 1.5), end=True)
47 header.append(("BGMEAN", 2.5), end=True)
48 header.append(astropy.io.fits.Card("ZPT", 27.3, "derived zeropoint"), end=True)
49 header.add_history("made by lsst_pipe")
50 header.add_history("processed by cp_pipe")
51 # Text containing "=" makes astropy treat this as a valueless card.
52 header.add_comment("derived zeropoint = 27.3")
53 header.append(astropy.io.fits.Card("", ""), end=True) # blank card
54 header["EXPTIME"] = 30.0
56 pl = PropertyList.from_astropy_header(header)
57 # Duplicated keys keep all of their values in the PropertyList.
58 self.assertEqual(pl.getArray("BGMEAN"), [1.5, 2.5])
59 self.assertEqual(
60 pl.getArray("HISTORY"), ["made by lsst_pipe", "processed by cp_pipe"]
61 )
62 self.assertEqual(pl.getComment("ZPT"), "derived zeropoint")
64 round_tripped = pl.to_astropy_header()
65 # Blank cards are dropped; everything else keeps its keyword, value,
66 # and comment, in order.
67 expected = [
68 (card.keyword, card.value, card.comment) for card in header.cards if card.keyword
69 ]
70 self.assertEqual(
71 [(card.keyword, card.value, card.comment) for card in round_tripped.cards],
72 expected,
73 )
74 # Test that HISTORY and COMMENT cards render without "= value" to make
75 # sure we passed them back to Astropy correctly.
76 self.assertEqual(
77 [str(card).rstrip() for card in round_tripped.cards if card.keyword == "HISTORY"],
78 ["HISTORY made by lsst_pipe", "HISTORY processed by cp_pipe"],
79 )
80 self.assertEqual(
81 [str(card).rstrip() for card in round_tripped.cards if card.keyword == "COMMENT"],
82 ["COMMENT derived zeropoint = 27.3"],
83 )
85 def test_property_list_metadata_round_trip_through_header(self):
86 """Test that a PropertyList converted to an Astropy header and back
87 keeps all of its values and comments, including duplicated keys and
88 HISTORY and COMMENT entries.
89 """
90 metadata = PropertyList()
91 metadata["PLATFORM"] = "lsstcam"
92 metadata.add("BGMEAN", 1.5)
93 metadata.add("BGMEAN", 2.5)
94 metadata.add("SEEING", 1.2, "arcsec")
95 metadata.add("HISTORY", "made by lsst_pipe")
96 metadata.add("HISTORY", "processed by cp_pipe")
97 metadata.add("COMMENT", "derived zeropoint = 27.3")
98 metadata.add("BGMEAN", 3.5)
100 header = metadata.to_astropy_header()
101 self.assertEqual(
102 [(card.keyword, card.value) for card in header.cards],
103 [
104 ("PLATFORM", "lsstcam"),
105 ("BGMEAN", 1.5),
106 ("BGMEAN", 2.5),
107 ("BGMEAN", 3.5),
108 ("SEEING", 1.2),
109 ("HISTORY", "made by lsst_pipe"),
110 ("HISTORY", "processed by cp_pipe"),
111 ("COMMENT", "derived zeropoint = 27.3"),
112 ],
113 )
114 self.assertEqual(
115 [card.comment for card in header.cards if card.keyword == "SEEING"], ["arcsec"]
116 )
117 self.assertEqual(
118 [str(card).rstrip() for card in header.cards if card.keyword == "HISTORY"],
119 ["HISTORY made by lsst_pipe", "HISTORY processed by cp_pipe"],
120 )
122 round_tripped = PropertyList.from_astropy_header(header)
123 self.assertEqual(round_tripped.getArray("BGMEAN"), [1.5, 2.5, 3.5])
124 self.assertEqual(
125 round_tripped.getArray("HISTORY"), ["made by lsst_pipe", "processed by cp_pipe"]
126 )
127 self.assertEqual(round_tripped.getArray("COMMENT"), ["derived zeropoint = 27.3"])
128 self.assertEqual(round_tripped["PLATFORM"], "lsstcam")
129 self.assertEqual(round_tripped.getScalar("SEEING"), 1.2)
130 self.assertEqual(round_tripped.getComment("SEEING"), "arcsec")
132 def test_header_to_property_list_undef_and_skips(self):
133 """Test how cards with values are handled: unparsable values (real
134 raws have headers with unterminated quoted strings) are skipped,
135 while `Undefined` values (e.g. an empty ``SEEING =``) and value-less
136 cards with comments are stored as undefined properties.
137 """
138 header = astropy.io.fits.Header.fromstring(
139 "".join(
140 [
141 _card80("PLATFORM= 'lsstcam'"),
142 _card80("SEEING ="),
143 _card80("FOO = / comment only"),
144 _card80("PROGRAM = 'HITS: real-time detection of &"),
145 _card80("CONTINUE 'stellar explosions&'"),
146 _card80("BGMEAN = 1.5"),
147 _card80("BGMEAN = 2.5"),
148 ]
149 )
150 )
151 # The unparsable and undefined cards must at least be present.
152 self.assertIn("PROGRAM", header)
153 self.assertIsInstance(
154 header.cards["SEEING"].value, astropy.io.fits.card.Undefined
155 )
157 pl = PropertyList.from_astropy_header(header)
158 self.assertEqual(pl["PLATFORM"], "lsstcam")
159 self.assertEqual(pl.getArray("BGMEAN"), [1.5, 2.5])
160 # Undefined cards are stored as undefined, not skipped.
161 self.assertTrue(pl.exists("SEEING"))
162 self.assertTrue(pl.isUndefined("SEEING"))
163 self.assertTrue(pl.exists("FOO"))
164 self.assertTrue(pl.isUndefined("FOO"))
165 self.assertEqual(pl.getComment("FOO"), "comment only")
166 # Unparsable values have no PropertyList representation at all.
167 self.assertFalse(pl.exists("PROGRAM"))
169 round_tripped = pl.to_astropy_header()
170 self.assertEqual(
171 [
172 (card.keyword, isinstance(card.value, astropy.io.fits.card.Undefined))
173 for card in round_tripped.cards
174 ],
175 [
176 ("PLATFORM", False),
177 ("SEEING", True),
178 ("FOO", True),
179 ("BGMEAN", False),
180 ("BGMEAN", False),
181 ],
182 )
183 self.assertEqual(
184 [card.comment for card in round_tripped.cards if card.keyword == "FOO"],
185 ["comment only"],
186 )
188 def test_non_finite_floats(self):
189 """Test that non-finite floats are written as the special strings
190 used by afw's FITS writer and are converted back to floats on read.
191 """
192 metadata = PropertyList()
193 metadata.add("NANVAL", math.nan, "not a number")
194 metadata.add("POSINF", math.inf)
195 metadata.add("NEGINF", -math.inf)
196 metadata.add("MIXED", 1.5)
197 metadata.add("MIXED", math.nan)
198 metadata.add("COMMENT", "NAN")
200 header = metadata.to_astropy_header()
201 self.assertEqual(
202 [(card.keyword, card.value) for card in header.cards],
203 [
204 ("NANVAL", "NAN"),
205 ("POSINF", "+INFINITY"),
206 ("NEGINF", "-INFINITY"),
207 ("MIXED", 1.5),
208 ("MIXED", "NAN"),
209 ("COMMENT", "NAN"),
210 ],
211 )
212 self.assertEqual(header.comments["NANVAL"], "not a number")
213 # The header must be writable as FITS.
214 header.tostring()
216 round_tripped = PropertyList.from_astropy_header(header)
217 self.assertTrue(math.isnan(round_tripped.getScalar("NANVAL")))
218 self.assertEqual(round_tripped.getComment("NANVAL"), "not a number")
219 self.assertEqual(round_tripped.getScalar("POSINF"), math.inf)
220 self.assertEqual(round_tripped.getScalar("NEGINF"), -math.inf)
221 mixed = round_tripped.getArray("MIXED")
222 self.assertEqual(mixed[0], 1.5)
223 self.assertTrue(math.isnan(mixed[1]))
224 # Commentary cards are text, never converted to floats.
225 self.assertEqual(round_tripped.getArray("COMMENT"), ["NAN"])
228class TestMemory(lsst.utils.tests.MemoryTestCase):
229 pass
232def setup_module(module):
233 lsst.utils.tests.init()
236if __name__ == "__main__": 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true
237 lsst.utils.tests.init()
238 unittest.main()