Coverage for tests/test_ppdb.py: 99%

300 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 09:33 +0000

1# This file is part of analysis_ap. 

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 unittest 

24import unittest.mock as mock 

25 

26import astropy.table 

27 

28import lsst.afw.geom 

29import lsst.afw.image 

30import lsst.geom 

31import lsst.utils.tests 

32from lsst.analysis.ap.ppdb import ( 

33 DEFAULT_PPDB_TAP_URL, 

34 DiaObjectLightCurve, 

35 PpdbTap, 

36 region_from_exposure, 

37) 

38 

39 

40class _FakeTapResults: 

41 """Stand-in for a pyvo TAPResults; just wraps a fixed astropy Table.""" 

42 

43 def __init__(self, table): 

44 self._table = table 

45 

46 def to_table(self): 

47 return self._table 

48 

49 

50class _FakeTapService: 

51 """Record ADQL queries and return preset tables, without any network. 

52 

53 ``tables`` may be a single Table (returned for every call) or a list of 

54 Tables returned for successive calls (the last is repeated thereafter). 

55 """ 

56 

57 def __init__(self, tables): 

58 if isinstance(tables, astropy.table.Table): 

59 tables = [tables] 

60 self._tables = list(tables) 

61 self.queries = [] 

62 self.maxrecs = [] 

63 

64 def run_async(self, query, maxrec=None): 

65 self.queries.append(query) 

66 self.maxrecs.append(maxrec) 

67 index = min(len(self.queries) - 1, len(self._tables) - 1) 

68 return _FakeTapResults(self._tables[index]) 

69 

70 

71def _objects_table(dia_object_ids=(1, 2, 3)): 

72 n = len(dia_object_ids) 

73 return astropy.table.Table({ 

74 "diaObjectId": list(dia_object_ids), 

75 "ra": [150.0 + 0.1 * i for i in range(n)], 

76 "dec": [2.5 + 0.1 * i for i in range(n)], 

77 }) 

78 

79 

80def _sources_table(n=2, id_column="diaSourceId"): 

81 return astropy.table.Table({ 

82 id_column: [100 + i for i in range(n)], 

83 "diaObjectId": [42] * n, 

84 "midpointMjdTai": [60000.0 + i for i in range(n)], 

85 "band": ["r"] * n, 

86 }) 

87 

88 

89def _make_exposure(ra=150.0, dec=2.5, scale=0.2, size=100): 

90 """A square ExposureF with a simple TAN WCS centered at (ra, dec).""" 

91 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), 

92 lsst.geom.Extent2I(size, size)) 

93 exposure = lsst.afw.image.ExposureF(bbox) 

94 crpix = lsst.geom.Point2D(size / 2.0, size / 2.0) 

95 crval = lsst.geom.SpherePoint(ra, dec, lsst.geom.degrees) 

96 cd_matrix = lsst.afw.geom.makeCdMatrix(scale=scale * lsst.geom.arcseconds) 

97 exposure.setWcs(lsst.afw.geom.makeSkyWcs(crpix, crval, cd_matrix)) 

98 return exposure 

99 

100 

101class TestRegionFromExposure(lsst.utils.tests.TestCase): 

102 def test_center_and_radius(self): 

103 exposure = _make_exposure(ra=150.0, dec=2.5, scale=0.2, size=100) 

104 ra, dec, radius0 = region_from_exposure(exposure, padding=0.0) 

105 self.assertAlmostEqual(ra, 150.0, places=3) 

106 self.assertAlmostEqual(dec, 2.5, places=3) 

107 # ~half-diagonal of a 100px box at 0.2 arcsec/px is ~14 arcsec. 

108 self.assertAlmostEqual(radius0 * 3600.0, 14.0, delta=0.5) 

109 

110 def test_padding_is_arcseconds(self): 

111 exposure = _make_exposure() 

112 _, _, radius0 = region_from_exposure(exposure, padding=0.0) 

113 _, _, radius5 = region_from_exposure(exposure, padding=5.0) 

114 # padding must add exactly 5 arcsec to the radius. 

115 self.assertAlmostEqual((radius5 - radius0) * 3600.0, 5.0, places=6) 

116 

117 def test_no_wcs_raises(self): 

118 bbox = lsst.geom.Box2I(lsst.geom.Point2I(0, 0), 

119 lsst.geom.Extent2I(10, 10)) 

120 exposure = lsst.afw.image.ExposureF(bbox) 

121 with self.assertRaisesRegex(ValueError, "no WCS"): 

122 region_from_exposure(exposure) 

123 

124 def test_negative_padding_raises(self): 

