Coverage for tests/test_guider.py: 40%

195 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-19 10:48 +0000

1# This file is part of summit_utils. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://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 <https://www.gnu.org/licenses/>. 

21 

22import os 

23import tempfile 

24import unittest 

25 

26import numpy as np 

27import pandas as pd 

28 

29import lsst.utils.tests 

30from lsst.daf.butler import Butler 

31from lsst.meas.algorithms.stamps import Stamps 

32from lsst.summit.utils.butlerUtils import makeDefaultButler 

33from lsst.summit.utils.guiders.detection import GuiderStarTrackerConfig, isBlankImage 

34from lsst.summit.utils.guiders.metrics import GuiderMetricsBuilder 

35from lsst.summit.utils.guiders.plotting import GuiderPlotter 

36from lsst.summit.utils.guiders.reading import GuiderData, GuiderReader 

37from lsst.summit.utils.guiders.seeing import CorrelationAnalysis, GuiderSeeing 

38from lsst.summit.utils.guiders.tracking import GuiderStarTracker, _diagnoseQualityCutRejections 

39from lsst.summit.utils.utils import getSite 

40 

41 

42class GuiderTestCase(unittest.TestCase): 

43 """Tests of the run method with fake data.""" 

44 

45 def setUp(self) -> None: 

46 try: 

47 if getSite() == "jenkins": 47 ↛ 49line 47 didn't jump to line 49 because the condition on line 47 was always true

48 raise unittest.SkipTest("Skip running butler-driven tests in Jenkins.") 

49 self.butler = makeDefaultButler("LSSTCam", embargo=False) 

50 except FileNotFoundError: 

51 raise unittest.SkipTest("Skipping tests that require the LSSTCam butler repo.") 

52 self.assertIsInstance(self.butler, Butler) 

53 

54 self.dayObs = 20250629 

55 self.seqNum = 340 

56 self.expId = 2025062900340 

57 

58 self.reader = GuiderReader(self.butler, view="dvcs") 

59 self.guiderData = self.reader.get(dayObs=self.dayObs, seqNum=self.seqNum) 

60 self.tracker = GuiderStarTracker(self.guiderData) 

61 self.stars = self.tracker.trackGuiderStars(refCatalog=None) 

62 self.plotter = GuiderPlotter(self.guiderData, starsDf=self.stars) 

63 self.metricsBuilder = GuiderMetricsBuilder(self.stars, self.guiderData.nMissingStamps) 

64 

65 def test_types(self) -> None: 

66 self.assertIsInstance(self.guiderData.header, dict) 

67 self.assertIsInstance(self.guiderData.stampsMap, dict) 

68 

69 expectedKeys = ( 

70 "R00_SG0", 

71 "R00_SG1", 

72 "R04_SG0", 

73 "R04_SG1", 

74 "R40_SG0", 

75 "R40_SG1", 

76 "R44_SG0", 

77 "R44_SG1", 

78 ) 

79 self.assertTrue( 

80 all(key in self.guiderData.stampsMap for key in expectedKeys), 

81 "Not all expected guider datasets are present in the data.", 

82 ) 

83 

84 detName = "R00_SG0" 

85 single = self.guiderData[detName, 0] 

86 self.assertIsInstance(single, np.ndarray) 

87 stack = self.guiderData.getStampArrayCoadd(detName=detName) 

88 self.assertIsInstance(stack, np.ndarray) 

89 self.assertEqual(single.shape, (400, 400)) 

90 

91 return 

92 

93 def test_reading(self) -> None: 

94 """Test that the reader can read the expected data.""" 

95 self.assertTrue(self.guiderData.isMedianSubtracted, "isMedianSubtracted not set correctly") 

96 for detName in self.guiderData.guiderNames: 

97 single = self.guiderData[detName, 0] 

98 self.assertIsInstance(single, np.ndarray) 

99 self.assertEqual(single.shape, (400, 400)) 

100 self.assertLessEqual(abs(np.nanmedian(single)), 10, "median subtracted median is too high") 

101 

102 stack = self.guiderData.getStampArrayCoadd(detName=detName) 

103 self.assertIsInstance(stack, np.ndarray) 

104 self.assertEqual(stack.shape, (400, 400)) 

105 self.assertLessEqual(abs(np.nanmedian(stack)), 10, "stack median subtracted median is too high") 

