Coverage for python / lsst / dax / apdb / tests / _apdb.py: 15%
444 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-09 01:21 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-09 01:21 -0800
1# This file is part of dax_apdb.
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/>.
22from __future__ import annotations
24__all__ = ["ApdbSchemaUpdateTest", "ApdbTest", "update_schema_yaml"]
26import contextlib
27import logging.config
28import os
29import tempfile
30from abc import ABC, abstractmethod
31from collections.abc import Iterator
32from tempfile import TemporaryDirectory
33from typing import TYPE_CHECKING, Any
35import astropy.time
36import felis.datamodel
37import pandas
38import yaml
40from lsst.sphgeom import Angle, Circle, LonLat, Region, UnitVector3d
42from .. import (
43 Apdb,
44 ApdbConfig,
45 ApdbReassignDiaSourceRecord,
46 ApdbReplica,
47 ApdbTableData,
48 ApdbTables,
49 ApdbUpdateRecord,
50 ApdbWithdrawDiaSourceRecord,
51 IncompatibleVersionError,
52 ReplicaChunk,
53 VersionTuple,
54)
55from .data_factory import (
56 makeForcedSourceCatalog,
57 makeObjectCatalog,
58 makeSourceCatalog,
59 makeTimestampNow,
60)
61from .utils import TestCaseMixin
63if TYPE_CHECKING:
64 from ..pixelization import Pixelization
67# Optionally configure logging from a config file.
68if log_config := os.environ.get("DAX_APDB_TEST_LOG_CONFIG"): 68 ↛ 69line 68 didn't jump to line 69 because the condition on line 68 was never true
69 logging.config.fileConfig(log_config)
72def _make_region(xyz: tuple[float, float, float] = (1.0, 1.0, -1.0)) -> Region:
73 """Make a region to use in tests"""
74 pointing_v = UnitVector3d(*xyz)
75 fov = 0.0013 # radians
76 region = Circle(pointing_v, Angle(fov / 2))
77 return region
80@contextlib.contextmanager
81def update_schema_yaml(
82 schema_file: str,
83 drop_metadata: bool = False,
84 version: str | None = None,
85) -> Iterator[str]:
86 """Update schema definition and return name of the new schema file.
88 Parameters
89 ----------
90 schema_file : `str`
91 Path for the existing YAML file with APDB schema.
92 drop_metadata : `bool`
93 If `True` then remove metadata table from the list of tables.
94 version : `str` or `None`
95 If non-empty string then set schema version to this string, if empty
96 string then remove schema version from config, if `None` - don't change
97 the version in config.
99 Yields
100 ------
101 Path for the updated configuration file.
102 """
103 with open(schema_file) as yaml_stream:
104 schemas_list = list(yaml.load_all(yaml_stream, Loader=yaml.SafeLoader))
105 # Edit YAML contents.
106 for schema in schemas_list:
107 # Optionally drop metadata table.
108 if drop_metadata:
109 schema["tables"] = [table for table in schema["tables"] if table["name"] != "metadata"]
110 if version is not None:
111 if version == "":
112 del schema["version"]
113 else:
114 schema["version"] = version
116 with TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
117 output_path = os.path.join(tmpdir, "schema.yaml")
118 with open(output_path, "w") as yaml_stream:
119 yaml.dump_all(schemas_list, stream=yaml_stream)
120 yield output_path
123class ApdbTest(TestCaseMixin, ABC):
124 """Base class for Apdb tests that can be specialized for concrete
125 implementation.
127 This can only be used as a mixin class for a unittest.TestCase and it
128 calls various assert methods.
129 """
131 visit_time = astropy.time.Time("2021-01-01T00:00:00", format="isot", scale="tai")
133 fsrc_requires_id_list = False
134 """Should be set to True if getDiaForcedSources requires object IDs"""
136 enable_replica: bool = False
137 """Set to true when support for replication is configured"""
139 use_mjd: bool = True
140 """If True then timestamp columns are MJD TAI."""
142 extra_chunk_columns = 1
143 """Number of additional columns in chunk tables."""
145 meta_row_count = 3
146 """Initial row count in metadata table."""
148 # number of columns as defined in tests/config/schema.yaml
149 table_column_count = {
150 ApdbTables.DiaObject: 7,
151 ApdbTables.DiaObjectLast: 5,
152 ApdbTables.DiaSource: 12,
153 ApdbTables.DiaForcedSource: 8,
154 ApdbTables.SSObject: 3,
155 }
157 @abstractmethod
158 def make_instance(self, **kwargs: Any) -> ApdbConfig:
159 """Make database instance and return configuration for it."""
160 raise NotImplementedError()
162 @abstractmethod
163 def getDiaObjects_table(self) -> ApdbTables:
164 """Return type of table returned from getDiaObjects method."""
165 raise NotImplementedError()
167 @abstractmethod
168 def pixelization(self, config: ApdbConfig) -> Pixelization:
169 """Return pixelization used by implementation."""
170 raise NotImplementedError()
172 def assert_catalog(self, catalog: Any, rows: int, table: ApdbTables) -> None:
173 """Validate catalog type and size
175 Parameters
176 ----------
177 catalog : `object`
178 Expected type of this is ``pandas.DataFrame``.
179 rows : `int`
180 Expected number of rows in a catalog.
181 table : `ApdbTables`
182 APDB table type.
183 """
184 self.assertIsInstance(catalog, pandas.DataFrame)
185 self.assertEqual(catalog.shape[0], rows)
186 self.assertEqual(catalog.shape[1], self.table_column_count[table])
188 def assert_table_data(self, catalog: Any, rows: int, table: ApdbTables) -> None:
189 """Validate catalog type and size
191 Parameters
192 ----------
193 catalog : `object`
194 Expected type of this is `ApdbTableData`.
195 rows : `int`
196 Expected number of rows in a catalog.
197 table : `ApdbTables`
198 APDB table type.
199 extra_columns : `int`
200 Count of additional columns expected in ``catalog``.
201 """
202 self.assertIsInstance(catalog, ApdbTableData)
203 n_rows = sum(1 for row in catalog.rows())
204 self.assertEqual(n_rows, rows)
205 # One extra column for replica chunk id
206 self.assertEqual(
207 len(catalog.column_names()), self.table_column_count[table] + self.extra_chunk_columns
208 )
210 def assert_column_types(self, catalog: Any, types: dict[str, felis.datamodel.DataType]) -> None:
211 column_defs = dict(catalog.column_defs())
212 for column, datatype in types.items():
213 self.assertEqual(column_defs[column], datatype)
215 def make_region(self, xyz: tuple[float, float, float] = (1.0, 1.0, -1.0)) -> Region:
216 """Make a region to use in tests"""
217 return _make_region(xyz)
219 def test_makeSchema(self) -> None:
220 """Test for making APDB schema."""
221 config = self.make_instance()
222 apdb = Apdb.from_config(config)
224 self.assertIsNotNone(apdb.tableDef(ApdbTables.DiaObject))
225 self.assertIsNotNone(apdb.tableDef(ApdbTables.DiaObjectLast))
226 self.assertIsNotNone(apdb.tableDef(ApdbTables.DiaSource))
227 self.assertIsNotNone(apdb.tableDef(ApdbTables.DiaForcedSource))
228 self.assertIsNotNone(apdb.tableDef(ApdbTables.metadata))
229 self.assertIsNotNone(apdb.tableDef(ApdbTables.SSObject))
230 self.assertIsNotNone(apdb.tableDef(ApdbTables.SSSource))
231 self.assertIsNotNone(apdb.tableDef(ApdbTables.DiaObject_To_Object_Match))
233 # Test from_uri factory method with the same config.
234 with tempfile.NamedTemporaryFile() as tmpfile:
235 config.save(tmpfile.name)
236 apdb = Apdb.from_uri(tmpfile.name)
238 self.assertIsNotNone(apdb.tableDef(ApdbTables.DiaObject))
239 self.assertIsNotNone(apdb.tableDef(ApdbTables.DiaObjectLast))
240 self.assertIsNotNone(apdb.tableDef(ApdbTables.DiaSource))
241 self.assertIsNotNone(apdb.tableDef(ApdbTables.DiaForcedSource))
242 self.assertIsNotNone(apdb.tableDef(ApdbTables.metadata))
243 self.assertIsNotNone(apdb.tableDef(ApdbTables.SSObject))
244 self.assertIsNotNone(apdb.tableDef(ApdbTables.SSSource))
245 self.assertIsNotNone(apdb.tableDef(ApdbTables.DiaObject_To_Object_Match))
247 def test_empty_gets(self) -> None:
248 """Test for getting data from empty database.
250 All get() methods should return empty results, only useful for
251 checking that code is not broken.
252 """
253 # use non-zero months for Forced/Source fetching
254 config = self.make_instance()
255 apdb = Apdb.from_config(config)
257 region = self.make_region()
258 visit_time = self.visit_time
260 res: pandas.DataFrame | None
262 # get objects by region
263 res = apdb.getDiaObjects(region)
264 self.assert_catalog(res, 0, self.getDiaObjects_table())
266 # get sources by region
267 res = apdb.getDiaSources(region, None, visit_time)
268 self.assert_catalog(res, 0, ApdbTables.DiaSource)
270 res = apdb.getDiaSources(region, [], visit_time)
271 self.assert_catalog(res, 0, ApdbTables.DiaSource)
273 # get sources by object ID, non-empty object list
274 res = apdb.getDiaSources(region, [1, 2, 3], visit_time)
275 self.assert_catalog(res, 0, ApdbTables.DiaSource)
277 # get forced sources by object ID, empty object list
278 res = apdb.getDiaForcedSources(region, [], visit_time)
279 self.assert_catalog(res, 0, ApdbTables.DiaForcedSource)
281 # get sources by object ID, non-empty object list
282 res = apdb.getDiaForcedSources(region, [1, 2, 3], visit_time)
283 self.assert_catalog(res, 0, ApdbTables.DiaForcedSource)
285 # data_factory's ccdVisitId generation corresponds to (1, 1)
286 res = apdb.containsVisitDetector(visit=1, detector=1, region=region, visit_time=visit_time)
287 self.assertFalse(res)
289 # get sources by region
290 if self.fsrc_requires_id_list:
291 with self.assertRaises(NotImplementedError):
292 apdb.getDiaForcedSources(region, None, visit_time)
293 else:
294 res = apdb.getDiaForcedSources(region, None, visit_time)
295 self.assert_catalog(res, 0, ApdbTables.DiaForcedSource)
297 def test_empty_gets_0months(self) -> None:
298 """Test for getting data from empty database.
300 All get() methods should return empty DataFrame or None.
301 """
302 # set read_sources_months to 0 so that Forced/Sources are None
303 config = self.make_instance(read_sources_months=0, read_forced_sources_months=0)
304 apdb = Apdb.from_config(config)
306 region = self.make_region()
307 visit_time = self.visit_time
309 res: pandas.DataFrame | None
311 # get objects by region
312 res = apdb.getDiaObjects(region)
313 self.assert_catalog(res, 0, self.getDiaObjects_table())
315 # get sources by region
316 res = apdb.getDiaSources(region, None, visit_time)
317 self.assertIs(res, None)
319 # get sources by object ID, empty object list
320 res = apdb.getDiaSources(region, [], visit_time)
321 self.assertIs(res, None)
323 # get forced sources by object ID, empty object list
324 res = apdb.getDiaForcedSources(region, [], visit_time)
325 self.assertIs(res, None)
327 # Database is empty, no images exist.
328 res = apdb.containsVisitDetector(visit=1, detector=1, region=region, visit_time=visit_time)
329 self.assertFalse(res)
331 def test_storeObjects(self) -> None:
332 """Store and retrieve DiaObjects."""
333 # don't care about sources.
334 config = self.make_instance()
335 apdb = Apdb.from_config(config)
337 region = self.make_region()
338 visit_time = self.visit_time
340 # make catalog with Objects
341 catalog = makeObjectCatalog(region, 100, visit_time)
343 # store catalog
344 apdb.store(visit_time, catalog)
346 # read it back and check sizes
347 res = apdb.getDiaObjects(region)
348 self.assert_catalog(res, len(catalog), self.getDiaObjects_table())
350 # TODO: test apdb.contains with generic implementation from DM-41671
352 def test_storeObjects_empty(self) -> None:
353 """Test calling storeObject when there are no objects: see DM-43270."""
354 config = self.make_instance()
355 apdb = Apdb.from_config(config)
356 region = self.make_region()
357 visit_time = self.visit_time
358 # make catalog with no Objects
359 catalog = makeObjectCatalog(region, 0, visit_time)
361 with self.assertLogs("lsst.dax.apdb", level="DEBUG") as cm:
362 apdb.store(visit_time, catalog)
363 self.assertIn("No objects", "\n".join(cm.output))
365 def test_storeMovingObject(self) -> None:
366 """Store and retrieve DiaObject which changes its position."""
367 # don't care about sources.
368 config = self.make_instance()
369 apdb = Apdb.from_config(config)
370 pixelization = self.pixelization(config)
372 lon_deg, lat_deg = 0.0, 0.0
373 lonlat1 = LonLat.fromDegrees(lon_deg - 1.0, lat_deg)
374 lonlat2 = LonLat.fromDegrees(lon_deg + 1.0, lat_deg)
375 uv1 = UnitVector3d(lonlat1)
376 uv2 = UnitVector3d(lonlat2)
378 # Check that they fall into different pixels.
379 self.assertNotEqual(pixelization.pixel(uv1), pixelization.pixel(uv2))
381 # Store one object at two different positions.
382 visit_time1 = self.visit_time
383 catalog1 = makeObjectCatalog(lonlat1, 1, visit_time1)
384 apdb.store(visit_time1, catalog1)
386 visit_time2 = visit_time1 + astropy.time.TimeDelta(120.0, format="sec")
387 catalog1 = makeObjectCatalog(lonlat2, 1, visit_time2)
388 apdb.store(visit_time2, catalog1)
390 # Make region covering both points.
391 region = Circle(UnitVector3d(LonLat.fromDegrees(lon_deg, lat_deg)), Angle.fromDegrees(1.1))
392 self.assertTrue(region.contains(uv1))
393 self.assertTrue(region.contains(uv2))
395 # Read it back, must return the latest one.
396 res = apdb.getDiaObjects(region)
397 self.assert_catalog(res, 1, self.getDiaObjects_table())
399 def test_storeSources(self) -> None:
400 """Store and retrieve DiaSources."""
401 config = self.make_instance()
402 apdb = Apdb.from_config(config)
404 region = self.make_region()
405 visit_time = self.visit_time
407 # have to store Objects first
408 objects = makeObjectCatalog(region, 100, visit_time)
409 oids = list(objects["diaObjectId"])
410 sources = makeSourceCatalog(objects, visit_time, use_mjd=self.use_mjd)
412 # save the objects and sources
413 apdb.store(visit_time, objects, sources)
415 # read it back, no ID filtering
416 res = apdb.getDiaSources(region, None, visit_time)
417 self.assert_catalog(res, len(sources), ApdbTables.DiaSource)
419 # read it back and filter by ID
420 res = apdb.getDiaSources(region, oids, visit_time)
421 self.assert_catalog(res, len(sources), ApdbTables.DiaSource)
423 # read it back to get schema
424 res = apdb.getDiaSources(region, [], visit_time)
425 self.assert_catalog(res, 0, ApdbTables.DiaSource)
427 # test if a visit is present
428 # data_factory's ccdVisitId generation corresponds to (1, 1)
429 res = apdb.containsVisitDetector(visit=1, detector=1, region=region, visit_time=visit_time)
430 self.assertTrue(res)
431 # non-existent image
432 res = apdb.containsVisitDetector(visit=2, detector=42, region=region, visit_time=visit_time)
433 self.assertFalse(res)
435 def test_storeForcedSources(self) -> None:
436 """Store and retrieve DiaForcedSources."""
437 config = self.make_instance()
438 apdb = Apdb.from_config(config)
440 region = self.make_region()
441 visit_time = self.visit_time
443 # have to store Objects first
444 objects = makeObjectCatalog(region, 100, visit_time)
445 oids = list(objects["diaObjectId"])
446 catalog = makeForcedSourceCatalog(objects, visit_time, use_mjd=self.use_mjd)
448 apdb.store(visit_time, objects, forced_sources=catalog)
450 # read it back and check sizes
451 res = apdb.getDiaForcedSources(region, oids, visit_time)
452 self.assert_catalog(res, len(catalog), ApdbTables.DiaForcedSource)
454 # read it back to get schema
455 res = apdb.getDiaForcedSources(region, [], visit_time)
456 self.assert_catalog(res, 0, ApdbTables.DiaForcedSource)
458 # data_factory's ccdVisitId generation corresponds to (1, 1)
459 res = apdb.containsVisitDetector(visit=1, detector=1, region=region, visit_time=visit_time)
460 self.assertTrue(res)
461 # non-existent image
462 res = apdb.containsVisitDetector(visit=2, detector=42, region=region, visit_time=visit_time)
463 self.assertFalse(res)
465 def test_timestamps(self) -> None:
466 """Check that timestamp return type is as expected."""
467 config = self.make_instance()
468 apdb = Apdb.from_config(config)
470 region = self.make_region()
471 visit_time = self.visit_time
473 # Cassandra has a millisecond precision, so subtract 1ms to allow for
474 # truncated returned values.
475 time_before = makeTimestampNow(self.use_mjd, -1)
476 objects = makeObjectCatalog(region, 100, visit_time)
477 oids = list(objects["diaObjectId"])
478 catalog = makeForcedSourceCatalog(objects, visit_time, use_mjd=self.use_mjd)
479 time_after = makeTimestampNow(self.use_mjd)
481 apdb.store(visit_time, objects, forced_sources=catalog)
483 # read it back and check sizes
484 res = apdb.getDiaForcedSources(region, oids, visit_time)
485 assert res is not None
486 self.assert_catalog(res, len(catalog), ApdbTables.DiaForcedSource)
488 time_processed_column = "timeProcessedMjdTai" if self.use_mjd else "time_processed"
489 self.assertIn(time_processed_column, res.dtypes)
490 dtype = res.dtypes[time_processed_column]
491 timestamp_type_name = "float64" if self.use_mjd else "datetime64[ns]"
492 self.assertEqual(dtype.name, timestamp_type_name)
493 # Verify that returned time is sensible.
494 self.assertTrue(all(time_before <= dt <= time_after for dt in res[time_processed_column]))
496 def test_getChunks(self) -> None:
497 """Store and retrieve replica chunks."""
498 # don't care about sources.
499 config = self.make_instance()
500 apdb = Apdb.from_config(config)
501 apdb_replica = ApdbReplica.from_config(config)
502 visit_time = self.visit_time
504 region1 = self.make_region((1.0, 1.0, -1.0))
505 region2 = self.make_region((-1.0, -1.0, -1.0))
506 nobj = 100
507 objects1 = makeObjectCatalog(region1, nobj, visit_time)
508 objects2 = makeObjectCatalog(region2, nobj, visit_time, start_id=nobj * 2)
510 # With the default 10 minutes replica chunk window we should have 4
511 # records.
512 visits = [
513 (astropy.time.Time("2021-01-01T00:01:00", format="isot", scale="tai"), objects1),
514 (astropy.time.Time("2021-01-01T00:02:00", format="isot", scale="tai"), objects2),
515 (astropy.time.Time("2021-01-01T00:11:00", format="isot", scale="tai"), objects1),
516 (astropy.time.Time("2021-01-01T00:12:00", format="isot", scale="tai"), objects2),
517 (astropy.time.Time("2021-01-01T00:45:00", format="isot", scale="tai"), objects1),
518 (astropy.time.Time("2021-01-01T00:46:00", format="isot", scale="tai"), objects2),
519 (astropy.time.Time("2021-03-01T00:01:00", format="isot", scale="tai"), objects1),
520 (astropy.time.Time("2021-03-01T00:02:00", format="isot", scale="tai"), objects2),
521 ]
523 start_id = 0
524 for visit_time, objects in visits:
525 sources = makeSourceCatalog(objects, visit_time, start_id=start_id, use_mjd=self.use_mjd)
526 fsources = makeForcedSourceCatalog(objects, visit_time, visit=start_id, use_mjd=self.use_mjd)
527 apdb.store(visit_time, objects, sources, fsources)
528 start_id += nobj
530 replica_chunks = apdb_replica.getReplicaChunks()
531 if not self.enable_replica:
532 self.assertIsNone(replica_chunks)
534 with self.assertRaisesRegex(ValueError, "APDB is not configured for replication"):
535 apdb_replica.getTableDataChunks(ApdbTables.DiaObject, [])
537 else:
538 assert replica_chunks is not None
539 self.assertEqual(len(replica_chunks), 4)
541 with self.assertRaisesRegex(ValueError, "does not support replica chunks"):
542 apdb_replica.getTableDataChunks(ApdbTables.SSObject, [])
544 def _check_chunks(replica_chunks: list[ReplicaChunk], n_records: int | None = None) -> None:
545 if n_records is None:
546 n_records = len(replica_chunks) * nobj
547 res = apdb_replica.getTableDataChunks(
548 ApdbTables.DiaObject, (chunk.id for chunk in replica_chunks)
549 )
550 self.assert_table_data(res, n_records, ApdbTables.DiaObject)
551 validityStartColumn = "validityStartMjdTai" if self.use_mjd else "validityStart"
552 validityStartType = (
553 felis.datamodel.DataType.double if self.use_mjd else felis.datamodel.DataType.timestamp
554 )
555 self.assert_column_types(
556 res,
557 {
558 "apdb_replica_chunk": felis.datamodel.DataType.long,
559 "diaObjectId": felis.datamodel.DataType.long,
560 validityStartColumn: validityStartType,
561 "ra": felis.datamodel.DataType.double,
562 "dec": felis.datamodel.DataType.double,
563 "parallax": felis.datamodel.DataType.float,
564 "nDiaSources": felis.datamodel.DataType.int,
565 },
566 )
568 res = apdb_replica.getTableDataChunks(
569 ApdbTables.DiaSource, (chunk.id for chunk in replica_chunks)
570 )
571 self.assert_table_data(res, n_records, ApdbTables.DiaSource)
572 self.assert_column_types(
573 res,
574 {
575 "apdb_replica_chunk": felis.datamodel.DataType.long,
576 "diaSourceId": felis.datamodel.DataType.long,
577 "visit": felis.datamodel.DataType.long,
578 "detector": felis.datamodel.DataType.short,
579 },
580 )
582 res = apdb_replica.getTableDataChunks(
583 ApdbTables.DiaForcedSource, (chunk.id for chunk in replica_chunks)
584 )
585 self.assert_table_data(res, n_records, ApdbTables.DiaForcedSource)
586 self.assert_column_types(
587 res,
588 {
589 "apdb_replica_chunk": felis.datamodel.DataType.long,
590 "diaObjectId": felis.datamodel.DataType.long,
591 "visit": felis.datamodel.DataType.long,
592 "detector": felis.datamodel.DataType.short,
593 },
594 )
596 # read it back and check sizes
597 _check_chunks(replica_chunks, 800)
598 _check_chunks(replica_chunks[1:], 600)
599 _check_chunks(replica_chunks[1:-1], 400)
600 _check_chunks(replica_chunks[2:3], 200)
601 _check_chunks([])
603 # try to remove some of those
604 deleted_chunks = replica_chunks[:1]
605 apdb_replica.deleteReplicaChunks(chunk.id for chunk in deleted_chunks)
607 # All queries on deleted ids should return empty set.
608 _check_chunks(deleted_chunks, 0)
610 replica_chunks = apdb_replica.getReplicaChunks()
611 assert replica_chunks is not None
612 self.assertEqual(len(replica_chunks), 3)
614 _check_chunks(replica_chunks, 600)
616 def test_reassignObjects(self) -> None:
617 """Reassign DiaObjects."""
618 # don't care about sources.
619 config = self.make_instance()
620 apdb = Apdb.from_config(config)
622 region = self.make_region()
623 visit_time = self.visit_time
624 objects = makeObjectCatalog(region, 100, visit_time)
625 oids = list(objects["diaObjectId"])
626 sources = makeSourceCatalog(objects, visit_time, use_mjd=self.use_mjd)
627 apdb.store(visit_time, objects, sources)
629 # read it back and filter by ID
630 res = apdb.getDiaSources(region, oids, visit_time)
631 self.assert_catalog(res, len(sources), ApdbTables.DiaSource)
633 apdb.reassignDiaSources({1: 1, 2: 2, 5: 5})
634 res = apdb.getDiaSources(region, oids, visit_time)
635 self.assert_catalog(res, len(sources) - 3, ApdbTables.DiaSource)
637 with self.assertRaisesRegex(ValueError, r"do not exist.*\D1000"):
638 apdb.reassignDiaSources(
639 {
640 1000: 1,
641 7: 3,
642 }
643 )
644 self.assert_catalog(res, len(sources) - 3, ApdbTables.DiaSource)
646 def test_storeUpdateRecord(self) -> None:
647 """Test _storeUpdateRecord() method."""
648 config = self.make_instance()
649 apdb = Apdb.from_config(config)
651 # Times are totally arbitrary.
652 update_time_ns1 = 2_000_000_000_000_000_000
653 update_time_ns2 = 2_000_000_001_000_000_000
654 records = [
655 ApdbReassignDiaSourceRecord(
656 update_time_ns=update_time_ns1,
657 update_order=0,
658 diaSourceId=1,
659 diaObjectId=321,
660 ssObjectId=1,
661 ssObjectReassocTimeMjdTai=60000.0,
662 ra=45.0,
663 dec=-45.0,
664 ),
665 ApdbWithdrawDiaSourceRecord(
666 update_time_ns=update_time_ns1,
667 update_order=1,
668 diaSourceId=123456,
669 diaObjectId=321,
670 timeWithdrawnMjdTai=61000.0,
671 ra=45.0,
672 dec=-45.0,
673 ),
674 ApdbReassignDiaSourceRecord(
675 update_time_ns=update_time_ns1,
676 update_order=3,
677 diaSourceId=2,
678 diaObjectId=3,
679 ssObjectId=3,
680 ssObjectReassocTimeMjdTai=60000.0,
681 ra=45.0,
682 dec=-45.0,
683 ),
684 ApdbWithdrawDiaSourceRecord(
685 update_time_ns=update_time_ns2,
686 update_order=0,
687 diaSourceId=123456,
688 diaObjectId=321,
689 timeWithdrawnMjdTai=61000.0,
690 ra=45.0,
691 dec=-45.0,
692 ),
693 ]
695 update_time = astropy.time.Time("2021-01-01T00:00:00", format="isot", scale="tai")
696 chunk = ReplicaChunk.make_replica_chunk(update_time, 600)
698 if not self.enable_replica:
699 with self.assertRaises(TypeError):
700 self.store_update_records(apdb, records, chunk)
701 else:
702 self.store_update_records(apdb, records, chunk)
704 apdb_replica = ApdbReplica.from_config(config)
705 records_returned = apdb_replica.getUpdateRecordChunks([chunk.id])
707 # Input records are ordered, output will be ordered too.
708 self.assertEqual(records_returned, records)
710 @abstractmethod
711 def store_update_records(self, apdb: Apdb, records: list[ApdbUpdateRecord], chunk: ReplicaChunk) -> None:
712 """Store update records in database, must be overriden in subclass."""
713 raise NotImplementedError()
715 def test_midpointMjdTai_src(self) -> None:
716 """Test for time filtering of DiaSources."""
717 config = self.make_instance()
718 apdb = Apdb.from_config(config)
720 region = self.make_region()
721 # 2021-01-01 plus 360 days is 2021-12-27
722 src_time1 = astropy.time.Time("2021-01-01T00:00:00", format="isot", scale="tai")
723 src_time2 = astropy.time.Time("2021-01-01T00:00:02", format="isot", scale="tai")
724 visit_time0 = astropy.time.Time("2021-12-26T23:59:59", format="isot", scale="tai")
725 visit_time1 = astropy.time.Time("2021-12-27T00:00:01", format="isot", scale="tai")
726 visit_time2 = astropy.time.Time("2021-12-27T00:00:03", format="isot", scale="tai")
727 one_sec = astropy.time.TimeDelta(1.0, format="sec")
729 objects = makeObjectCatalog(region, 100, visit_time0)
730 oids = list(objects["diaObjectId"])
731 sources = makeSourceCatalog(objects, src_time1, 0, use_mjd=self.use_mjd)
732 apdb.store(src_time1, objects, sources)
734 sources = makeSourceCatalog(objects, src_time2, 100, use_mjd=self.use_mjd)
735 apdb.store(src_time2, objects, sources)
737 # reading at time of last save should read all
738 res = apdb.getDiaSources(region, oids, src_time2)
739 self.assert_catalog(res, 200, ApdbTables.DiaSource)
741 # one second before 12 months
742 res = apdb.getDiaSources(region, oids, visit_time0)
743 self.assert_catalog(res, 200, ApdbTables.DiaSource)
745 # reading at later time of last save should only read a subset
746 res = apdb.getDiaSources(region, oids, visit_time1)
747 self.assert_catalog(res, 100, ApdbTables.DiaSource)
749 # reading at later time of last save should only read a subset
750 res = apdb.getDiaSources(region, oids, visit_time2)
751 self.assert_catalog(res, 0, ApdbTables.DiaSource)
753 # Use explicit start time argument instead of 12 month window, visit
754 # time does not matter in this case, set it to before all data.
755 res = apdb.getDiaSources(region, oids, src_time1 - one_sec, src_time1 - one_sec)
756 self.assert_catalog(res, 200, ApdbTables.DiaSource)
758 res = apdb.getDiaSources(region, oids, src_time1 - one_sec, src_time2 - one_sec)
759 self.assert_catalog(res, 100, ApdbTables.DiaSource)
761 res = apdb.getDiaSources(region, oids, src_time1 - one_sec, src_time2 + one_sec)
762 self.assert_catalog(res, 0, ApdbTables.DiaSource)
764 def test_midpointMjdTai_fsrc(self) -> None:
765 """Test for time filtering of DiaForcedSources."""
766 config = self.make_instance()
767 apdb = Apdb.from_config(config)
769 region = self.make_region()
770 src_time1 = astropy.time.Time("2021-01-01T00:00:00", format="isot", scale="tai")
771 src_time2 = astropy.time.Time("2021-01-01T00:00:02", format="isot", scale="tai")
772 visit_time0 = astropy.time.Time("2021-12-26T23:59:59", format="isot", scale="tai")
773 visit_time1 = astropy.time.Time("2021-12-27T00:00:01", format="isot", scale="tai")
774 visit_time2 = astropy.time.Time("2021-12-27T00:00:03", format="isot", scale="tai")
775 one_sec = astropy.time.TimeDelta(1.0, format="sec")
777 objects = makeObjectCatalog(region, 100, visit_time0)
778 oids = list(objects["diaObjectId"])
779 sources = makeForcedSourceCatalog(objects, src_time1, 1, use_mjd=self.use_mjd)
780 apdb.store(src_time1, objects, forced_sources=sources)
782 sources = makeForcedSourceCatalog(objects, src_time2, 2, use_mjd=self.use_mjd)
783 apdb.store(src_time2, objects, forced_sources=sources)
785 # reading at time of last save should read all
786 res = apdb.getDiaForcedSources(region, oids, src_time2)
787 self.assert_catalog(res, 200, ApdbTables.DiaForcedSource)
789 # one second before 12 months
790 res = apdb.getDiaForcedSources(region, oids, visit_time0)
791 self.assert_catalog(res, 200, ApdbTables.DiaForcedSource)
793 # reading at later time of last save should only read a subset
794 res = apdb.getDiaForcedSources(region, oids, visit_time1)
795 self.assert_catalog(res, 100, ApdbTables.DiaForcedSource)
797 # reading at later time of last save should only read a subset
798 res = apdb.getDiaForcedSources(region, oids, visit_time2)
799 self.assert_catalog(res, 0, ApdbTables.DiaForcedSource)
801 # Use explicit start time argument instead of 12 month window, visit
802 # time does not matter in this case, set it to before all data.
803 res = apdb.getDiaForcedSources(region, oids, src_time1 - one_sec, src_time1 - one_sec)
804 self.assert_catalog(res, 200, ApdbTables.DiaForcedSource)
806 res = apdb.getDiaForcedSources(region, oids, src_time1 - one_sec, src_time2 - one_sec)
807 self.assert_catalog(res, 100, ApdbTables.DiaForcedSource)
809 res = apdb.getDiaForcedSources(region, oids, src_time1 - one_sec, src_time2 + one_sec)
810 self.assert_catalog(res, 0, ApdbTables.DiaForcedSource)
812 def test_metadata(self) -> None:
813 """Simple test for writing/reading metadata table"""
814 config = self.make_instance()
815 apdb = Apdb.from_config(config)
816 metadata = apdb.metadata
818 # APDB should write two or three metadata items with version numbers
819 # and a frozen JSON config.
820 self.assertFalse(metadata.empty())
821 self.assertEqual(len(list(metadata.items())), self.meta_row_count)
823 metadata.set("meta", "data")
824 metadata.set("data", "meta")
826 self.assertFalse(metadata.empty())
827 self.assertTrue(set(metadata.items()) >= {("meta", "data"), ("data", "meta")})
829 with self.assertRaisesRegex(KeyError, "Metadata key 'meta' already exists"):
830 metadata.set("meta", "data1")
832 metadata.set("meta", "data2", force=True)
833 self.assertTrue(set(metadata.items()) >= {("meta", "data2"), ("data", "meta")})
835 self.assertTrue(metadata.delete("meta"))
836 self.assertIsNone(metadata.get("meta"))
837 self.assertFalse(metadata.delete("meta"))
839 self.assertEqual(metadata.get("data"), "meta")
840 self.assertEqual(metadata.get("meta", "meta"), "meta")
842 def test_schemaVersionFromYaml(self) -> None:
843 """Check version number handling for reading schema from YAML."""
844 config = self.make_instance()
845 default_schema = config.schema_file
846 apdb = Apdb.from_config(config)
847 self.assertEqual(apdb.schema.schemaVersion(), VersionTuple(0, 1, 1))
849 with update_schema_yaml(default_schema, version="") as schema_file:
850 config = self.make_instance(schema_file=schema_file)
851 apdb = Apdb.from_config(config)
852 self.assertEqual(
853 apdb.schema.schemaVersion(),
854 VersionTuple(0, 1, 0),
855 )
857 with update_schema_yaml(default_schema, version="99.0.0") as schema_file:
858 config = self.make_instance(schema_file=schema_file)
859 apdb = Apdb.from_config(config)
860 self.assertEqual(
861 apdb.schema.schemaVersion(),
862 VersionTuple(99, 0, 0),
863 )
865 def test_config_freeze(self) -> None:
866 """Test that some config fields are correctly frozen in database."""
867 config = self.make_instance()
869 # `enable_replica` is the only parameter that is frozen in all
870 # implementations.
871 config.enable_replica = not self.enable_replica
872 apdb = Apdb.from_config(config)
873 frozen_config = apdb.getConfig()
874 self.assertEqual(frozen_config.enable_replica, self.enable_replica)
877class ApdbSchemaUpdateTest(TestCaseMixin, ABC):
878 """Base class for unit tests that verify how schema changes work."""
880 visit_time = astropy.time.Time("2021-01-01T00:00:00", format="isot", scale="tai")
882 @abstractmethod
883 def make_instance(self, **kwargs: Any) -> ApdbConfig:
884 """Make config class instance used in all tests.
886 This method should return configuration that point to the identical
887 database instance on each call (i.e. ``db_url`` must be the same,
888 which also means for sqlite it has to use on-disk storage).
889 """
890 raise NotImplementedError()
892 def make_region(self, xyz: tuple[float, float, float] = (1.0, 1.0, -1.0)) -> Region:
893 """Make a region to use in tests"""
894 return _make_region(xyz)
896 def test_schema_add_replica(self) -> None:
897 """Check that new code can work with old schema without replica
898 tables.
899 """
900 # Make schema without replica tables.
901 config = self.make_instance(enable_replica=False)
902 apdb = Apdb.from_config(config)
903 apdb_replica = ApdbReplica.from_config(config)
905 # Make APDB instance configured for replication.
906 config.enable_replica = True
907 apdb = Apdb.from_config(config)
909 # Try to insert something, should work OK.
910 region = self.make_region()
911 visit_time = self.visit_time
913 # have to store Objects first
914 objects = makeObjectCatalog(region, 100, visit_time)
915 sources = makeSourceCatalog(objects, visit_time)
916 fsources = makeForcedSourceCatalog(objects, visit_time)
917 apdb.store(visit_time, objects, sources, fsources)
919 # There should be no replica chunks.
920 replica_chunks = apdb_replica.getReplicaChunks()
921 self.assertIsNone(replica_chunks)
923 def test_schemaVersionCheck(self) -> None:
924 """Check version number compatibility."""
925 config = self.make_instance()
926 apdb = Apdb.from_config(config)
928 self.assertEqual(apdb.schema.schemaVersion(), VersionTuple(0, 1, 1))
930 # Claim that schema version is now 99.0.0, must raise an exception.
931 with update_schema_yaml(config.schema_file, version="99.0.0") as schema_file:
932 config.schema_file = schema_file
933 with self.assertRaises(IncompatibleVersionError):
934 apdb = Apdb.from_config(config)
935 # Version is checked only when we try to do connect.
936 apdb.metadata.items()