125 with self.assertRaisesRegex(ValueError, "non-negative"): 

126 region_from_exposure(_make_exposure(), padding=-1.0) 

127 

128 

129class TestPpdbTapAuth(lsst.utils.tests.TestCase): 

130 def test_missing_token_raises(self): 

131 with mock.patch.dict(os.environ, {}, clear=True): 

132 with self.assertRaisesRegex(RuntimeError, "No RSP token"): 

133 PpdbTap() 

134 

135 def test_token_sets_bearer_header(self): 

136 with mock.patch("lsst.analysis.ap.ppdb.pyvo.dal.TAPService") as tap: 

137 PpdbTap(token="secret-token") 

138 args, kwargs = tap.call_args 

139 self.assertEqual(args[0], DEFAULT_PPDB_TAP_URL) 

140 session = kwargs["session"] 

141 self.assertEqual(session.headers["Authorization"], 

142 "Bearer secret-token") 

143 

144 def test_env_token_used(self): 

145 with mock.patch.dict(os.environ, {"RSP_TOKEN": "from-env"}, clear=True): 

146 with mock.patch("lsst.analysis.ap.ppdb.pyvo.dal.TAPService") as tap: 

147 PpdbTap() 

148 session = tap.call_args.kwargs["session"] 

149 self.assertEqual(session.headers["Authorization"], "Bearer from-env") 

150 

151 

152class TestLoadObjects(lsst.utils.tests.TestCase): 

153 def test_default_query(self): 

154 fake = _FakeTapService(_objects_table()) 

155 PpdbTap(service=fake).load_objects() 

156 query = fake.queries[-1] 

157 self.assertIn("FROM ppdb.DiaObject", query) 

158 self.assertNotIn("WHERE", query) 

159 self.assertIn("TOP 100000", query) 

160 self.assertIn("ORDER BY diaObjectId", query) 

161 

162 def test_cone_search(self): 

163 fake = _FakeTapService(_objects_table()) 

164 PpdbTap(service=fake).load_objects(ra=150.0, dec=2.5, radius=1.0) 

165 query = fake.queries[-1] 

166 self.assertIn("CONTAINS(POINT('ICRS', ra, dec)", query) 

167 self.assertIn("CIRCLE('ICRS', 150.0, 2.5, 1.0)", query) 

168 

169 def test_partial_cone_raises(self): 

170 fake = _FakeTapService(_objects_table()) 

171 with self.assertRaisesRegex(ValueError, "ra, dec, and radius"): 

172 PpdbTap(service=fake).load_objects(ra=150.0, dec=2.5) 

173 

174 def test_exposure_region(self): 

175 fake = _FakeTapService(_objects_table()) 

176 PpdbTap(service=fake).load_objects(exposure=_make_exposure()) 

177 self.assertIn("CONTAINS(POINT('ICRS', ra, dec)", fake.queries[-1]) 

178 

179 def test_exposure_and_cone_conflict(self): 

180 fake = _FakeTapService(_objects_table()) 

181 with self.assertRaisesRegex(ValueError, "either exposure="): 

182 PpdbTap(service=fake).load_objects(exposure=_make_exposure(), 

183 ra=150.0, dec=2.5, radius=1.0) 

184 

185 def test_no_limit_omits_top(self): 

186 fake = _FakeTapService(_objects_table()) 

187 PpdbTap(service=fake).load_objects(limit=None) 

188 self.assertNotIn("TOP", fake.queries[-1]) 

189 

190 def test_duplicate_current_versions_warns(self): 

191 fake = _FakeTapService(_objects_table((1, 1, 2))) 

192 ppdb = PpdbTap(service=fake) 

193 with self.assertLogs("lsst.analysis.ap.ppdb", level="WARNING") as cm: 

194 ppdb.load_objects() 

195 self.assertTrue(any("duplicate current versions" in m 

196 for m in cm.output)) 

197 

198 def test_truncation_warns(self): 

199 fake = _FakeTapService(_objects_table((1, 2))) 

200 ppdb = PpdbTap(service=fake) 

201 with self.assertLogs("lsst.analysis.ap.ppdb", level="WARNING") as cm: 

202 ppdb.load_objects(limit=2) 

203 self.assertTrue(any("row limit" in m for m in cm.output)) 

204 

205 def test_load_object_missing_raises(self): 

206 fake = _FakeTapService(astropy.table.Table()) 

207 with self.assertRaisesRegex(RuntimeError, "diaObjectId=999 not found"): 

208 PpdbTap(service=fake).load_object(999) 

209 

210 def test_load_object_returns_row(self): 