106 

107 fullStamps = self.guiderData[detName] 

108 self.assertIsInstance(fullStamps, Stamps) 

109 

110 noMedianSubtracted = self.reader.get(dayObs=self.dayObs, seqNum=self.seqNum, doSubtractMedian=False) 

111 self.assertIsInstance(noMedianSubtracted, GuiderData) 

112 self.assertFalse(noMedianSubtracted.isMedianSubtracted, "isMedianSubtracted not set correctly") 

113 for detName in noMedianSubtracted.guiderNames: 

114 single = noMedianSubtracted[detName, 0] 

115 self.assertIsInstance(single, np.ndarray) 

116 self.assertEqual(single.shape, (400, 400)) 

117 self.assertGreater(abs(np.nanmedian(single)), 500, "median un-subtracted median is too low") 

118 

119 stack = noMedianSubtracted.getStampArrayCoadd(detName=detName) 

120 self.assertIsInstance(stack, np.ndarray) 

121 self.assertEqual(stack.shape, (400, 400)) 

122 self.assertGreater(abs(np.nanmedian(single)), 500, "median un-subtracted median is too low") 

123 

124 fullStamps = noMedianSubtracted[detName] 

125 self.assertIsInstance(fullStamps, Stamps) 

126 

127 def test_detection(self) -> None: 

128 self.assertIsInstance(self.stars, pd.DataFrame) 

129 requiredColumns = ( 

130 "xroi", 

131 "yroi", 

132 "dx", 

133 "dy", 

134 "dalt", 

135 "daz", 

136 "fwhm", 

137 "trackid", 

138 "expid", 

139 ) 

140 self.assertTrue( 

141 all(col in self.stars.columns for col in requiredColumns), 

142 "Not all required columns are present in the stars DataFrame.", 

143 ) 

144 

145 maxStampIndex = max(self.stars["stamp"]) 

146 nStamps = len(self.guiderData) # we should make an attribute for this 

147 

148 # we skip the first stamp, so the max index should be nStamps - 1 

149 self.assertEqual(maxStampIndex, nStamps - 1, "Did not get detections for all expected stamps") 

150 

151 def testPlotMosaicFullView(self) -> None: 

152 with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as tmp: 

153 self.plotter.plotMosaic(stampNum=-1, cutoutSize=-1, plo=50, phi=98, saveAs=tmp.name) 

154 os.fsync(tmp.fileno()) # be strict 

155 size = os.path.getsize(tmp.name) 

156 self.assertGreater(size, 1000, f"{tmp.name} too small: {size} bytes") 

157 

158 def testPlotMosaicZoomView(self) -> None: 

159 with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as tmp: 

160 self.plotter.plotMosaic(stampNum=-1, cutoutSize=12, plo=50, phi=98, saveAs=tmp.name) 

161 os.fsync(tmp.fileno()) # be strict 

162 size = os.path.getsize(tmp.name) 

163 self.assertGreater(size, 1000, f"{tmp.name} too small: {size} bytes") 

164 

165 def testPlotMosaicStampZoomView(self) -> None: 

166 with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as tmp: 

167 self.plotter.plotMosaic(stampNum=4, cutoutSize=12, plo=50, phi=98, saveAs=tmp.name) 

168 os.fsync(tmp.fileno()) # be strict 

169 size = os.path.getsize(tmp.name) 

170 self.assertGreater(size, 1000, f"{tmp.name} too small: {size} bytes") 

171 

172 def testStripPlotPsf(self) -> None: 

173 with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as tmp: 

174 self.plotter.stripPlot(plotType="psf", saveAs=tmp.name) 

175 os.fsync(tmp.fileno()) 

176 size = os.path.getsize(tmp.name) 

177 self.assertGreater(size, 1000, f"{tmp.name} too small: {size} bytes") 

178 

179 def testStripPlotCentroidAltAz(self) -> None: 

180 with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as tmp: 

181 self.plotter.stripPlot(plotType="centroidAltAz", saveAs=tmp.name) 

182 os.fsync(tmp.fileno()) 

183 size = os.path.getsize(tmp.name) 

184 self.assertGreater(size, 1000, f"{tmp.name} too small: {size} bytes") 

185 

186 def testStripPlotFlux(self) -> None: 

