Coverage for python/lsst/dax/apdb/cassandra/partitioner.py: 91%
84 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 02:17 -0700
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-19 02:17 -0700
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__ = ["Partitioner"]
26from typing import Any, overload
28import astropy.time
30from lsst import sphgeom
32from ..apdbSchema import ApdbTables
33from ..pixelization import Pixelization
34from .config import ApdbCassandraConfig, ApdbCassandraTimePartitionRange
35from .queries import Column as C # noqa: N817
36from .queries import QExpr
39class Partitioner:
40 """Logic for temporal and spatial partitioning of APDB tables.
42 Parameters
43 ----------
44 config : `ApdbCassandraConfig`
45 Configuration object.
46 """
48 partition_zero_epoch = astropy.time.Time(0, format="unix_tai")
49 """Start time for partition 0, this should never be changed."""
51 def __init__(self, config: ApdbCassandraConfig):
52 self._config = config
53 self.pixelization = Pixelization(
54 config.partitioning.part_pixelization,
55 config.partitioning.part_pix_level,
56 config.partitioning.part_pix_max_ranges,
57 )
58 self._epoch = float(self.partition_zero_epoch.mjd)
60 @overload
61 def pixel(self, direction: sphgeom.UnitVector3d, /) -> int: ... 61 ↛ exitline 61 didn't return from function 'pixel' because
63 @overload
64 def pixel(self, ra: float, dec: float, /) -> int: ... 64 ↛ exitline 64 didn't return from function 'pixel' because
66 def pixel(self, *args: Any) -> int:
67 """Compute the index of the pixel for given direction.
69 Parameters
70 ----------
71 args
72 The method can take either a single `lsst.sphgeom.UnitVector3d` or
73 a pair of floating point numbers (or values convertible to floats)
74 representing RA and Dec in degrees.
76 Returns
77 -------
78 pixel : `int`
79 Pixel index.
80 """
81 return self.pixelization.pixel(*args)
83 def time_partition(self, time: float | astropy.time.Time) -> int:
84 """Calculate time partition number for a given time.
86 Parameters
87 ----------
88 time : `float` or `astropy.time.Time`
89 Time for which to calculate partition number. Can be float to mean
90 MJD or `astropy.time.Time`
92 Returns
93 -------
94 partition : `int`
95 Partition number for a given time.
96 """
97 if isinstance(time, astropy.time.Time):
98 mjd = float(time.mjd)
99 else:
100 mjd = time
101 days_since_epoch = mjd - self._epoch
102 partition = int(days_since_epoch) // self._config.partitioning.time_partition_days
103 return partition
105 def partition_period(self, time_partition: int) -> tuple[astropy.time.Time, astropy.time.Time]:
106 """Return time period for specified time partition.
108 Parameters
109 ----------
110 time_partition : `int`
111 Time partition.
113 Returns
114 -------
115 start : `astropy.time.Time`
116 Start of the period, inclusive boundary.
117 end : `astropy.time.Time`
118 Start of the period, exclusive boundary.
119 """
120 partition_days = self._config.partitioning.time_partition_days
121 start_mjd = self._epoch + partition_days * time_partition
122 end_mjd = self._epoch + partition_days * (time_partition + 1)
123 start = astropy.time.Time(start_mjd, format="mjd", scale="tai")
124 end = astropy.time.Time(end_mjd, format="mjd", scale="tai")
125 return (start, end)
127 def spatial_where(
128 self, region: sphgeom.Region | None, *, use_ranges: bool = False
129 ) -> tuple[list[QExpr], int]:
130 """Generate expressions for spatial part of WHERE clause.
132 Parameters
133 ----------
134 region : `sphgeom.Region`
135 Spatial region for query results.
136 use_ranges : `bool`, optional
137 If True then use pixel ranges ("apdb_part >= p1 AND apdb_part <=
138 p2") instead of exact list of pixels. Should be set to True for
139 large regions covering very many pixels.
141 Returns
142 -------
143 expressions : `list` [ `QExpr` ]
144 Empty list is returned if ``region`` is `None`, otherwise a list
145 of one or more `QExpr`.
146 partition_count : `int`
147 Number of spatial partitions in the result.
148 """
149 if region is None: 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true
150 return [], 0
152 count = 0
153 expressions: list[QExpr] = []
154 if use_ranges:
155 pixel_ranges = self.pixelization.envelope(region)
156 for lower, upper in pixel_ranges:
157 upper -= 1
158 if lower == upper: 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true
159 expressions.append(C("apdb_part") == lower)
160 count += 1
161 elif lower + 1 == upper: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true
162 expressions.append(C("apdb_part") == lower)
163 expressions.append(C("apdb_part") == upper)
164 count += 2
165 else:
166 count += upper - lower + 1
167 expressions.append((C("apdb_part") >= lower) & (C("apdb_part") <= upper))
168 else:
169 pixels = self.pixelization.pixels(region)
170 count = len(pixels)
171 if self._config.partitioning.query_per_spatial_part:
172 expressions.extend((C("apdb_part") == pixel) for pixel in pixels)
173 else:
174 # If the are many pixels then don't prepare statements.
175 can_prepare = len(pixels) <= 3
176 expressions.append(C("apdb_part").in_(pixels, can_prepare=can_prepare))
178 return expressions, count
180 def temporal_where(
181 self,
182 table: ApdbTables,
183 start_time: float | astropy.time.Time,
184 end_time: float | astropy.time.Time,
185 *,
186 query_per_time_part: bool | None = None,
187 partitons_range: ApdbCassandraTimePartitionRange | None = None,
188 ) -> tuple[list[str], list[QExpr]]:
189 """Generate table names and expressions for temporal part of WHERE
190 clauses.
192 Parameters
193 ----------
194 table : `ApdbTables`
195 Table to select from.
196 start_time : `astropy.time.Time` or `float`
197 Starting Datetime of MJD value of the time range.
198 end_time : `astropy.time.Time` or `float`
199 Starting Datetime of MJD value of the time range.
200 query_per_time_part : `bool`, optional
201 If None then use ``query_per_time_part`` from configuration.
202 partitons_range : `ApdbCassandraTimePartitionRange` or `None`
203 Partitions range to further restrict time range.
205 Returns
206 -------
207 tables : `list` [ `str` ]
208 List of the table names to query. Empty list is returned when time
209 range does not overlap ``partitons_range``.
210 expressions : `list` [ `QExpr` ]
211 A list of zero or more `QExpr` instances.
212 """
213 tables: list[str]
214 temporal_where: list[QExpr] = []
215 # First and last partition.
216 time_part_start = self.time_partition(start_time)
217 time_part_end = self.time_partition(end_time)
218 if partitons_range:
219 # Check for non-overlapping ranges.
220 if time_part_start > partitons_range.end or time_part_end < partitons_range.start:
221 return [], []
222 if time_part_start < partitons_range.start:
223 time_part_start = partitons_range.start
224 if time_part_end > partitons_range.end:
225 time_part_end = partitons_range.end
226 # Inclusive range.
227 time_parts = list(range(time_part_start, time_part_end + 1))
228 if self._config.partitioning.time_partition_tables:
229 tables = [table.table_name(self._config.prefix, part) for part in time_parts]
230 else:
231 tables = [table.table_name(self._config.prefix)]
232 if query_per_time_part is None:
233 query_per_time_part = self._config.partitioning.query_per_time_part
234 if query_per_time_part:
235 temporal_where = [QExpr('"apdb_time_part" = {}', (time_part,)) for time_part in time_parts]
236 else:
237 # If the are many partitions then don't prepare statements.
238 can_prepare = len(time_parts) <= 3
239 temporal_where = [C("apdb_time_part").in_(time_parts, can_prepare=can_prepare)]
241 return tables, temporal_where