211 fake = _FakeTapService(_objects_table((7,))) 

212 row = PpdbTap(service=fake).load_object(7) 

213 self.assertEqual(row["diaObjectId"], 7) 

214 self.assertIn("WHERE diaObjectId = 7", fake.queries[-1]) 

215 

216 

217class TestLoadSources(lsst.utils.tests.TestCase): 

218 def test_sources_for_object(self): 

219 fake = _FakeTapService(_sources_table()) 

220 PpdbTap(service=fake).load_sources_for_object(42) 

221 query = fake.queries[-1] 

222 self.assertIn("FROM ppdb.DiaSource", query) 

223 self.assertIn("diaObjectId = 42", query) 

224 self.assertIn("ORDER BY midpointMjdTai", query) 

225 

226 def test_forced_sources_for_object(self): 

227 fake = _FakeTapService(_sources_table(id_column="diaForcedSourceId")) 

228 PpdbTap(service=fake).load_forced_sources_for_object(42) 

229 self.assertIn("FROM ppdb.DiaForcedSource", fake.queries[-1]) 

230 

231 def test_band_filter(self): 

232 fake = _FakeTapService(_sources_table()) 

233 PpdbTap(service=fake).load_sources(diaObjectId=42, bands=["r", "g"]) 

234 query = fake.queries[-1] 

235 self.assertIn("diaObjectId = 42", query) 

236 self.assertIn("band IN ('r', 'g')", query) 

237 

238 def test_invalid_band_raises(self): 

239 fake = _FakeTapService(_sources_table()) 

240 with self.assertRaisesRegex(ValueError, "Unknown band"): 

241 PpdbTap(service=fake).load_sources(diaObjectId=42, bands=["x"]) 

242 

243 def test_time_window(self): 

244 fake = _FakeTapService(_sources_table()) 

245 PpdbTap(service=fake).load_sources(diaObjectId=42, mjd_begin=100.0, 

246 mjd_end=200.0) 

247 query = fake.queries[-1] 

248 self.assertIn("midpointMjdTai >= 100.0", query) 

249 self.assertIn("midpointMjdTai < 200.0", query) 

250 

251 def test_object_id_required(self): 

252 fake = _FakeTapService(_sources_table()) 

253 # omitting it entirely is a TypeError (required keyword) 

254 with self.assertRaises(TypeError): 

255 PpdbTap(service=fake).load_sources() 

256 # passing None explicitly gives a guiding ValueError 

257 with self.assertRaisesRegex(ValueError, "diaObjectId is required"): 

258 PpdbTap(service=fake).load_sources(diaObjectId=None) 

259 

260 def test_load_source_missing_raises(self): 

261 fake = _FakeTapService(astropy.table.Table()) 

262 with self.assertRaisesRegex(RuntimeError, "diaSourceId=5 not found"): 

263 PpdbTap(service=fake).load_source(5) 

264 

265 def test_load_source_returns_row(self): 

266 fake = _FakeTapService(_sources_table(n=1)) 

267 row = PpdbTap(service=fake).load_source(100) 

268 self.assertEqual(row["diaSourceId"], 100) 

269 

270 def test_id_list_is_chunked(self): 

271 fake = _FakeTapService(_sources_table(n=2)) 

272 result = PpdbTap(service=fake).load_sources( 

273 diaObjectId=[1, 2, 3, 4, 5], id_chunk_size=2) 

274 # 5 ids in chunks of 2 -> 3 IN queries, results concatenated. 

275 self.assertEqual(len(fake.queries), 3) 

276 self.assertIn("diaObjectId IN (1, 2)", fake.queries[0]) 

277 self.assertIn("diaObjectId IN (3, 4)", fake.queries[1]) 

278 self.assertIn("diaObjectId IN (5)", fake.queries[2]) 

279 self.assertEqual(len(result), 6) 

280 

281 def test_empty_id_list_returns_empty(self): 

282 fake = _FakeTapService(_sources_table(n=0)) 

283 result = PpdbTap(service=fake).load_sources(diaObjectId=[]) 

284 self.assertEqual(len(result), 0) 

285 self.assertIn("1 = 0", fake.queries[-1]) 

286 

287 

288class TestRegionAndCone(lsst.utils.tests.TestCase): 

289 def test_sources_for_region_two_step(self): 

290 fake = _FakeTapService([_objects_table((10, 20, 30)), 

291 _sources_table(n=2)]) 

292 PpdbTap(service=fake).load_sources_for_region(ra=150.0, dec=2.5, 

293 radius=0.1) 

294 # first: cone on DiaObject (the only table allowing spatial search). 

