Coverage for tests/test_dimensions.py: 11%
493 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-11 01:27 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-11 01:27 -0700
1# This file is part of daf_butler.
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 software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <http://www.gnu.org/licenses/>.
28import copy
29import itertools
30import math
31import os
32import pickle
33import unittest
34from collections.abc import Iterator
35from dataclasses import dataclass
36from random import Random
38import pydantic
40import lsst.sphgeom
41from lsst.daf.butler import (
42 Config,
43 DataCoordinate,
44 DataCoordinateSequence,
45 DataCoordinateSet,
46 DatasetType,
47 Dimension,
48 DimensionConfig,
49 DimensionElement,
50 DimensionGroup,
51 DimensionNameError,
52 DimensionPacker,
53 DimensionUniverse,
54 NamedKeyDict,
55 NamedValueSet,
56 ddl,
57)
58from lsst.daf.butler.tests.utils import create_populated_sqlite_registry
59from lsst.daf.butler.timespan_database_representation import TimespanDatabaseRepresentation
61DIMENSION_DATA_FILE = os.path.normpath(
62 os.path.join(os.path.dirname(__file__), "data", "registry", "hsc-rc2-subset.yaml")
63)
66def loadDimensionData() -> DataCoordinateSequence:
67 """Load dimension data from an export file included in the code repository.
69 Returns
70 -------
71 dataIds : `DataCoordinateSet`
72 A set containing all data IDs in the export file.
73 """
74 # Create an in-memory SQLite database and Registry just to import the YAML
75 # data and retrieve it as a set of DataCoordinate objects.
76 butler = create_populated_sqlite_registry(DIMENSION_DATA_FILE)
77 dimensions = butler.registry.dimensions.conform(["visit", "detector", "tract", "patch"])
78 return butler.registry.queryDataIds(dimensions).expanded().toSequence()
81class ConcreteTestDimensionPacker(DimensionPacker):
82 """A concrete `DimensionPacker` for testing its base class implementations.
84 This class just returns the detector ID as-is.
85 """
87 def __init__(self, fixed: DataCoordinate, dimensions: DimensionGroup):
88 super().__init__(fixed, dimensions)
89 self._n_detectors = fixed.records["instrument"].detector_max
90 self._max_bits = (self._n_detectors - 1).bit_length()
92 @property
93 def maxBits(self) -> int:
94 # Docstring inherited from DimensionPacker.maxBits
95 return self._max_bits
97 def _pack(self, dataId: DataCoordinate) -> int:
98 # Docstring inherited from DimensionPacker._pack
99 return dataId["detector"]
101 def unpack(self, packedId: int) -> DataCoordinate:
102 # Docstring inherited from DimensionPacker.unpack
103 return DataCoordinate.standardize(
104 {
105 "instrument": self.fixed["instrument"],
106 "detector": packedId,
107 },
108 dimensions=self._dimensions,
109 )
112class DimensionTestCase(unittest.TestCase):
113 """Tests for dimensions.
115 All tests here rely on the content of ``config/dimensions.yaml``, either
116 to test that the definitions there are read in properly or just as generic
117 data for testing various operations.
118 """
120 def setUp(self):
121 self.universe = DimensionUniverse()
123 def checkGroupInvariants(self, group: DimensionGroup):
124 elements = list(group.elements)
125 for n, element_name in enumerate(elements):
126 element = self.universe[element_name]
127 # Ordered comparisons on graphs behave like sets.
128 self.assertLessEqual(element.minimal_group, group)
129 # Ordered comparisons on elements correspond to the ordering within
130 # a DimensionUniverse (topological, with deterministic
131 # tiebreakers).
132 for other_name in elements[:n]:
133 other = self.universe[other_name]
134 self.assertLess(other, element)
135 self.assertLessEqual(other, element)
136 for other_name in elements[n + 1 :]:
137 other = self.universe[other_name]
138 self.assertGreater(other, element)
139 self.assertGreaterEqual(other, element)
140 if isinstance(element, Dimension):
141 self.assertEqual(element.minimal_group.required, element.required)
142 self.assertEqual(self.universe.conform(group.required), group)
143 self.assertCountEqual(
144 group.required,
145 [
146 dimension_name
147 for dimension_name in group.names
148 if not any(
149 dimension_name in self.universe[other_name].minimal_group.implied
150 for other_name in group.elements
151 )
152 ],
153 )
154 self.assertCountEqual(group.implied, group.names - group.required)
155 self.assertCountEqual(group.names, itertools.chain(group.required, group.implied))
156 # Check primary key traversal order: each element should follow any it
157 # requires, and element that is implied by any other in the graph
158 # follow at least one of those.
159 seen: set[str] = set()
160 for element_name in group.lookup_order:
161 element = self.universe[element_name]
162 with self.subTest(required=group.required, implied=group.implied, element=element):
163 seen.add(element_name)
164 self.assertLessEqual(element.minimal_group.required, seen)
165 if element_name in group.implied:
166 self.assertTrue(any(element_name in self.universe[s].implied for s in seen))
167 self.assertCountEqual(seen, group.elements)
169 def testConfigPresent(self):
170 config = self.universe.dimensionConfig
171 self.assertIsInstance(config, DimensionConfig)
173 def test_group_union(self) -> None:
174 """Test unions of DimensionGroups."""
175 a = self.universe.conform(["visit"])
176 b = self.universe.conform(["detector"])
177 self.assertIs(a.union(b), self.universe.conform(["visit", "detector"]))
178 self.assertIs(DimensionGroup.union(a, b), self.universe.conform(["visit", "detector"]))
179 self.assertIs(DimensionGroup.union(universe=self.universe), self.universe.empty)
180 with self.assertRaises(TypeError):
181 DimensionGroup.union()
183 def test_group_set_ops(self) -> None:
184 """Test set operations on DimensionGroups."""
185 a = self.universe.conform(["visit"])
186 b = self.universe.conform(["detector"])
187 c = self.universe.conform(["visit", "detector"])
188 d = self.universe.conform(["physical_filter"])
189 e = self.universe.conform(["detector", "physical_filter"])
190 self.assertEqual(a.union(b), c)
191 self.assertEqual(a.union(d), a)
192 self.assertEqual(b.union(d), e)
193 self.assertEqual(a.difference(b), a)
194 self.assertEqual(a.difference(c), self.universe.empty)
195 self.assertEqual(a.difference(d), a)
196 self.assertEqual(c.difference(a), b)
197 self.assertEqual(a.intersection(c), a)
198 self.assertEqual(a.intersection(d), d)
199 self.assertEqual(a.intersection(e), d)
201 def testCompatibility(self):
202 # Simple check that should always be true.
203 self.assertTrue(self.universe.isCompatibleWith(self.universe))
205 # Create a universe like the default universe but with a different
206 # version number.
207 clone = self.universe.dimensionConfig.copy()
208 clone["version"] = clone["version"] + 1_000_000 # High version number
209 universe_clone = DimensionUniverse(config=clone)
210 with self.assertLogs("lsst.daf.butler.dimensions", "INFO") as cm:
211 self.assertTrue(self.universe.isCompatibleWith(universe_clone))
212 self.assertIn("differing versions", "\n".join(cm.output))
214 # Create completely incompatible universe.
215 config = Config(
216 {
217 "version": 1,
218 "namespace": "compat_test",
219 "skypix": {
220 "common": "htm7",
221 "htm": {
222 "class": "lsst.sphgeom.HtmPixelization",
223 "max_level": 24,
224 },
225 },
226 "elements": {
227 "A": {
228 "keys": [
229 {
230 "name": "id",
231 "type": "int",
232 }
233 ],
234 "storage": {
235 "cls": "lsst.daf.butler.registry.dimensions.table.TableDimensionRecordStorage",
236 },
237 },
238 "B": {
239 "keys": [
240 {
241 "name": "id",
242 "type": "int",
243 }
244 ],
245 "storage": {
246 "cls": "lsst.daf.butler.registry.dimensions.table.TableDimensionRecordStorage",
247 },
248 },
249 },
250 "packers": {},
251 }
252 )
253 universe2 = DimensionUniverse(config=config)
254 self.assertFalse(universe2.isCompatibleWith(self.universe))
256 def testVersion(self):
257 self.assertEqual(self.universe.namespace, "daf_butler")
258 # Test was added starting at version 2.
259 self.assertGreaterEqual(self.universe.version, 2)
261 def testConfigRead(self):
262 self.assertEqual(
263 set(self.universe.getStaticDimensions().names),
264 {
265 "instrument",
266 "visit",
267 "visit_system",
268 "exposure",
269 "detector",
270 "group",
271 "day_obs",
272 "physical_filter",
273 "band",
274 "subfilter",
275 "skymap",
276 "tract",
277 "patch",
278 }
279 | {f"htm{level}" for level in range(1, 25)}
280 | {f"healpix{level}" for level in range(1, 18)},
281 )
283 def testGraphs(self):
284 self.checkGroupInvariants(self.universe.empty)
285 for element in self.universe.getStaticElements():
286 self.checkGroupInvariants(element.minimal_group)
288 def testInstrumentDimensions(self):
289 group = self.universe.conform(["exposure", "detector", "visit"])
290 self.assertCountEqual(
291 group.names,
292 ("instrument", "exposure", "detector", "visit", "physical_filter", "band", "group", "day_obs"),
293 )
294 self.assertCountEqual(group.required, ("instrument", "exposure", "detector", "visit"))
295 self.assertCountEqual(group.implied, ("physical_filter", "band", "group", "day_obs"))
296 self.assertCountEqual(group.elements - group.names, ("visit_detector_region", "visit_definition"))
297 self.assertCountEqual(group.governors, {"instrument"})
298 for element in group.elements:
299 self.assertEqual(self.universe[element].has_own_table, element != "band", element)
300 self.assertEqual(
301 self.universe[element].implied_union_target,
302 "physical_filter" if element == "band" else None,
303 element,
304 )
305 self.assertEqual(
306 self.universe[element].defines_relationships,
307 element
308 in ("visit", "exposure", "physical_filter", "visit_definition", "visit_system_membership"),
309 element,
310 )
312 def testCalibrationDimensions(self):
313 group = self.universe.conform(["physical_filter", "detector"])
314 self.assertCountEqual(group.names, ("instrument", "detector", "physical_filter", "band"))
315 self.assertCountEqual(group.required, ("instrument", "detector", "physical_filter"))
316 self.assertCountEqual(group.implied, ("band",))
317 self.assertCountEqual(group.elements, group.names)
318 self.assertCountEqual(group.governors, {"instrument"})
319 self.assertIsNone(group.region_dimension)
320 self.assertIsNone(group.timespan_dimension)
322 def testObservationDimensions(self):
323 group = self.universe.conform(["exposure", "detector", "visit"])
324 self.assertCountEqual(
325 group.names,
326 ("instrument", "detector", "visit", "exposure", "physical_filter", "band", "group", "day_obs"),
327 )
328 self.assertCountEqual(group.required, ("instrument", "detector", "exposure", "visit"))
329 self.assertCountEqual(group.implied, ("physical_filter", "band", "group", "day_obs"))
330 self.assertCountEqual(group.elements - group.names, ("visit_detector_region", "visit_definition"))
331 self.assertCountEqual(group.spatial.names, ("observation_regions",))
332 self.assertCountEqual(group.temporal.names, ("observation_timespans",))
333 self.assertCountEqual(group.governors, {"instrument"})
334 self.assertEqual(group.region_dimension, "visit_detector_region")
335 self.assertEqual(group.timespan_dimension, "exposure")
336 self.assertEqual(group.spatial.names, {"observation_regions"})
337 self.assertEqual(group.temporal.names, {"observation_timespans"})
338 self.assertEqual(next(iter(group.spatial)).governor, self.universe["instrument"])
339 self.assertEqual(next(iter(group.temporal)).governor, self.universe["instrument"])
340 self.assertEqual(self.universe["visit_definition"].populated_by, self.universe["visit"])
341 self.assertEqual(self.universe["visit_system_membership"].populated_by, self.universe["visit"])
342 self.assertEqual(self.universe["visit_detector_region"].populated_by, self.universe["visit"])
343 self.assertEqual(
344 self.universe.get_elements_populated_by(self.universe["visit"]),
345 NamedValueSet(
346 {
347 self.universe["visit"],
348 self.universe["visit_definition"],
349 self.universe["visit_system_membership"],
350 self.universe["visit_detector_region"],
351 }
352 ),
353 )
355 def testSkyMapDimensions(self):
356 group = self.universe.conform(["patch"])
357 self.assertEqual(group.names, {"skymap", "tract", "patch"})
358 self.assertEqual(group.required, {"skymap", "tract", "patch"})
359 self.assertEqual(group.implied, set())
360 self.assertEqual(group.elements, group.names)
361 self.assertEqual(group.governors, {"skymap"})
362 self.assertEqual(group.spatial.names, {"skymap_regions"})
363 self.assertEqual(next(iter(group.spatial)).governor, self.universe["skymap"])
365 def testSubsetCalculation(self):
366 """Test that independent spatial and temporal options are computed
367 correctly.
368 """
369 group = self.universe.conform(["visit", "detector", "tract", "patch", "htm7", "exposure"])
370 self.assertCountEqual(group.spatial.names, ("observation_regions", "skymap_regions", "htm"))
371 self.assertCountEqual(group.temporal.names, ("observation_timespans",))
372 self.assertEqual(group.timespan_dimension, "exposure")
373 # Can not choose between visit_detector_region or htm7 or tract/patch.
374 self.assertIsNone(group.region_dimension)
376 def testSchemaGeneration(self):
377 tableSpecs: NamedKeyDict[DimensionElement, ddl.TableSpec] = NamedKeyDict({})
378 for element in self.universe.getStaticElements():
379 if element.has_own_table:
380 tableSpecs[element] = element.RecordClass.fields.makeTableSpec(
381 TimespanReprClass=TimespanDatabaseRepresentation.Compound,
382 )
383 for element, tableSpec in tableSpecs.items():
384 for dep in element.required:
385 with self.subTest(element=element.name, dep=dep.name):
386 if dep != element:
387 self.assertIn(dep.name, tableSpec.fields)
388 self.assertEqual(tableSpec.fields[dep.name].dtype, dep.primaryKey.dtype)
389 self.assertEqual(tableSpec.fields[dep.name].length, dep.primaryKey.length)
390 self.assertEqual(tableSpec.fields[dep.name].nbytes, dep.primaryKey.nbytes)
391 self.assertFalse(tableSpec.fields[dep.name].nullable)
392 self.assertTrue(tableSpec.fields[dep.name].primaryKey)
393 else:
394 self.assertIn(element.primaryKey.name, tableSpec.fields)
395 self.assertEqual(
396 tableSpec.fields[element.primaryKey.name].dtype, dep.primaryKey.dtype
397 )
398 self.assertEqual(
399 tableSpec.fields[element.primaryKey.name].length, dep.primaryKey.length
400 )
401 self.assertEqual(
402 tableSpec.fields[element.primaryKey.name].nbytes, dep.primaryKey.nbytes
403 )
404 self.assertFalse(tableSpec.fields[element.primaryKey.name].nullable)
405 self.assertTrue(tableSpec.fields[element.primaryKey.name].primaryKey)
406 for dep in element.implied:
407 with self.subTest(element=element.name, dep=dep.name):
408 self.assertIn(dep.name, tableSpec.fields)
409 self.assertEqual(tableSpec.fields[dep.name].dtype, dep.primaryKey.dtype)
410 self.assertFalse(tableSpec.fields[dep.name].primaryKey)
411 for foreignKey in tableSpec.foreignKeys:
412 self.assertIn(foreignKey.table, tableSpecs)
413 self.assertIn(foreignKey.table, element.dimensions)
414 self.assertEqual(len(foreignKey.source), len(foreignKey.target))
415 for source, target in zip(foreignKey.source, foreignKey.target, strict=True):
416 self.assertIn(source, tableSpec.fields.names)
417 self.assertIn(target, tableSpecs[foreignKey.table].fields.names)
418 self.assertEqual(
419 tableSpec.fields[source].dtype, tableSpecs[foreignKey.table].fields[target].dtype
420 )
421 self.assertEqual(
422 tableSpec.fields[source].length, tableSpecs[foreignKey.table].fields[target].length
423 )
424 self.assertEqual(
425 tableSpec.fields[source].nbytes, tableSpecs[foreignKey.table].fields[target].nbytes
426 )
428 def testPickling(self):
429 # Pickling and copying should always yield the exact same object within
430 # a single process (cross-process is impossible to test here).
431 universe1 = DimensionUniverse()
432 universe2 = pickle.loads(pickle.dumps(universe1))
433 universe3 = copy.copy(universe1)
434 universe4 = copy.deepcopy(universe1)
435 self.assertIs(universe1, universe2)
436 self.assertIs(universe1, universe3)
437 self.assertIs(universe1, universe4)
438 for element1 in universe1.getStaticElements():
439 element2 = pickle.loads(pickle.dumps(element1))
440 self.assertIs(element1, element2)
441 group1 = element1.minimal_group
442 group2 = pickle.loads(pickle.dumps(group1))
443 self.assertIs(group1, group2)
445 def testSerialization(self):
446 # Check that dataset types round-trip correctly through serialization.
447 dataset_type = DatasetType(
448 "flat",
449 dimensions=["instrument", "detector", "physical_filter", "band"],
450 isCalibration=True,
451 universe=self.universe,
452 storageClass="int",
453 )
454 roundtripped = DatasetType.from_simple(dataset_type.to_simple(), universe=self.universe)
456 self.assertEqual(dataset_type, roundtripped)
459@dataclass
460class SplitByStateFlags:
461 """A struct that separates data IDs with different states but the same
462 values.
463 """
465 minimal: DataCoordinateSequence | None = None
466 """Data IDs that only contain values for required dimensions.
468 `DataCoordinateSequence.hasFull()` will return `True` for this if and only
469 if ``minimal.dimensions.implied`` has no elements.
470 `DataCoordinate.hasRecords()` will always return `False`.
471 """
473 complete: DataCoordinateSequence | None = None
474 """Data IDs that contain values for all dimensions.
476 `DataCoordinateSequence.hasFull()` will always `True` and
477 `DataCoordinate.hasRecords()` will always return `True` for this attribute.
478 """
480 expanded: DataCoordinateSequence | None = None
481 """Data IDs that contain values for all dimensions as well as records.
483 `DataCoordinateSequence.hasFull()` and `DataCoordinate.hasRecords()` will
484 always return `True` for this attribute.
485 """
487 def chain(self, n: int | None = None) -> Iterator[DataCoordinate]:
488 """Iterate over the data IDs of different types.
490 Parameters
491 ----------
492 n : `int`, optional
493 If provided (`None` is default), iterate over only the ``nth``
494 data ID in each attribute.
496 Yields
497 ------
498 dataId : `DataCoordinate`
499 A data ID from one of the attributes in this struct.
500 """
501 if n is None:
502 s = slice(None, None)
503 else:
504 s = slice(n, n + 1)
505 if self.minimal is not None:
506 yield from self.minimal[s]
507 if self.complete is not None:
508 yield from self.complete[s]
509 if self.expanded is not None:
510 yield from self.expanded[s]
513class DataCoordinateTestCase(unittest.TestCase):
514 """Test `DataCoordinate`."""
516 RANDOM_SEED = 10
518 @classmethod
519 def setUpClass(cls):
520 cls.allDataIds = loadDimensionData()
522 def setUp(self):
523 self.rng = Random(self.RANDOM_SEED)
525 def randomDataIds(self, n: int, dataIds: DataCoordinateSequence | None = None):
526 """Select random data IDs from those loaded from test data.
528 Parameters
529 ----------
530 n : `int`
531 Number of data IDs to select.
532 dataIds : `DataCoordinateSequence`, optional
533 Data IDs to select from. Defaults to ``self.allDataIds``.
535 Returns
536 -------
537 selected : `DataCoordinateSequence`
538 ``n`` Data IDs randomly selected from ``dataIds`` with replacement.
539 """
540 if dataIds is None:
541 dataIds = self.allDataIds
542 return DataCoordinateSequence(
543 self.rng.sample(dataIds, n),
544 dimensions=dataIds.dimensions,
545 hasFull=dataIds.hasFull(),
546 hasRecords=dataIds.hasRecords(),
547 check=False,
548 )
550 def randomDimensionSubset(self, n: int = 3, group: DimensionGroup | None = None) -> DimensionGroup:
551 """Generate a random `DimensionGroup` that has a subset of the
552 dimensions in a given one.
554 Parameters
555 ----------
556 n : `int`
557 Number of dimensions to select, before automatic expansion by
558 `DimensionGroup`.
559 group : `DimensionGroup`, optional
560 Dimensions to select from. Defaults to
561 ``self.allDataIds.dimensions``.
563 Returns
564 -------
565 selected : `DimensionGroup`
566 ``n`` or more dimensions randomly selected from ``group`` with
567 replacement.
568 """
569 if group is None:
570 group = self.allDataIds.dimensions
571 return group.universe.conform(self.rng.sample(list(group.names), max(n, len(group))))
573 def splitByStateFlags(
574 self,
575 dataIds: DataCoordinateSequence | None = None,
576 *,
577 expanded: bool = True,
578 complete: bool = True,
579 minimal: bool = True,
580 ) -> SplitByStateFlags:
581 """Given a sequence of data IDs, generate new equivalent sequences
582 containing less information.
584 Parameters
585 ----------
586 dataIds : `DataCoordinateSequence`, optional.
587 Data IDs to start from. Defaults to ``self.allDataIds``.
588 ``dataIds.hasRecords()`` and ``dataIds.hasFull()`` must both return
589 `True`.
590 expanded : `bool`, optional
591 If `True` (default) include the original data IDs that contain all
592 information in the result.
593 complete : `bool`, optional
594 If `True` (default) include data IDs for which ``hasFull()``
595 returns `True` but ``hasRecords()`` does not.
596 minimal : `bool`, optional
597 If `True` (default) include data IDS that only contain values for
598 required dimensions, for which ``hasFull()`` may not return `True`.
600 Returns
601 -------
602 split : `SplitByStateFlags`
603 A dataclass holding the indicated data IDs in attributes that
604 correspond to the boolean keyword arguments.
605 """
606 if dataIds is None:
607 dataIds = self.allDataIds
608 assert dataIds.hasFull() and dataIds.hasRecords()
609 result = SplitByStateFlags(expanded=dataIds)
610 if complete:
611 result.complete = DataCoordinateSequence(
612 [
613 DataCoordinate.standardize(e.mapping, dimensions=dataIds.dimensions)
614 for e in result.expanded
615 ],
616 dimensions=dataIds.dimensions,
617 )
618 self.assertTrue(result.complete.hasFull())
619 self.assertFalse(result.complete.hasRecords())
620 if minimal:
621 result.minimal = DataCoordinateSequence(
622 [
623 DataCoordinate.standardize(e.required, dimensions=dataIds.dimensions)
624 for e in result.expanded
625 ],
626 dimensions=dataIds.dimensions,
627 )
628 self.assertEqual(result.minimal.hasFull(), not dataIds.dimensions.implied)
629 self.assertFalse(result.minimal.hasRecords())
630 if not expanded:
631 result.expanded = None
632 return result
634 def testMappingViews(self):
635 """Test that the ``mapping`` and ``required`` attributes in
636 `DataCoordinate` are self-consistent and consistent with the
637 ``dimensions`` property.
638 """
639 for _ in range(5):
640 dimensions = self.randomDimensionSubset()
641 dataIds = self.randomDataIds(n=1).subset(dimensions)
642 split = self.splitByStateFlags(dataIds)
643 for dataId in split.chain():
644 with self.subTest(dataId=dataId):
645 self.assertEqual(dataId.required.keys(), dataId.dimensions.required)
646 self.assertEqual(
647 list(dataId.required.values()), [dataId[d] for d in dataId.dimensions.required]
648 )
649 self.assertEqual(
650 list(dataId.required_values), [dataId[d] for d in dataId.dimensions.required]
651 )
652 self.assertEqual(dataId.required.keys(), dataId.dimensions.required)
653 for dataId in itertools.chain(split.complete, split.expanded):
654 with self.subTest(dataId=dataId):
655 self.assertTrue(dataId.hasFull())
656 self.assertEqual(dataId.dimensions.names, dataId.mapping.keys())
657 self.assertEqual(
658 list(dataId.mapping.values()), [dataId[k] for k in dataId.mapping.keys()]
659 )
661 def test_pickle(self):
662 for _ in range(5):
663 dimensions = self.randomDimensionSubset()
664 dataIds = self.randomDataIds(n=1).subset(dimensions)
665 split = self.splitByStateFlags(dataIds)
666 for data_id in split.chain():
667 s = pickle.dumps(data_id)
668 read_data_id: DataCoordinate = pickle.loads(s)
669 self.assertEqual(data_id, read_data_id)
670 self.assertEqual(data_id.hasFull(), read_data_id.hasFull())
671 self.assertEqual(data_id.hasRecords(), read_data_id.hasRecords())
672 if data_id.hasFull():
673 self.assertEqual(data_id.mapping, read_data_id.mapping)
674 if data_id.hasRecords():
675 for element_name in data_id.dimensions.elements:
676 self.assertEqual(
677 data_id.records[element_name], read_data_id.records[element_name]
678 )
680 def test_record_attributes(self):
681 """Test that dimension records are available as attributes on expanded
682 data coordinates.
683 """
684 for _ in range(5):
685 dimensions = self.randomDimensionSubset()
686 dataIds = self.randomDataIds(n=1).subset(dimensions)
687 split = self.splitByStateFlags(dataIds)
688 for data_id in split.expanded:
689 for element_name in data_id.dimensions.elements:
690 self.assertIs(getattr(data_id, element_name), data_id.records[element_name])
691 self.assertIn(element_name, dir(data_id))
692 with self.assertRaisesRegex(AttributeError, "^not_a_dimension_name$"):
693 data_id.not_a_dimension_name
694 for data_id in itertools.chain(split.minimal, split.complete):
695 for element_name in data_id.dimensions.elements:
696 with self.assertRaisesRegex(AttributeError, "only available on expanded DataCoordinates"):
697 getattr(data_id, element_name)
698 with self.assertRaisesRegex(AttributeError, "^not_a_dimension_name$"):
699 data_id.not_a_dimension_name
701 def testEquality(self):
702 """Test that different `DataCoordinate` instances with different state
703 flags can be compared with each other and other mappings.
704 """
705 dataIds = self.randomDataIds(n=2)
706 split = self.splitByStateFlags(dataIds)
707 # Iterate over all combinations of different states of DataCoordinate,
708 # with the same underlying data ID values.
709 for a0, b0 in itertools.combinations(split.chain(0), 2):
710 self.assertEqual(a0, b0)
711 # Same thing, for a different data ID value.
712 for a1, b1 in itertools.combinations(split.chain(1), 2):
713 self.assertEqual(a1, b1)
714 # Iterate over all combinations of different states of DataCoordinate,
715 # with different underlying data ID values.
716 for a0, b1 in itertools.product(split.chain(0), split.chain(1)):
717 self.assertNotEqual(a0, b1)
718 self.assertNotEqual(a1, b0)
720 def testStandardize(self):
721 """Test constructing a DataCoordinate from many different kinds of
722 input via `DataCoordinate.standardize` and `DataCoordinate.subset`.
723 """
724 for _ in range(5):
725 dimensions = self.randomDimensionSubset()
726 dataIds = self.randomDataIds(n=1).subset(dimensions)
727 split = self.splitByStateFlags(dataIds)
728 for dataId in split.chain():
729 # Passing in any kind of DataCoordinate alone just returns
730 # that object.
731 self.assertIs(dataId, DataCoordinate.standardize(dataId))
732 # Same if we also explicitly pass the dimensions we want.
733 self.assertIs(dataId, DataCoordinate.standardize(dataId, dimensions=dataId.dimensions))
734 # Same if we pass the dimensions and some irrelevant
735 # kwargs.
736 self.assertIs(
737 dataId, DataCoordinate.standardize(dataId, dimensions=dataId.dimensions, htm7=12)
738 )
739 # Test constructing a new data ID from this one with a
740 # subset of the dimensions.
741 # This is not possible for some combinations of
742 # dimensions if hasFull is False (see
743 # `DataCoordinate.subset` docs).
744 newDimensions = self.randomDimensionSubset(n=1, group=dataId.dimensions)
745 if dataId.hasFull() or dataId.dimensions.required >= newDimensions.required:
746 newDataIds = [
747 dataId.subset(newDimensions),
748 DataCoordinate.standardize(dataId, dimensions=newDimensions),
749 DataCoordinate.standardize(dataId, dimensions=newDimensions, htm7=12),
750 ]
751 for newDataId in newDataIds:
752 with self.subTest(newDataId=newDataId, type=type(dataId)):
753 commonKeys = dataId.dimensions.required & newDataId.dimensions.required
754 self.assertTrue(commonKeys)
755 self.assertEqual(
756 [newDataId[k] for k in commonKeys],
757 [dataId[k] for k in commonKeys],
758 )
759 # This should never "downgrade" from
760 # Complete to Minimal or Expanded to Complete.
761 if dataId.hasRecords():
762 self.assertTrue(newDataId.hasRecords())
763 if dataId.hasFull():
764 self.assertTrue(newDataId.hasFull())
765 # Start from a complete data ID, and pass its values in via several
766 # different ways that should be equivalent.
767 for dataId in split.complete:
768 # Split the keys (dimension names) into two random subsets, so
769 # we can pass some as kwargs below.
770 keys1 = set(self.rng.sample(list(dataId.dimensions.names), len(dataId.dimensions) // 2))
771 keys2 = dataId.dimensions.names - keys1
772 newCompleteDataIds = [
773 DataCoordinate.standardize(dataId.mapping, universe=dataId.universe),
774 DataCoordinate.standardize(dataId.mapping, dimensions=dataId.dimensions),
775 DataCoordinate.standardize(
776 DataCoordinate.make_empty(dataId.dimensions.universe), **dataId.mapping
777 ),
778 DataCoordinate.standardize(
779 DataCoordinate.make_empty(dataId.dimensions.universe),
780 dimensions=dataId.dimensions,
781 **dataId.mapping,
782 ),
783 DataCoordinate.standardize(**dataId.mapping, universe=dataId.universe),
784 DataCoordinate.standardize(dimensions=dataId.dimensions, **dataId.mapping),
785 DataCoordinate.standardize(
786 {k: dataId[k] for k in keys1},
787 universe=dataId.universe,
788 **{k: dataId[k] for k in keys2},
789 ),
790 DataCoordinate.standardize(
791 {k: dataId[k] for k in keys1},
792 dimensions=dataId.dimensions,
793 **{k: dataId[k] for k in keys2},
794 ),
795 ]
796 for newDataId in newCompleteDataIds:
797 with self.subTest(dataId=dataId, newDataId=newDataId, type=type(dataId)):
798 self.assertEqual(dataId, newDataId)
799 self.assertTrue(newDataId.hasFull())
801 coord = DataCoordinate.standardize({"instrument": "HSC"}, universe=self.allDataIds.universe)
802 with self.assertRaises(DimensionNameError):
803 coord.subset(["detector"])
805 def testUnion(self):
806 """Test `DataCoordinate.union`."""
807 # Make test groups to combine; mostly random, but with a few explicit
808 # cases to make sure certain edge cases are covered.
809 groups = [self.randomDimensionSubset(n=2) for i in range(2)]
810 groups.append(self.allDataIds.universe["visit"].minimal_group)
811 groups.append(self.allDataIds.universe["detector"].minimal_group)
812 groups.append(self.allDataIds.universe["physical_filter"].minimal_group)
813 groups.append(self.allDataIds.universe["band"].minimal_group)
814 # Iterate over all combinations, including the same graph with itself.
815 for group1, group2 in itertools.product(groups, repeat=2):
816 parentDataIds = self.randomDataIds(n=1)
817 split1 = self.splitByStateFlags(parentDataIds.subset(group1))
818 split2 = self.splitByStateFlags(parentDataIds.subset(group2))
819 (parentDataId,) = parentDataIds
820 for lhs, rhs in itertools.product(split1.chain(), split2.chain()):
821 unioned = lhs.union(rhs)
822 with self.subTest(lhs=lhs, rhs=rhs, unioned=unioned):
823 self.assertEqual(unioned.dimensions, group1.union(group2))
824 self.assertEqual(unioned, parentDataId.subset(unioned.dimensions))
825 if unioned.hasFull():
826 self.assertEqual(unioned.subset(lhs.dimensions), lhs)
827 self.assertEqual(unioned.subset(rhs.dimensions), rhs)
828 if lhs.hasFull() and rhs.hasFull():
829 self.assertTrue(unioned.hasFull())
830 if lhs.dimensions >= unioned.dimensions and lhs.hasFull():
831 self.assertTrue(unioned.hasFull())
832 if lhs.hasRecords():
833 self.assertTrue(unioned.hasRecords())
834 if rhs.dimensions >= unioned.dimensions and rhs.hasFull():
835 self.assertTrue(unioned.hasFull())
836 if rhs.hasRecords():
837 self.assertTrue(unioned.hasRecords())
838 if lhs.dimensions.required | rhs.dimensions.required >= unioned.dimensions.names:
839 self.assertTrue(unioned.hasFull())
840 if (
841 lhs.hasRecords()
842 and rhs.hasRecords()
843 and lhs.dimensions.elements | rhs.dimensions.elements >= unioned.dimensions.elements
844 ):
845 self.assertTrue(unioned.hasRecords())
847 def testRegions(self):
848 """Test that data IDs for a few known dimensions have the expected
849 regions.
850 """
851 for dataId in self.randomDataIds(n=4).subset(self.allDataIds.universe.conform(["visit"])):
852 self.assertIsNotNone(dataId.region)
853 self.assertEqual(dataId.dimensions.spatial.names, {"observation_regions"})
854 self.assertEqual(dataId.region, dataId.records["visit"].region)
855 for dataId in self.randomDataIds(n=4).subset(self.allDataIds.universe.conform(["visit", "detector"])):
856 self.assertIsNotNone(dataId.region)
857 self.assertEqual(dataId.dimensions.spatial.names, {"observation_regions"})
858 self.assertEqual(dataId.region, dataId.records["visit_detector_region"].region)
859 for dataId in self.randomDataIds(n=4).subset(self.allDataIds.universe.conform(["tract"])):
860 self.assertIsNotNone(dataId.region)
861 self.assertEqual(dataId.dimensions.spatial.names, {"skymap_regions"})
862 self.assertEqual(dataId.region, dataId.records["tract"].region)
863 for dataId in self.randomDataIds(n=4).subset(self.allDataIds.universe.conform(["patch"])):
864 self.assertIsNotNone(dataId.region)
865 self.assertEqual(dataId.dimensions.spatial.names, {"skymap_regions"})
866 self.assertEqual(dataId.region, dataId.records["patch"].region)
867 for data_id in self.randomDataIds(n=1).subset(self.allDataIds.universe.conform(["visit", "tract"])):
868 self.assertEqual(data_id.region.relate(data_id.records["visit"].region), lsst.sphgeom.WITHIN)
869 self.assertEqual(data_id.region.relate(data_id.records["tract"].region), lsst.sphgeom.WITHIN)
871 def testTimespans(self):
872 """Test that data IDs for a few known dimensions have the expected
873 timespans.
874 """
875 for dataId in self.randomDataIds(n=4).subset(self.allDataIds.universe.conform(["visit"])):
876 self.assertIsNotNone(dataId.timespan)
877 self.assertEqual(dataId.dimensions.temporal.names, {"observation_timespans"})
878 self.assertEqual(dataId.timespan, dataId.records["visit"].timespan)
879 self.assertEqual(dataId.timespan, dataId.visit.timespan)
880 # Also test the case for non-temporal DataIds.
881 for dataId in self.randomDataIds(n=4).subset(self.allDataIds.universe.conform(["patch"])):
882 self.assertIsNone(dataId.timespan)
884 def testIterableStatusFlags(self):
885 """Test that DataCoordinateSet and DataCoordinateSequence compute
886 their hasFull and hasRecords flags correctly from their elements.
887 """
888 dataIds = self.randomDataIds(n=10)
889 split = self.splitByStateFlags(dataIds)
890 for cls in (DataCoordinateSet, DataCoordinateSequence):
891 self.assertTrue(cls(split.expanded, dimensions=dataIds.dimensions, check=True).hasFull())
892 self.assertTrue(cls(split.expanded, dimensions=dataIds.dimensions, check=False).hasFull())
893 self.assertTrue(cls(split.expanded, dimensions=dataIds.dimensions, check=True).hasRecords())
894 self.assertTrue(cls(split.expanded, dimensions=dataIds.dimensions, check=False).hasRecords())
895 self.assertTrue(cls(split.complete, dimensions=dataIds.dimensions, check=True).hasFull())
896 self.assertTrue(cls(split.complete, dimensions=dataIds.dimensions, check=False).hasFull())
897 self.assertFalse(cls(split.complete, dimensions=dataIds.dimensions, check=True).hasRecords())
898 self.assertFalse(cls(split.complete, dimensions=dataIds.dimensions, check=False).hasRecords())
899 with self.assertRaises(ValueError):
900 cls(split.complete, dimensions=dataIds.dimensions, hasRecords=True, check=True)
901 self.assertEqual(
902 cls(split.minimal, dimensions=dataIds.dimensions, check=True).hasFull(),
903 not dataIds.dimensions.implied,
904 )
905 self.assertEqual(
906 cls(split.minimal, dimensions=dataIds.dimensions, check=False).hasFull(),
907 not dataIds.dimensions.implied,
908 )
909 self.assertFalse(cls(split.minimal, dimensions=dataIds.dimensions, check=True).hasRecords())
910 self.assertFalse(cls(split.minimal, dimensions=dataIds.dimensions, check=False).hasRecords())
911 with self.assertRaises(ValueError):
912 cls(split.minimal, dimensions=dataIds.dimensions, hasRecords=True, check=True)
913 if dataIds.dimensions.implied:
914 with self.assertRaises(ValueError):
915 cls(split.minimal, dimensions=dataIds.dimensions, hasFull=True, check=True)
917 def testSetOperations(self):
918 """Test for self-consistency across DataCoordinateSet's operations."""
919 c = self.randomDataIds(n=10).toSet()
920 a = self.randomDataIds(n=20).toSet() | c
921 b = self.randomDataIds(n=20).toSet() | c
922 # Make sure we don't have a particularly unlucky random seed, since
923 # that would make a lot of this test uninteresting.
924 self.assertNotEqual(a, b)
925 self.assertGreater(len(a), 0)
926 self.assertGreater(len(b), 0)
927 # The rest of the tests should not depend on the random seed.
928 self.assertEqual(a, a)
929 self.assertNotEqual(a, a.toSequence())
930 self.assertEqual(a, a.toSequence().toSet())
931 self.assertEqual(a, a.toSequence().toSet())
932 self.assertEqual(b, b)
933 self.assertNotEqual(b, b.toSequence())
934 self.assertEqual(b, b.toSequence().toSet())
935 self.assertEqual(a & b, a.intersection(b))
936 self.assertLessEqual(a & b, a)
937 self.assertLessEqual(a & b, b)
938 self.assertEqual(a | b, a.union(b))
939 self.assertGreaterEqual(a | b, a)
940 self.assertGreaterEqual(a | b, b)
941 self.assertEqual(a - b, a.difference(b))
942 self.assertLessEqual(a - b, a)
943 self.assertLessEqual(b - a, b)
944 self.assertEqual(a ^ b, a.symmetric_difference(b))
945 self.assertGreaterEqual(a ^ b, (a | b) - (a & b))
947 def testPackers(self):
948 (instrument_data_id,) = self.allDataIds.subset(
949 self.allDataIds.universe.conform(["instrument"])
950 ).toSet()
951 (detector_data_id,) = self.randomDataIds(n=1).subset(self.allDataIds.universe.conform(["detector"]))
952 packer = ConcreteTestDimensionPacker(instrument_data_id, detector_data_id.dimensions)
953 packed_id, max_bits = packer.pack(detector_data_id, returnMaxBits=True)
954 self.assertEqual(packed_id, detector_data_id["detector"])
955 self.assertEqual(max_bits, packer.maxBits)
956 self.assertEqual(
957 max_bits, math.ceil(math.log2(instrument_data_id.records["instrument"].detector_max))
958 )
959 self.assertEqual(packer.pack(detector_data_id), packed_id)
960 self.assertEqual(packer.pack(detector=detector_data_id["detector"]), detector_data_id["detector"])
961 self.assertEqual(packer.unpack(packed_id), detector_data_id)
963 def test_dimension_group_pydantic(self):
964 """Test that DimensionGroup round-trips through Pydantic as long as
965 it's given the universe when validated.
966 """
967 dimensions = self.allDataIds.dimensions
968 adapter = pydantic.TypeAdapter(DimensionGroup)
969 json_str = adapter.dump_json(dimensions)
970 python_data = adapter.dump_python(dimensions)
971 self.assertEqual(
972 dimensions, adapter.validate_json(json_str, context=dict(universe=dimensions.universe))
973 )
974 self.assertEqual(
975 dimensions, adapter.validate_python(python_data, context=dict(universe=dimensions.universe))
976 )
977 self.assertEqual(dimensions, adapter.validate_python(dimensions))
979 def test_dimension_element_pydantic(self):
980 """Test that DimensionElement round-trips through Pydantic as long as
981 it's given the universe when validated.
982 """
983 element = self.allDataIds.universe["visit"]
984 adapter = pydantic.TypeAdapter(DimensionElement)
985 json_str = adapter.dump_json(element)
986 python_data = adapter.dump_python(element)
987 self.assertEqual(element, adapter.validate_json(json_str, context=dict(universe=element.universe)))
988 self.assertEqual(
989 element, adapter.validate_python(python_data, context=dict(universe=element.universe))
990 )
991 self.assertEqual(element, adapter.validate_python(element))
994if __name__ == "__main__":
995 unittest.main()