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

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 unittest 

23 

24import astropy.io.fits 

25 

26import lsst.utils.tests 

27from lsst.daf.base import PropertyList 

28 

29 

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

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

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

33 

34 

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

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

37 

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 

54 

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

62 

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 ) 

83 

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) 

98 

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 ) 

120 

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

130 

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 ) 

155 

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

167 

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 ) 

186 

187 

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

189 pass 

190 

191 

192def setup_module(module): 

193 lsst.utils.tests.init() 

194 

195 

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