Coverage for tests/test_astropy_header_conversion.py: 97%

93 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 00:35 +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/>. 

21 

22import math 

23import unittest 

24 

25import astropy.io.fits 

26 

27import lsst.utils.tests 

28from lsst.daf.base import PropertyList 

29 

30 

31def _card80(image: str) -> str: 

32 """Pad a FITS card image to exactly 80 characters.""" 

33 return image.ljust(80)[:80] 

34 

35 

36class AstropyHeaderConversionTestCase(lsst.utils.tests.TestCase): 

37 """Tests for PropertyList.from_astropy_header and to_astropy_header.""" 

38 

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 

55 

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") 

63 

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 ) 

84 

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) 

99 

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 ) 

121 

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") 

131 

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 ) 

156 

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")) 

168 

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 ) 

187 

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") 

199 

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() 

215 

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"]) 

226 

227 

228class TestMemory(lsst.utils.tests.MemoryTestCase): 

229 pass 

230 

231 

232def setup_module(module): 

233 lsst.utils.tests.init() 

234 

235 

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()