Coverage for tests/test_ppdb.py: 99%

306 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-22 02:18 -0700

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.assertIn("validityEndMjdTai IS NULL", query) 

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

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

161 

162 def test_latest_false_drops_validity_filter(self): 

163 fake = _FakeTapService(_objects_table()) 

164 PpdbTap(service=fake).load_objects(latest=False) 

165 self.assertNotIn("validityEndMjdTai", fake.queries[-1]) 

166 

167 def test_cone_search(self): 

168 fake = _FakeTapService(_objects_table()) 

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

170 query = fake.queries[-1] 

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

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

173 

174 def test_partial_cone_raises(self): 

175 fake = _FakeTapService(_objects_table()) 

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

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

178 

179 def test_exposure_region(self): 

180 fake = _FakeTapService(_objects_table()) 

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

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

183 

184 def test_exposure_and_cone_conflict(self): 

185 fake = _FakeTapService(_objects_table()) 

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

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

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

189 

190 def test_no_limit_omits_top(self): 

191 fake = _FakeTapService(_objects_table()) 

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

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

194 

195 def test_duplicate_current_versions_warns(self): 

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

197 ppdb = PpdbTap(service=fake) 

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

199 ppdb.load_objects() 

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

201 for m in cm.output)) 

202 

203 def test_truncation_warns(self): 

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

205 ppdb = PpdbTap(service=fake) 

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

207 ppdb.load_objects(limit=2) 

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

209 

210 def test_load_object_missing_raises(self): 

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

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

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

214 

215 def test_load_object_returns_row(self): 

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

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

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

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

220 self.assertIn("validityEndMjdTai IS NULL", fake.queries[-1]) 

221 

222 

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

224 def test_sources_for_object(self): 

225 fake = _FakeTapService(_sources_table()) 

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

227 query = fake.queries[-1] 

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

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

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

231 

232 def test_forced_sources_for_object(self): 

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

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

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

236 

237 def test_band_filter(self): 

238 fake = _FakeTapService(_sources_table()) 

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

240 query = fake.queries[-1] 

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

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

243 

244 def test_invalid_band_raises(self): 

245 fake = _FakeTapService(_sources_table()) 

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

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

248 

249 def test_time_window(self): 

250 fake = _FakeTapService(_sources_table()) 

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

252 mjd_end=200.0) 

253 query = fake.queries[-1] 

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

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

256 

257 def test_object_id_required(self): 

258 fake = _FakeTapService(_sources_table()) 

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

260 with self.assertRaises(TypeError): 

261 PpdbTap(service=fake).load_sources() 

262 # passing None explicitly gives a guiding ValueError 

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

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

265 

266 def test_load_source_missing_raises(self): 

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

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

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

270 

271 def test_load_source_returns_row(self): 

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

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

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

275 

276 def test_id_list_is_chunked(self): 

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

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

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

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

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

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

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

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

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

286 

287 def test_empty_id_list_returns_empty(self): 

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

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

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

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

292 

293 

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

295 def test_sources_for_region_two_step(self): 

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

297 _sources_table(n=2)]) 

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

299 radius=0.1) 

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

301 obj_query = fake.queries[0] 

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

303 self.assertIn("validityEndMjdTai IS NULL", obj_query) 

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

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

306 src_query = fake.queries[1] 

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

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

309 self.assertNotIn("CONTAINS", src_query) 

310 

311 def test_forced_sources_for_region_with_exposure(self): 

312 fake = _FakeTapService([ 

313 _objects_table((7,)), 

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

315 ]) 

316 PpdbTap(service=fake).load_forced_sources_for_region( 

317 exposure=_make_exposure()) 

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

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

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

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

322 

323 def test_region_with_no_objects(self): 

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

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

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

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

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

329 

330 def test_sources_by_cone_warns_and_queries_source_table(self): 

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

332 ppdb = PpdbTap(service=fake) 

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

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

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

336 query = fake.queries[-1] 

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

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

339 

340 def test_forced_sources_by_cone(self): 

341 fake = _FakeTapService( 

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

343 ppdb = PpdbTap(service=fake) 

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

345 ppdb.load_forced_sources_by_cone(exposure=_make_exposure()) 

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

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

348 

349 def test_by_cone_requires_a_cone(self): 

350 fake = _FakeTapService(_sources_table()) 

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

352 PpdbTap(service=fake).load_sources_by_cone() 

353 

354 def test_sources_by_time_window_warns_and_queries_source_table(self): 

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

356 ppdb = PpdbTap(service=fake) 

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

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

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

360 query = fake.queries[-1] 

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

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

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

364 self.assertNotIn("diaObjectId", query) 

365 self.assertNotIn("CONTAINS", query) 

366 

367 def test_forced_sources_by_time_window_open_ended(self): 

368 fake = _FakeTapService( 

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

370 ppdb = PpdbTap(service=fake) 

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

372 ppdb.load_forced_sources_by_time_window(mjd_begin=60000.0) 

373 query = fake.queries[-1] 

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

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

376 

377 def test_by_time_window_requires_a_bound(self): 

378 fake = _FakeTapService(_sources_table()) 

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

380 PpdbTap(service=fake).load_sources_by_time_window() 

381 

382 

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

384 def test_sources_columns_reordered_to_sdm(self): 

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

386 scrambled = astropy.table.Table({ 

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

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

389 fake = _FakeTapService(scrambled) 

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

391 self.assertEqual(result.colnames, 

392 ["diaSourceId", "diaObjectId", "midpointMjdTai", 

393 "ra", "dec", "band"]) 

394 

395 def test_unknown_columns_raise(self): 

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

397 tab = astropy.table.Table({ 

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

399 fake = _FakeTapService(tab) 

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

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

402 

403 def test_explicit_columns_order_preserved(self): 

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

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

406 fake = _FakeTapService(tab) 

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

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

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

410 

411 def test_objects_columns_reordered_to_sdm(self): 

412 scrambled = astropy.table.Table({ 

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

414 "validityEndMjdTai": [60000.0]}) 

415 fake = _FakeTapService(scrambled) 

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

417 self.assertEqual( 

418 result.colnames, 

419 ["diaObjectId", "validityEndMjdTai", "ra", "dec"]) 

420 

421 def test_load_object_row_in_sdm_order(self): 

422 scrambled = astropy.table.Table({ 

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

424 fake = _FakeTapService(scrambled) 

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

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

427 

428 def test_id_list_path_reordered_to_sdm(self): 

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

430 scrambled = astropy.table.Table({ 

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

432 "midpointMjdTai": [60000.0]}) 

433 fake = _FakeTapService(scrambled) 

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

435 id_chunk_size=1) 

436 self.assertEqual( 

437 result.colnames, 

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

439 

440 

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

442 def test_assembles_object_and_series(self): 

443 tables = [ 

444 _objects_table((42,)), 

445 _sources_table(n=2), 

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

447 ] 

448 fake = _FakeTapService(tables) 

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

450 self.assertIsInstance(light_curve, DiaObjectLightCurve) 

451 self.assertEqual(light_curve.diaObjectId, 42) 

452 self.assertEqual(light_curve.n_sources, 2) 

453 self.assertEqual(light_curve.n_forced_sources, 3) 

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

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

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

457 

458 

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

460 pass 

461 

462 

463def setup_module(module): 

464 lsst.utils.tests.init() 

465 

466 

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

468 lsst.utils.tests.init() 

469 unittest.main()