187 with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as tmp: 

188 self.plotter.stripPlot(plotType="flux", saveAs=tmp.name) 

189 os.fsync(tmp.fileno()) 

190 size = os.path.getsize(tmp.name) 

191 self.assertGreater(size, 1000, f"{tmp.name} too small: {size} bytes") 

192 

193 def testStripPlotShape(self) -> None: 

194 with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as tmp: 

195 self.plotter.stripPlot(plotType="ellip", saveAs=tmp.name) 

196 os.fsync(tmp.fileno()) 

197 size = os.path.getsize(tmp.name) 

198 self.assertGreater(size, 1000, f"{tmp.name} too small: {size} bytes") 

199 

200 def testMakeGif(self) -> None: 

201 with tempfile.NamedTemporaryFile(suffix=".gif", delete=True) as tmp: 

202 # test the crop and zoom as a gif 

203 self.plotter.makeAnimation(cutoutSize=14, plo=50, phi=98, saveAs=tmp.name) 

204 os.fsync(tmp.fileno()) 

205 size = os.path.getsize(tmp.name) 

206 self.assertGreater(size, 1000, f"{tmp.name} too small: {size} bytes") 

207 

208 def testMakeMp4(self) -> None: 

209 with tempfile.NamedTemporaryFile(suffix=".mp4", delete=True) as tmp: 

210 # test the full frame as an mp4 

211 self.plotter.makeAnimation(cutoutSize=-1, plo=50, phi=98, saveAs=tmp.name) 

212 os.fsync(tmp.fileno()) 

213 size = os.path.getsize(tmp.name) 

214 self.assertGreater(size, 1000, f"{tmp.name} too small: {size} bytes") 

215 

216 def test_metrics(self) -> None: 

217 # Check that metrics can be built without error 

218 metrics = self.metricsBuilder.buildMetrics(self.expId) 

219 self.assertIsInstance(metrics, pd.DataFrame) 

220 self.assertGreater(len(metrics), 0, "Metrics DataFrame is empty") 

221 

222 # These are what's currently there. Why is the 8th guider missing? 

223 expectedColumns = ( 

224 "n_guiders", 

225 "n_stars", 

226 "n_missing_stamps", 

227 "n_measurements", 

228 "fraction_possible_measurements", 

229 "exptime", 

230 "R00_SG0", 

231 "R00_SG1", 

232 "R04_SG0", 

233 "R04_SG1", 

234 "R40_SG0", 

235 "R40_SG1", 

236 "R44_SG0", 

237 "R44_SG1", 

238 "az_drift_slope", 

239 "az_drift_intercept", 

240 "az_drift_trend_rmse", 

241 "az_drift_global_std", 

242 "az_drift_outlier_frac", 

243 "az_drift_slope_significance", 

244 "az_drift_nsize", 

245 "alt_drift_slope", 

246 "alt_drift_intercept", 

247 "alt_drift_trend_rmse", 

248 "alt_drift_global_std", 

249 "alt_drift_outlier_frac", 

250 "alt_drift_slope_significance", 

251 "alt_drift_nsize", 

252 "rotator_slope", 

253 "rotator_intercept", 

254 "rotator_trend_rmse", 

255 "rotator_global_std", 

256 "rotator_outlier_frac", 

257 "rotator_slope_significance", 

258 "rotator_nsize", 

259 "mag_slope", 

260 "mag_intercept", 

261 "mag_trend_rmse", 

262 "mag_global_std", 

263 "mag_outlier_frac", 

264 "mag_slope_significance", 

265 "mag_nsize", 

266 "psf_slope", 

267 "psf_intercept", 

268 "psf_trend_rmse", 

269 "psf_global_std", 

270 "psf_outlier_frac", 

271 "psf_slope_significance", 

272 "psf_nsize", 

273 ) 

274 for col in expectedColumns: 

275 self.assertIn(col, metrics.columns, f"Column {col} is missing from metrics DataFrame") 

276 

277 # check this runs without error 

278 self.metricsBuilder.printSummary() 

279 

280 def testTomographicSeeing(self) -> None: 

281 analysis = CorrelationAnalysis(self.stars, self.expId) 

282 variance = np.nanmedian(analysis.measureVariance()) 

283 self.assertGreater(variance, 0, "Variance should be positive and non-zero") 