295 obj_query = fake.queries[0] 

296 self.assertIn("FROM ppdb.DiaObject", obj_query) 

297 self.assertIn("CONTAINS(POINT('ICRS', ra, dec)", obj_query) 

298 # second: sources by id, with no spatial predicate. 

299 src_query = fake.queries[1] 

300 self.assertIn("FROM ppdb.DiaSource", src_query) 

301 self.assertIn("diaObjectId IN (10, 20, 30)", src_query) 

302 self.assertNotIn("CONTAINS", src_query) 

303 

304 def test_forced_sources_for_region_with_exposure(self): 

305 fake = _FakeTapService([ 

306 _objects_table((7,)), 

307 _sources_table(n=1, id_column="diaForcedSourceId"), 

308 ]) 

309 PpdbTap(service=fake).load_forced_sources_for_region( 

310 exposure=_make_exposure()) 

311 self.assertIn("FROM ppdb.DiaObject", fake.queries[0]) 

312 self.assertIn("CONTAINS", fake.queries[0]) 

313 self.assertIn("FROM ppdb.DiaForcedSource", fake.queries[1]) 

314 self.assertIn("diaObjectId IN (7)", fake.queries[1]) 

315 

316 def test_region_with_no_objects(self): 

317 fake = _FakeTapService([astropy.table.Table(), _sources_table(n=0)]) 

318 result = PpdbTap(service=fake).load_sources_for_region( 

319 ra=1.0, dec=1.0, radius=0.01) 

320 self.assertEqual(len(result), 0) 

321 self.assertIn("1 = 0", fake.queries[1]) 

322 

323 def test_sources_by_cone_warns_and_queries_source_table(self): 

324 fake = _FakeTapService(_sources_table(n=2)) 

325 ppdb = PpdbTap(service=fake) 

326 with self.assertLogs("lsst.analysis.ap.ppdb", level="WARNING") as cm: 

327 ppdb.load_sources_by_cone(ra=150.0, dec=2.5, radius=0.05) 

328 self.assertTrue(any("prototype-only" in m for m in cm.output)) 

329 query = fake.queries[-1] 

330 self.assertIn("FROM ppdb.DiaSource", query) 

331 self.assertIn("CONTAINS(POINT('ICRS', ra, dec)", query) 

332 

333 def test_forced_sources_by_cone(self): 

334 fake = _FakeTapService( 

335 _sources_table(n=1, id_column="diaForcedSourceId")) 

336 ppdb = PpdbTap(service=fake) 

337 with self.assertLogs("lsst.analysis.ap.ppdb", level="WARNING"): 

338 ppdb.load_forced_sources_by_cone(exposure=_make_exposure()) 

339 self.assertIn("FROM ppdb.DiaForcedSource", fake.queries[-1]) 

340 self.assertIn("CONTAINS", fake.queries[-1]) 

341 

342 def test_by_cone_requires_a_cone(self): 

343 fake = _FakeTapService(_sources_table()) 

344 with self.assertRaisesRegex(ValueError, "cone search requires"): 

345 PpdbTap(service=fake).load_sources_by_cone() 

346 

347 def test_sources_by_time_window_warns_and_queries_source_table(self): 

348 fake = _FakeTapService(_sources_table(n=2)) 

349 ppdb = PpdbTap(service=fake) 

350 with self.assertLogs("lsst.analysis.ap.ppdb", level="WARNING") as cm: 

351 ppdb.load_sources_by_time_window(mjd_begin=60000.0, mjd_end=60001.0) 

352 self.assertTrue(any("prototype-only" in m for m in cm.output)) 

353 query = fake.queries[-1] 

354 self.assertIn("FROM ppdb.DiaSource", query) 

355 self.assertIn("midpointMjdTai >= 60000.0", query) 

356 self.assertIn("midpointMjdTai < 60001.0", query) 

357 self.assertNotIn("diaObjectId", query) 

358 self.assertNotIn("CONTAINS", query) 

359 

360 def test_forced_sources_by_time_window_open_ended(self): 

361 fake = _FakeTapService( 

362 _sources_table(n=1, id_column="diaForcedSourceId")) 

363 ppdb = PpdbTap(service=fake) 

364 with self.assertLogs("lsst.analysis.ap.ppdb", level="WARNING"): 

365 ppdb.load_forced_sources_by_time_window(mjd_begin=60000.0) 

366 query = fake.queries[-1] 

367 self.assertIn("FROM ppdb.DiaForcedSource", query) 

368 self.assertIn("midpointMjdTai >= 60000.0", query) 

369 

