Coverage for python/lsst/daf/butler/core/dimensions/_coordinate.py : 26%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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 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/>.
22#
23# Design notes for this module are in
24# doc/lsst.daf.butler/dev/dataCoordinate.py.
25#
27from __future__ import annotations
29__all__ = ("DataCoordinate", "DataId", "DataIdKey", "DataIdValue")
31from abc import abstractmethod
32import numbers
33from typing import (
34 AbstractSet,
35 Any,
36 Dict,
37 Iterator,
38 Mapping,
39 Optional,
40 Tuple,
41 TYPE_CHECKING,
42 Union,
43)
45from lsst.sphgeom import Region
46from ..named import NamedKeyDict, NamedKeyMapping, NameLookupMapping, NamedValueAbstractSet
47from ..timespan import Timespan
48from ._elements import Dimension, DimensionElement
49from ._graph import DimensionGraph
50from ._records import DimensionRecord
52if TYPE_CHECKING: # Imports needed only for type annotations; may be circular. 52 ↛ 53line 52 didn't jump to line 53, because the condition on line 52 was never true
53 from ._universe import DimensionUniverse
55DataIdKey = Union[str, Dimension]
56"""Type annotation alias for the keys that can be used to index a
57DataCoordinate.
58"""
60DataIdValue = Union[str, int, None]
61"""Type annotation alias for the values that can be present in a
62DataCoordinate or other data ID.
63"""
66def _intersectRegions(*args: Region) -> Optional[Region]:
67 """Return the intersection of several regions.
69 For internal use by `ExpandedDataCoordinate` only.
71 If no regions are provided, returns `None`.
73 This is currently a placeholder; it actually returns `NotImplemented`
74 (it does *not* raise an exception) when multiple regions are given, which
75 propagates to `ExpandedDataCoordinate`. This reflects the fact that we
76 don't want to fail to construct an `ExpandedDataCoordinate` entirely when
77 we can't compute its region, and at present we don't have a high-level use
78 case for the regions of these particular data IDs.
79 """
80 if len(args) == 0:
81 return None
82 elif len(args) == 1:
83 return args[0]
84 else:
85 return NotImplemented
88class DataCoordinate(NamedKeyMapping[Dimension, DataIdValue]):
89 """An immutable data ID dictionary that guarantees that its key-value pairs
90 identify at least all required dimensions in a `DimensionGraph`.
92 `DataCoordinateSet` itself is an ABC, but provides `staticmethod` factory
93 functions for private concrete implementations that should be sufficient
94 for most purposes. `standardize` is the most flexible and safe of these;
95 the others (`makeEmpty`, `fromRequiredValues`, and `fromFullValues`) are
96 more specialized and perform little or no checking of inputs.
98 Notes
99 -----
100 Like any data ID class, `DataCoordinate` behaves like a dictionary, but
101 with some subtleties:
103 - Both `Dimension` instances and `str` names thereof may be used as keys
104 in lookup operations, but iteration (and `keys`) will yield `Dimension`
105 instances. The `names` property can be used to obtain the corresponding
106 `str` names.
108 - Lookups for implied dimensions (those in ``self.graph.implied``) are
109 supported if and only if `hasFull` returns `True`, and are never
110 included in iteration or `keys`. The `full` property may be used to
111 obtain a mapping whose keys do include implied dimensions.
113 - Equality comparison with other mappings is supported, but it always
114 considers only required dimensions (as well as requiring both operands
115 to identify the same dimensions). This is not quite consistent with the
116 way mappings usually work - normally differing keys imply unequal
117 mappings - but it makes sense in this context because data IDs with the
118 same values for required dimensions but different values for implied
119 dimensions represent a serious problem with the data that
120 `DataCoordinate` cannot generally recognize on its own, and a data ID
121 that knows implied dimension values should still be able to compare as
122 equal to one that does not. This is of course not the way comparisons
123 between simple `dict` data IDs work, and hence using a `DataCoordinate`
124 instance for at least one operand in any data ID comparison is strongly
125 recommended.
126 """
128 __slots__ = ()
130 @staticmethod
131 def standardize(
132 mapping: Optional[NameLookupMapping[Dimension, DataIdValue]] = None,
133 *,
134 graph: Optional[DimensionGraph] = None,
135 universe: Optional[DimensionUniverse] = None,
136 defaults: Optional[DataCoordinate] = None,
137 **kwargs: Any
138 ) -> DataCoordinate:
139 """Adapt an arbitrary mapping and/or additional arguments into a true
140 `DataCoordinate`, or augment an existing one.
142 Parameters
143 ----------
144 mapping : `~collections.abc.Mapping`, optional
145 An informal data ID that maps dimensions or dimension names to
146 their primary key values (may also be a true `DataCoordinate`).
147 graph : `DimensionGraph`
148 The dimensions to be identified by the new `DataCoordinate`.
149 If not provided, will be inferred from the keys of ``mapping`` and
150 ``**kwargs``, and ``universe`` must be provided unless ``mapping``
151 is already a `DataCoordinate`.
152 universe : `DimensionUniverse`
153 All known dimensions and their relationships; used to expand
154 and validate dependencies when ``graph`` is not provided.
155 defaults : `DataCoordinate`, optional
156 Default dimension key-value pairs to use when needed. These are
157 never used to infer ``graph``, and are ignored if a different value
158 is provided for the same key in ``mapping`` or `**kwargs``.
159 **kwargs
160 Additional keyword arguments are treated like additional key-value
161 pairs in ``mapping``.
163 Returns
164 -------
165 coordinate : `DataCoordinate`
166 A validated `DataCoordinate` instance.
168 Raises
169 ------
170 TypeError
171 Raised if the set of optional arguments provided is not supported.
172 KeyError
173 Raised if a key-value pair for a required dimension is missing.
174 """
175 d: Dict[str, DataIdValue] = {}
176 if isinstance(mapping, DataCoordinate):
177 if graph is None:
178 if not kwargs:
179 # Already standardized to exactly what we want.
180 return mapping
181 elif kwargs.keys().isdisjoint(graph.dimensions.names):
182 # User provided kwargs, but told us not to use them by
183 # passing in dimensions that are disjoint from those kwargs.
184 # This is not necessarily user error - it's a useful pattern
185 # to pass in all of the key-value pairs you have and let the
186 # code here pull out only what it needs.
187 return mapping.subset(graph)
188 assert universe is None or universe == mapping.universe
189 universe = mapping.universe
190 d.update((name, mapping[name]) for name in mapping.graph.required.names)
191 if mapping.hasFull():
192 d.update((name, mapping[name]) for name in mapping.graph.implied.names)
193 elif isinstance(mapping, NamedKeyMapping):
194 d.update(mapping.byName())
195 elif mapping is not None:
196 d.update(mapping)
197 d.update(kwargs)
198 if graph is None:
199 if defaults is not None:
200 universe = defaults.universe
201 elif universe is None:
202 raise TypeError("universe must be provided if graph is not.")
203 graph = DimensionGraph(universe, names=d.keys())
204 if not graph.dimensions:
205 return DataCoordinate.makeEmpty(graph.universe)
206 if defaults is not None:
207 if defaults.hasFull():
208 for k, v in defaults.full.items():
209 d.setdefault(k.name, v)
210 else:
211 for k, v in defaults.items():
212 d.setdefault(k.name, v)
213 if d.keys() >= graph.dimensions.names:
214 values = tuple(d[name] for name in graph._dataCoordinateIndices.keys())
215 else:
216 try:
217 values = tuple(d[name] for name in graph.required.names)
218 except KeyError as err:
219 raise KeyError(f"No value in data ID ({mapping}) for required dimension {err}.") from err
220 # Some backends cannot handle numpy.int64 type which is a subclass of
221 # numbers.Integral; convert that to int.
222 values = tuple(int(val) if isinstance(val, numbers.Integral) # type: ignore
223 else val for val in values)
224 return _BasicTupleDataCoordinate(graph, values)
226 @staticmethod
227 def makeEmpty(universe: DimensionUniverse) -> DataCoordinate:
228 """Return an empty `DataCoordinate` that identifies the null set of
229 dimensions.
231 Parameters
232 ----------
233 universe : `DimensionUniverse`
234 Universe to which this null dimension set belongs.
236 Returns
237 -------
238 dataId : `DataCoordinate`
239 A data ID object that identifies no dimensions. `hasFull` and
240 `hasRecords` are guaranteed to return `True`, because both `full`
241 and `records` are just empty mappings.
242 """
243 return _ExpandedTupleDataCoordinate(universe.empty, (), {})
245 @staticmethod
246 def fromRequiredValues(graph: DimensionGraph, values: Tuple[DataIdValue, ...]) -> DataCoordinate:
247 """Construct a `DataCoordinate` from a tuple of dimension values that
248 identify only required dimensions.
250 This is a low-level interface with at most assertion-level checking of
251 inputs. Most callers should use `standardize` instead.
253 Parameters
254 ----------
255 graph : `DimensionGraph`
256 Dimensions this data ID will identify.
257 values : `tuple` [ `int` or `str` ]
258 Tuple of primary key values corresponding to ``graph.required``,
259 in that order.
261 Returns
262 -------
263 dataId : `DataCoordinate`
264 A data ID object that identifies the given dimensions.
265 ``dataId.hasFull()`` will return `True` if and only if
266 ``graph.implied`` is empty, and ``dataId.hasRecords()`` will never
267 return `True`.
268 """
269 assert len(graph.required) == len(values), \
270 f"Inconsistency between dimensions {graph.required} and required values {values}."
271 return _BasicTupleDataCoordinate(graph, values)
273 @staticmethod
274 def fromFullValues(graph: DimensionGraph, values: Tuple[DataIdValue, ...]) -> DataCoordinate:
275 """Construct a `DataCoordinate` from a tuple of dimension values that
276 identify all dimensions.
278 This is a low-level interface with at most assertion-level checking of
279 inputs. Most callers should use `standardize` instead.
281 Parameters
282 ----------
283 graph : `DimensionGraph`
284 Dimensions this data ID will identify.
285 values : `tuple` [ `int` or `str` ]
286 Tuple of primary key values corresponding to
287 ``itertools.chain(graph.required, graph.implied)``, in that order.
288 Note that this is _not_ the same order as ``graph.dimensions``,
289 though these contain the same elements.
291 Returns
292 -------
293 dataId : `DataCoordinate`
294 A data ID object that identifies the given dimensions.
295 ``dataId.hasFull()`` will return `True` if and only if
296 ``graph.implied`` is empty, and ``dataId.hasRecords()`` will never
297 return `True`.
298 """
299 assert len(graph.dimensions) == len(values), \
300 f"Inconsistency between dimensions {graph.dimensions} and full values {values}."
301 return _BasicTupleDataCoordinate(graph, values)
303 def __hash__(self) -> int:
304 return hash((self.graph,) + tuple(self[d.name] for d in self.graph.required))
306 def __eq__(self, other: Any) -> bool:
307 if not isinstance(other, DataCoordinate):
308 other = DataCoordinate.standardize(other, universe=self.universe)
309 return self.graph == other.graph and all(self[d.name] == other[d.name] for d in self.graph.required)
311 def __repr__(self) -> str:
312 # We can't make repr yield something that could be exec'd here without
313 # printing out the whole DimensionUniverse the graph is derived from.
314 # So we print something that mostly looks like a dict, but doesn't
315 # quote its keys: that's both more compact and something that can't
316 # be mistaken for an actual dict or something that could be exec'd.
317 terms = [f"{d}: {self[d]!r}" for d in self.graph.required.names]
318 if self.hasFull() and self.graph.required != self.graph.dimensions:
319 terms.append("...")
320 return "{{{}}}".format(', '.join(terms))
322 def __lt__(self, other: Any) -> bool:
323 # Allow DataCoordinate to be sorted
324 if not isinstance(other, type(self)):
325 return NotImplemented
326 # Form tuple of tuples for each DataCoordinate:
327 # Unlike repr() we only use required keys here to ensure that
328 # __eq__ can not be true simultaneously with __lt__ being true.
329 self_kv = tuple(self.items())
330 other_kv = tuple(other.items())
332 return self_kv < other_kv
334 def __iter__(self) -> Iterator[Dimension]:
335 return iter(self.keys())
337 def __len__(self) -> int:
338 return len(self.keys())
340 def keys(self) -> NamedValueAbstractSet[Dimension]:
341 return self.graph.required
343 @property
344 def names(self) -> AbstractSet[str]:
345 """The names of the required dimensions identified by this data ID, in
346 the same order as `keys` (`collections.abc.Set` [ `str` ]).
347 """
348 return self.keys().names
350 @abstractmethod
351 def subset(self, graph: DimensionGraph) -> DataCoordinate:
352 """Return a `DataCoordinate` whose graph is a subset of ``self.graph``.
354 Parameters
355 ----------
356 graph : `DimensionGraph`
357 The dimensions identified by the returned `DataCoordinate`.
359 Returns
360 -------
361 coordinate : `DataCoordinate`
362 A `DataCoordinate` instance that identifies only the given
363 dimensions. May be ``self`` if ``graph == self.graph``.
365 Raises
366 ------
367 KeyError
368 Raised if the primary key value for one or more required dimensions
369 is unknown. This may happen if ``graph.issubset(self.graph)`` is
370 `False`, or even if ``graph.issubset(self.graph)`` is `True`, if
371 ``self.hasFull()`` is `False` and
372 ``graph.required.issubset(self.graph.required)`` is `False`. As
373 an example of the latter case, consider trying to go from a data ID
374 with dimensions {instrument, physical_filter, band} to
375 just {instrument, band}; band is implied by
376 physical_filter and hence would have no value in the original data
377 ID if ``self.hasFull()`` is `False`.
379 Notes
380 -----
381 If `hasFull` and `hasRecords` return `True` on ``self``, they will
382 return `True` (respectively) on the returned `DataCoordinate` as well.
383 The converse does not hold.
384 """
385 raise NotImplementedError()
387 @abstractmethod
388 def union(self, other: DataCoordinate) -> DataCoordinate:
389 """Combine two data IDs, yielding a new one that identifies all
390 dimensions that either of them identify.
392 Parameters
393 ----------
394 other : `DataCoordinate`
395 Data ID to combine with ``self``.
397 Returns
398 -------
399 unioned : `DataCoordinate`
400 A `DataCoordinate` instance that satisfies
401 ``unioned.graph == self.graph.union(other.graph)``. Will preserve
402 ``hasFull`` and ``hasRecords`` whenever possible.
404 Notes
405 -----
406 No checking for consistency is performed on values for keys that
407 ``self`` and ``other`` have in common, and which value is included in
408 the returned data ID is not specified.
409 """
410 raise NotImplementedError()
412 @abstractmethod
413 def expanded(self, records: NameLookupMapping[DimensionElement, Optional[DimensionRecord]]
414 ) -> DataCoordinate:
415 """Return a `DataCoordinate` that holds the given records and
416 guarantees that `hasRecords` returns `True`.
418 This is a low-level interface with at most assertion-level checking of
419 inputs. Most callers should use `Registry.expandDataId` instead.
421 Parameters
422 ----------
423 records : `Mapping` [ `str`, `DimensionRecord` or `None` ]
424 A `NamedKeyMapping` with `DimensionElement` keys or a regular
425 `Mapping` with `str` (`DimensionElement` name) keys and
426 `DimensionRecord` values. Keys must cover all elements in
427 ``self.graph.elements``. Values may be `None`, but only to reflect
428 actual NULL values in the database, not just records that have not
429 been fetched.
430 """
431 raise NotImplementedError()
433 @property
434 def universe(self) -> DimensionUniverse:
435 """The universe that defines all known dimensions compatible with
436 this coordinate (`DimensionUniverse`).
437 """
438 return self.graph.universe
440 @property
441 @abstractmethod
442 def graph(self) -> DimensionGraph:
443 """The dimensions identified by this data ID (`DimensionGraph`).
445 Note that values are only required to be present for dimensions in
446 ``self.graph.required``; all others may be retrieved (from a
447 `Registry`) given these.
448 """
449 raise NotImplementedError()
451 @abstractmethod
452 def hasFull(self) -> bool:
453 """Whether this data ID contains values for implied as well as
454 required dimensions.
456 Returns
457 -------
458 state : `bool`
459 If `True`, `__getitem__`, `get`, and `__contains__` (but not
460 `keys`!) will act as though the mapping includes key-value pairs
461 for implied dimensions, and the `full` property may be used. If
462 `False`, these operations only include key-value pairs for required
463 dimensions, and accessing `full` is an error. Always `True` if
464 there are no implied dimensions.
465 """
466 raise NotImplementedError()
468 @property
469 def full(self) -> NamedKeyMapping[Dimension, DataIdValue]:
470 """A mapping that includes key-value pairs for all dimensions in
471 ``self.graph``, including implied (`NamedKeyMapping`).
473 Accessing this attribute if `hasFull` returns `False` is a logic error
474 that may raise an exception of unspecified type either immediately or
475 when implied keys are accessed via the returned mapping, depending on
476 the implementation and whether assertions are enabled.
477 """
478 assert self.hasFull(), "full may only be accessed if hasRecords() returns True."
479 return _DataCoordinateFullView(self)
481 @abstractmethod
482 def hasRecords(self) -> bool:
483 """Whether this data ID contains records for all of the dimension
484 elements it identifies.
486 Returns
487 -------
488 state : `bool`
489 If `True`, the following attributes may be accessed:
491 - `records`
492 - `region`
493 - `timespan`
494 - `pack`
496 If `False`, accessing any of these is considered a logic error.
497 """
498 raise NotImplementedError()
500 @property
501 def records(self) -> NamedKeyMapping[DimensionElement, Optional[DimensionRecord]]:
502 """A mapping that contains `DimensionRecord` objects for all elements
503 identified by this data ID (`NamedKeyMapping`).
505 The values of this mapping may be `None` if and only if there is no
506 record for that element with these dimensions in the database (which
507 means some foreign key field must have a NULL value).
509 Accessing this attribute if `hasRecords` returns `False` is a logic
510 error that may raise an exception of unspecified type either
511 immediately or when the returned mapping is used, depending on the
512 implementation and whether assertions are enabled.
513 """
514 assert self.hasRecords(), "records may only be accessed if hasRecords() returns True."
515 return _DataCoordinateRecordsView(self)
517 @abstractmethod
518 def _record(self, name: str) -> Optional[DimensionRecord]:
519 """Protected implementation hook that backs the ``records`` attribute.
521 Parameters
522 ----------
523 name : `str`
524 The name of a `DimensionElement`, guaranteed to be in
525 ``self.graph.elements.names``.
527 Returns
528 -------
529 record : `DimensionRecord` or `None`
530 The dimension record for the given element identified by this
531 data ID, or `None` if there is no such record.
532 """
533 raise NotImplementedError()
535 @property
536 def region(self) -> Optional[Region]:
537 """The spatial region associated with this data ID
538 (`lsst.sphgeom.Region` or `None`).
540 This is `None` if and only if ``self.graph.spatial`` is empty.
542 Accessing this attribute if `hasRecords` returns `False` is a logic
543 error that may or may not raise an exception, depending on the
544 implementation and whether assertions are enabled.
545 """
546 assert self.hasRecords(), "region may only be accessed if hasRecords() returns True."
547 regions = []
548 for family in self.graph.spatial:
549 element = family.choose(self.graph.elements)
550 record = self._record(element.name)
551 if record is None or record.region is None:
552 return None
553 else:
554 regions.append(record.region)
555 return _intersectRegions(*regions)
557 @property
558 def timespan(self) -> Optional[Timespan]:
559 """The temporal interval associated with this data ID
560 (`Timespan` or `None`).
562 This is `None` if and only if ``self.graph.timespan`` is empty.
564 Accessing this attribute if `hasRecords` returns `False` is a logic
565 error that may or may not raise an exception, depending on the
566 implementation and whether assertions are enabled.
567 """
568 assert self.hasRecords(), "timespan may only be accessed if hasRecords() returns True."
569 timespans = []
570 for family in self.graph.temporal:
571 element = family.choose(self.graph.elements)
572 record = self._record(element.name)
573 # DimensionRecord subclasses for temporal elements always have
574 # .timespan, but they're dynamic so this can't be type-checked.
575 if record is None or record.timespan is None:
576 return None
577 else:
578 timespans.append(record.timespan)
579 return Timespan.intersection(*timespans)
581 def pack(self, name: str, *, returnMaxBits: bool = False) -> Union[Tuple[int, int], int]:
582 """Pack this data ID into an integer.
584 Parameters
585 ----------
586 name : `str`
587 Name of the `DimensionPacker` algorithm (as defined in the
588 dimension configuration).
589 returnMaxBits : `bool`, optional
590 If `True` (`False` is default), return the maximum number of
591 nonzero bits in the returned integer across all data IDs.
593 Returns
594 -------
595 packed : `int`
596 Integer ID. This ID is unique only across data IDs that have
597 the same values for the packer's "fixed" dimensions.
598 maxBits : `int`, optional
599 Maximum number of nonzero bits in ``packed``. Not returned unless
600 ``returnMaxBits`` is `True`.
602 Notes
603 -----
604 Accessing this attribute if `hasRecords` returns `False` is a logic
605 error that may or may not raise an exception, depending on the
606 implementation and whether assertions are enabled.
607 """
608 assert self.hasRecords(), "pack() may only be called if hasRecords() returns True."
609 return self.universe.makePacker(name, self).pack(self, returnMaxBits=returnMaxBits)
612DataId = Union[DataCoordinate, Mapping[str, Any]]
613"""A type-annotation alias for signatures that accept both informal data ID
614dictionaries and validated `DataCoordinate` instances.
615"""
618class _DataCoordinateFullView(NamedKeyMapping[Dimension, DataIdValue]):
619 """View class that provides the default implementation for
620 `DataCoordinate.full`.
622 Parameters
623 ----------
624 target : `DataCoordinate`
625 The `DataCoordinate` instance this object provides a view of.
626 """
627 def __init__(self, target: DataCoordinate):
628 self._target = target
630 __slots__ = ("_target",)
632 def __repr__(self) -> str:
633 terms = [f"{d}: {self[d]!r}" for d in self._target.graph.dimensions.names]
634 return "{{{}}}".format(', '.join(terms))
636 def __getitem__(self, key: DataIdKey) -> DataIdValue:
637 return self._target[key]
639 def __iter__(self) -> Iterator[Dimension]:
640 return iter(self.keys())
642 def __len__(self) -> int:
643 return len(self.keys())
645 def keys(self) -> NamedValueAbstractSet[Dimension]:
646 return self._target.graph.dimensions
648 @property
649 def names(self) -> AbstractSet[str]:
650 # Docstring inherited from `NamedKeyMapping`.
651 return self.keys().names
654class _DataCoordinateRecordsView(NamedKeyMapping[DimensionElement, Optional[DimensionRecord]]):
655 """View class that provides the default implementation for
656 `DataCoordinate.records`.
658 Parameters
659 ----------
660 target : `DataCoordinate`
661 The `DataCoordinate` instance this object provides a view of.
662 """
663 def __init__(self, target: DataCoordinate):
664 self._target = target
666 __slots__ = ("_target",)
668 def __repr__(self) -> str:
669 terms = [f"{d}: {self[d]!r}" for d in self._target.graph.elements.names]
670 return "{{{}}}".format(', '.join(terms))
672 def __str__(self) -> str:
673 return "\n".join(str(v) for v in self.values())
675 def __getitem__(self, key: Union[DimensionElement, str]) -> Optional[DimensionRecord]:
676 if isinstance(key, DimensionElement):
677 key = key.name
678 return self._target._record(key)
680 def __iter__(self) -> Iterator[DimensionElement]:
681 return iter(self.keys())
683 def __len__(self) -> int:
684 return len(self.keys())
686 def keys(self) -> NamedValueAbstractSet[DimensionElement]:
687 return self._target.graph.elements
689 @property
690 def names(self) -> AbstractSet[str]:
691 # Docstring inherited from `NamedKeyMapping`.
692 return self.keys().names
695class _BasicTupleDataCoordinate(DataCoordinate):
696 """Standard implementation of `DataCoordinate`, backed by a tuple of
697 values.
699 This class should only be accessed outside this module via the
700 `DataCoordinate` interface, and should only be constructed via the static
701 methods there.
703 Parameters
704 ----------
705 graph : `DimensionGraph`
706 The dimensions to be identified.
707 values : `tuple` [ `int` or `str` ]
708 Data ID values, ordered to match ``graph._dataCoordinateIndices``. May
709 include values for just required dimensions (which always come first)
710 or all dimensions.
711 """
712 def __init__(self, graph: DimensionGraph, values: Tuple[DataIdValue, ...]):
713 self._graph = graph
714 self._values = values
716 __slots__ = ("_graph", "_values")
718 @property
719 def graph(self) -> DimensionGraph:
720 # Docstring inherited from DataCoordinate.
721 return self._graph
723 def __getitem__(self, key: DataIdKey) -> DataIdValue:
724 # Docstring inherited from DataCoordinate.
725 if isinstance(key, Dimension):
726 key = key.name
727 index = self._graph._dataCoordinateIndices[key]
728 try:
729 return self._values[index]
730 except IndexError:
731 # Caller asked for an implied dimension, but this object only has
732 # values for the required ones.
733 raise KeyError(key) from None
735 def subset(self, graph: DimensionGraph) -> DataCoordinate:
736 # Docstring inherited from DataCoordinate.
737 if self._graph == graph:
738 return self
739 elif self.hasFull() or self._graph.required >= graph.dimensions:
740 return _BasicTupleDataCoordinate(
741 graph,
742 tuple(self[k] for k in graph._dataCoordinateIndices.keys()),
743 )
744 else:
745 return _BasicTupleDataCoordinate(graph, tuple(self[k] for k in graph.required.names))
747 def union(self, other: DataCoordinate) -> DataCoordinate:
748 # Docstring inherited from DataCoordinate.
749 graph = self.graph.union(other.graph)
750 # See if one or both input data IDs is already what we want to return;
751 # if so, return the most complete one we have.
752 if other.graph == graph:
753 if self.graph == graph:
754 # Input data IDs have the same graph (which is also the result
755 # graph), but may not have the same content.
756 # other might have records; self does not, so try other first.
757 # If it at least has full values, it's no worse than self.
758 if other.hasFull():
759 return other
760 else:
761 return self
762 elif other.hasFull():
763 return other
764 # There's some chance that neither self nor other has full values,
765 # but together provide enough to the union to. Let the general
766 # case below handle that.
767 elif self.graph == graph:
768 # No chance at returning records. If self has full values, it's
769 # the best we can do.
770 if self.hasFull():
771 return self
772 # General case with actual merging of dictionaries.
773 values = self.full.byName() if self.hasFull() else self.byName()
774 values.update(other.full.byName() if other.hasFull() else other.byName())
775 return DataCoordinate.standardize(values, graph=graph)
777 def expanded(self, records: NameLookupMapping[DimensionElement, Optional[DimensionRecord]]
778 ) -> DataCoordinate:
779 # Docstring inherited from DataCoordinate
780 values = self._values
781 if not self.hasFull():
782 # Extract a complete values tuple from the attributes of the given
783 # records. It's possible for these to be inconsistent with
784 # self._values (which is a serious problem, of course), but we've
785 # documented this as a no-checking API.
786 values += tuple(getattr(records[d.name], d.primaryKey.name) for d in self._graph.implied)
787 return _ExpandedTupleDataCoordinate(self._graph, values, records)
789 def hasFull(self) -> bool:
790 # Docstring inherited from DataCoordinate.
791 return len(self._values) == len(self._graph._dataCoordinateIndices)
793 def hasRecords(self) -> bool:
794 # Docstring inherited from DataCoordinate.
795 return False
797 def _record(self, name: str) -> Optional[DimensionRecord]:
798 # Docstring inherited from DataCoordinate.
799 assert False
802class _ExpandedTupleDataCoordinate(_BasicTupleDataCoordinate):
803 """A `DataCoordinate` implementation that can hold `DimensionRecord`
804 objects.
806 This class should only be accessed outside this module via the
807 `DataCoordinate` interface, and should only be constructed via calls to
808 `DataCoordinate.expanded`.
810 Parameters
811 ----------
812 graph : `DimensionGraph`
813 The dimensions to be identified.
814 values : `tuple` [ `int` or `str` ]
815 Data ID values, ordered to match ``graph._dataCoordinateIndices``.
816 May include values for just required dimensions (which always come
817 first) or all dimensions.
818 records : `Mapping` [ `str`, `DimensionRecord` or `None` ]
819 A `NamedKeyMapping` with `DimensionElement` keys or a regular
820 `Mapping` with `str` (`DimensionElement` name) keys and
821 `DimensionRecord` values. Keys must cover all elements in
822 ``self.graph.elements``. Values may be `None`, but only to reflect
823 actual NULL values in the database, not just records that have not
824 been fetched.
825 """
826 def __init__(self, graph: DimensionGraph, values: Tuple[DataIdValue, ...],
827 records: NameLookupMapping[DimensionElement, Optional[DimensionRecord]]):
828 super().__init__(graph, values)
829 assert super().hasFull(), "This implementation requires full dimension records."
830 self._records = records
832 __slots__ = ("_records",)
834 def subset(self, graph: DimensionGraph) -> DataCoordinate:
835 # Docstring inherited from DataCoordinate.
836 if self._graph == graph:
837 return self
838 return _ExpandedTupleDataCoordinate(graph,
839 tuple(self[k] for k in graph._dataCoordinateIndices.keys()),
840 records=self._records)
842 def expanded(self, records: NameLookupMapping[DimensionElement, Optional[DimensionRecord]]
843 ) -> DataCoordinate:
844 # Docstring inherited from DataCoordinate.
845 return self
847 def union(self, other: DataCoordinate) -> DataCoordinate:
848 # Docstring inherited from DataCoordinate.
849 graph = self.graph.union(other.graph)
850 # See if one or both input data IDs is already what we want to return;
851 # if so, return the most complete one we have.
852 if self.graph == graph:
853 # self has records, so even if other is also a valid result, it's
854 # no better.
855 return self
856 if other.graph == graph:
857 # If other has full values, and self does not identify some of
858 # those, it's the base we can do. It may have records, too.
859 if other.hasFull():
860 return other
861 # If other does not have full values, there's a chance self may
862 # provide the values needed to complete it. For example, self
863 # could be {band} while other could be
864 # {instrument, physical_filter, band}, with band unknown.
865 # General case with actual merging of dictionaries.
866 values = self.full.byName()
867 values.update(other.full.byName() if other.hasFull() else other.byName())
868 basic = DataCoordinate.standardize(values, graph=graph)
869 # See if we can add records.
870 if self.hasRecords() and other.hasRecords():
871 # Sometimes the elements of a union of graphs can contain elements
872 # that weren't in either input graph (because graph unions are only
873 # on dimensions). e.g. {visit} | {detector} brings along
874 # visit_detector_region.
875 elements = set(graph.elements.names)
876 elements -= self.graph.elements.names
877 elements -= other.graph.elements.names
878 if not elements:
879 records = NamedKeyDict[DimensionElement, Optional[DimensionRecord]](self.records)
880 records.update(other.records)
881 return basic.expanded(records.freeze())
882 return basic
884 def hasFull(self) -> bool:
885 # Docstring inherited from DataCoordinate.
886 return True
888 def hasRecords(self) -> bool:
889 # Docstring inherited from DataCoordinate.
890 return True
892 def _record(self, name: str) -> Optional[DimensionRecord]:
893 # Docstring inherited from DataCoordinate.
894 return self._records[name]