284 self.assertIsInstance(variance, float) 

285 

286 seeing = analysis.measureTomographicSeeing() 

287 self.assertIsInstance(seeing, GuiderSeeing) 

288 

289 

290class IsBlankImageTestCase(unittest.TestCase): 

291 """Pure-function tests for isBlankImage (no butler).""" 

292 

293 def test_blank_image_is_blank(self) -> None: 

294 rng = np.random.default_rng(0) 

295 image = 1000 + rng.normal(0, 2, (50, 50)) 

296 self.assertTrue(isBlankImage(image)) 

297 

298 def test_bright_source_fails_flux_check(self) -> None: 

299 rng = np.random.default_rng(0) 

300 image = 1000 + rng.normal(0, 2, (50, 50)) 

301 image[25, 25] += 1000 

302 self.assertFalse(isBlankImage(image, fluxMin=300)) 

303 

304 def test_faint_source_caught_by_snr_check(self) -> None: 

305 # Peak well under fluxMin, but many sigma above the noise -> not blank. 

306 rng = np.random.default_rng(0) 

307 image = 1000 + rng.normal(0, 1, (50, 50)) 

308 image[25, 25] += 50 

309 self.assertFalse(isBlankImage(image, fluxMin=300, peakSnrMin=5.0)) 

310 

311 def test_zero_std_is_blank(self) -> None: 

312 image = np.full((10, 10), 5.0) 

313 self.assertTrue(isBlankImage(image)) 

314 

315 

316class DiagnoseQualityCutRejectionsTestCase(unittest.TestCase): 

317 """Pure-function tests for _diagnoseQualityCutRejections (no butler).""" 

318 

319 def setUp(self) -> None: 

320 self.config = GuiderStarTrackerConfig() 

321 self.shape = (400.0, 400.0) 

322 

323 def test_low_snr_reported(self) -> None: 

324 stars = pd.DataFrame( 

325 { 

326 "snr": [1.0], 

327 "flux": [100.0], 

328 "flux_err": [10.0], 

329 "e1": [0.0], 

330 "e2": [0.0], 

331 "xroi": [200.0], 

332 "yroi": [200.0], 

333 } 

334 ) 

335 reasons = _diagnoseQualityCutRejections(stars, self.shape, self.config) 

336 self.assertEqual(len(reasons), 1) 

337 self.assertIn("snr=", reasons[0]) 

338 

339 def test_high_ellipticity_reported(self) -> None: 

340 stars = pd.DataFrame( 

341 { 

342 "snr": [50.0], 

343 "flux": [100.0], 

344 "flux_err": [10.0], 

345 "e1": [0.9], 

346 "e2": [0.0], 

347 "xroi": [200.0], 

348 "yroi": [200.0], 

349 } 

350 ) 

351 reasons = _diagnoseQualityCutRejections(stars, self.shape, self.config) 

352 self.assertIn("e=", reasons[0]) 

353 

354 def test_edge_position_reported(self) -> None: 

355 stars = pd.DataFrame( 

356 { 

357 "snr": [50.0], 

358 "flux": [100.0], 

359 "flux_err": [10.0], 

360 "e1": [0.0], 

361 "e2": [0.0], 

362 "xroi": [1.0], 

363 "yroi": [1.0], 

364 } 

365 ) 

366 reasons = _diagnoseQualityCutRejections(stars, self.shape, self.config) 

367 self.assertIn("edge", reasons[0]) 

368 

369 def test_passing_star_has_no_reason_string(self) -> None: 

370 stars = pd.DataFrame( 

371 { 

372 "snr": [50.0], 

373 "flux": [100.0], 

374 "flux_err": [10.0], 

375 "e1": [0.0], 

376 "e2": [0.0], 

377 "xroi": [200.0], 

378 "yroi": [200.0], 

379 } 

380 ) 

381 reasons = _diagnoseQualityCutRejections(stars, self.shape, self.config) 

382 self.assertEqual(reasons[0], "?") 

383 

384 

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

386 pass 

387 

388 

389def setup_module(module: object) -> None: 

390 lsst.utils.tests.init() 

391 

392 

393if __name__ == "__main__": 393 ↛ 394line 393 didn't jump to line 394 because the condition on line 393 was never true

394 lsst.utils.tests.init() 

395 unittest.main()