370 def test_by_time_window_requires_a_bound(self): 

371 fake = _FakeTapService(_sources_table()) 

372 with self.assertRaisesRegex(ValueError, "time-window search requires"): 

373 PpdbTap(service=fake).load_sources_by_time_window() 

374 

375 

376class TestColumnOrdering(lsst.utils.tests.TestCase): 

377 def test_sources_columns_reordered_to_sdm(self): 

378 # alphabetical input columns -> SDM-schema order out. 

379 scrambled = astropy.table.Table({ 

380 "band": ["r"], "dec": [1.0], "diaObjectId": [42], 

381 "diaSourceId": [100], "midpointMjdTai": [60000.0], "ra": [150.0]}) 

382 fake = _FakeTapService(scrambled) 

383 result = PpdbTap(service=fake).load_sources(diaObjectId=42) 

384 self.assertEqual(result.colnames, 

385 ["diaSourceId", "diaObjectId", "midpointMjdTai", 

386 "ra", "dec", "band"]) 

387 

388 def test_unknown_columns_raise(self): 

389 # a column not in the SDM schema signals schema drift -> error. 

390 tab = astropy.table.Table({ 

391 "ra": [150.0], "diaSourceId": [100], "myExtra": [1]}) 

392 fake = _FakeTapService(tab) 

393 with self.assertRaisesRegex(RuntimeError, "not in the SDM schema"): 

394 PpdbTap(service=fake).load_sources(diaObjectId=42) 

395 

396 def test_explicit_columns_order_preserved(self): 

397 # an explicit columns list is respected, not reordered to SDM order. 

398 tab = astropy.table.Table({"dec": [1.0], "ra": [150.0]}) 

399 fake = _FakeTapService(tab) 

400 result = PpdbTap(service=fake).load_sources(diaObjectId=42, 

401 columns=["dec", "ra"]) 

402 self.assertEqual(result.colnames, ["dec", "ra"]) 

403 

404 def test_objects_columns_reordered_to_sdm(self): 

405 scrambled = astropy.table.Table({ 

406 "ra": [150.0], "diaObjectId": [1], "dec": [2.0], 

407 "validityStartMjdTai": [60000.0]}) 

408 fake = _FakeTapService(scrambled) 

409 result = PpdbTap(service=fake).load_objects() 

410 self.assertEqual( 

411 result.colnames, 

412 ["diaObjectId", "validityStartMjdTai", "ra", "dec"]) 

413 

414 def test_load_object_row_in_sdm_order(self): 

415 scrambled = astropy.table.Table({ 

416 "ra": [150.0], "diaObjectId": [7], "dec": [2.0]}) 

417 fake = _FakeTapService(scrambled) 

418 row = PpdbTap(service=fake).load_object(7) 

419 self.assertEqual(row.colnames, ["diaObjectId", "ra", "dec"]) 

420 

421 def test_id_list_path_reordered_to_sdm(self): 

422 # the chunked many-ids path reorders per page before stacking. 

423 scrambled = astropy.table.Table({ 

424 "band": ["r"], "diaObjectId": [42], "diaSourceId": [100], 

425 "midpointMjdTai": [60000.0]}) 

426 fake = _FakeTapService(scrambled) 

427 result = PpdbTap(service=fake).load_sources(diaObjectId=[1, 2], 

428 id_chunk_size=1) 

429 self.assertEqual( 

430 result.colnames, 

431 ["diaSourceId", "diaObjectId", "midpointMjdTai", "band"]) 

432 

433 

434class TestLightCurve(lsst.utils.tests.TestCase): 

435 def test_assembles_object_and_series(self): 

436 tables = [ 

437 _objects_table((42,)), 

438 _sources_table(n=2), 

439 _sources_table(n=3, id_column="diaForcedSourceId"), 

440 ] 

441 fake = _FakeTapService(tables) 

442 light_curve = PpdbTap(service=fake).load_light_curve(42) 

443 self.assertIsInstance(light_curve, DiaObjectLightCurve) 

444 self.assertEqual(light_curve.diaObjectId, 42) 

445 self.assertEqual(light_curve.n_sources, 2) 

446 self.assertEqual(light_curve.n_forced_sources, 3) 

447 # one query each for object, sources, forced sources. 

448 self.assertEqual(len(fake.queries), 3) 

449 self.assertIn("FROM ppdb.DiaObject", fake.queries[0]) 

450 

451 

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

453 pass 

454 

455 

456def setup_module(module): 

457 lsst.utils.tests.init() 

458 

459 

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

461 lsst.utils.tests.init() 

462 unittest.main()