Coverage for python/lsst/ap/association/utils.py: 17%
120 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 01:34 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 01:34 -0800
1# This file is part of ap_association.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""Utilities for working with the APDB.
23"""
24__all__ = ("convertDataFrameToSdmSchema", "readSdmSchemaFile", "readSchemaFromApdb",
25 "dropEmptyColumns", "make_empty_catalog", "getMidpointFromTimespan",
26 "makeEmptyForcedSourceTable", "checkSdmSchemaColumns", "getRegion", "paddedRegion")
28from collections.abc import Mapping
29import os
31from lsst.dax.apdb import Apdb, ApdbTables, schema_model
32import felis.datamodel
33import math
34import numpy as np
35import pandas as pd
36from astropy.table import Table
37import yaml
39from lsst.daf.butler import Timespan
40import lsst.dax.apdb as daxApdb
41import lsst.geom
42import lsst.sphgeom
45# The first entry in the returned mapping is for nullable columns,
46# the second entry is for non-nullable columns.
47_dtype_map: Mapping[felis.datamodel.DataType, tuple[str, str]] = {
48 felis.datamodel.DataType.double: ("float64", "float64"), # Cassandra utilities need np.nan not pd.NA
49 felis.datamodel.DataType.float: ("float32", "float32"), # Cassandra utilities need np.nan not pd.NA
50 felis.datamodel.DataType.timestamp: ("datetime64[ms]", "datetime64[ms]"),
51 felis.datamodel.DataType.long: ("Int64", "int64"),
52 felis.datamodel.DataType.int: ("Int32", "int32"),
53 felis.datamodel.DataType.short: ("Int16", "int16"),
54 felis.datamodel.DataType.byte: ("Int8", "int8"),
55 felis.datamodel.DataType.binary: ("object", "object"),
56 felis.datamodel.DataType.char: ("object", "object"),
57 felis.datamodel.DataType.text: ("object", "object"),
58 felis.datamodel.DataType.string: ("object", "object"),
59 felis.datamodel.DataType.unicode: ("object", "object"),
60 felis.datamodel.DataType.boolean: ("boolean", "bool"),
61}
64def column_dtype(felis_type: felis.datamodel.DataType | daxApdb.schema_model.ExtraDataTypes,
65 nullable=False) -> str:
66 """Return Pandas data type for a given Felis column type.
68 Parameters
69 ----------
70 felis_type : `felis.datamodel.DataType`
71 Felis type, on of the enums defined in `felis.datamodel` module.
73 Returns
74 -------
75 column_dtype : `type` or `str`
76 Type that can be used for columns in Pandas.
78 Raises
79 ------
80 TypeError
81 Raised if type is cannot be handled.
82 """
83 try:
84 return _dtype_map[felis_type][0] if nullable else _dtype_map[felis_type][1]
85 except KeyError:
86 raise TypeError(f"Unexpected Felis type: {felis_type}")
89def readSdmSchemaFile(schemaFile: str,
90 schemaName: str = "ApdbSchema",
91 ):
92 """Read a schema file in YAML format.
94 Parameters
95 ----------
96 schemaFile : `str`
97 Fully specified path to the file to be read.
98 schemaName : `str`, optional
99 Name of the table of schemas to read from the file.
101 Returns
102 -------
103 schemaTable : dict[str, schema_model.Table]
104 A dict of the schemas in the given table defined in the specified file.
106 Raises
107 ------
108 ValueError
109 If the schema file can't be parsed.
110 """
111 schemaFile = os.path.expandvars(schemaFile)
112 with open(schemaFile) as yaml_stream:
113 schemas_list = list(yaml.load_all(yaml_stream, Loader=yaml.SafeLoader))
114 schemas_list = [schema for schema in schemas_list if schema.get("name") == schemaName]
115 if not schemas_list:
116 raise ValueError(f"Schema file {schemaFile!r} does not define schema {schemaName!r}")
117 elif len(schemas_list) > 1:
118 raise ValueError(f"Schema file {schemaFile!r} defines multiple schemas {schemaName!r}")
119 felis_schema = felis.datamodel.Schema.model_validate(schemas_list[0])
120 schema = daxApdb.schema_model.Schema.from_felis(felis_schema)
121 schemaTable = {}
123 for singleTable in schema.tables:
124 schemaTable[singleTable.name] = singleTable
125 return schemaTable
128def readSchemaFromApdb(apdb: Apdb) -> dict[str, schema_model.Table | None]:
129 """Extract the schema from an APDB instance.
131 Parameters
132 ----------
133 apdb : `lsst.dax.apdb.Apdb`
134 AP database connection object.
136 Returns
137 -------
138 schemaTable : dict[str, schema_model.Table | None]
139 A dict of the schemas in the given table defined in the specified file.
140 """
141 return {table.table_name(): apdb.tableDef(table) for table in ApdbTables}
144def checkSdmSchemaColumns(apdbSchema, colNames, tableName):
145 """Check if supplied column names exists in the schema.
147 Parameters
148 ----------
149 apdbSchema : `dict` [`str`, `lsst.dax.apdb.schema_model.Table`]
150 Schema from ``sdm_schemas`` containing the table definition to use.
151 colNames : `list` of ``str`
152 Names of the columns to check for in the table.
153 tableName : `str`
154 Name of the table in the schema to use.
156 Returns
157 -------
158 missing : `list` of `str`
159 All column names that are not in the schema
160 """
161 table = apdbSchema[tableName]
162 missing = []
164 names = [columnDef.name for columnDef in table.columns]
165 for col in colNames:
166 if col not in names:
167 missing.append(col)
168 return missing
171def convertDataFrameToSdmSchema(apdbSchema, sourceTable, tableName):
172 """Force a table to conform to the schema defined by the APDB.
174 This method uses the table definitions in ``sdm_schemas`` to
175 load the schema of the APDB, and does not actually connect to the APDB.
177 Parameters
178 ----------
179 apdbSchema : `dict` [`str`, `lsst.dax.apdb.schema_model.Table`]
180 Schema from ``sdm_schemas`` containing the table definition to use.
181 sourceTable : `pandas.DataFrame`
182 The input table to convert.
183 tableName : `str`
184 Name of the table in the schema to use.
186 Returns
187 -------
188 `pandas.DataFrame`
189 A table with the correct schema for the APDB and data copied from
190 the input ``sourceTable``.
191 """
192 if sourceTable.empty:
193 make_empty_catalog(apdbSchema, tableName)
194 table = apdbSchema[tableName]
196 data = {}
197 nSrc = len(sourceTable)
199 for columnDef in table.columns:
200 dtype = column_dtype(columnDef.datatype, nullable=columnDef.nullable)
201 if columnDef.name in sourceTable.columns:
202 data[columnDef.name] = pd.Series(sourceTable[columnDef.name], dtype=dtype,
203 index=sourceTable.index)
204 else:
205 if columnDef.nullable:
206 try:
207 data[columnDef.name] = pd.Series([pd.NA]*nSrc, dtype=dtype, index=sourceTable.index)
208 except TypeError:
209 data[columnDef.name] = pd.Series([np.nan]*nSrc, dtype=dtype, index=sourceTable.index)
210 else:
211 data[columnDef.name] = pd.Series([0]*nSrc, dtype=dtype, index=sourceTable.index)
212 return pd.DataFrame(data)
215def convertTableToSdmSchema(apdbSchema, sourceTable, tableName):
216 """Force a table to conform to the schema defined by the APDB.
218 This method uses the table definitions in ``sdm_schemas`` to
219 load the schema of the APDB, and does not actually connect to the APDB.
221 Parameters
222 ----------
223 apdbSchema : `dict` [`str`, `lsst.dax.apdb.schema_model.Table`]
224 Schema from ``sdm_schemas`` containing the table definition to use.
225 sourceTable : `astropy.table.Table`
226 The input table to convert.
227 tableName : `str`
228 Name of the table in the schema to use.
230 Returns
231 -------
232 `astropy.table.Table`
233 A table with the correct schema for the APDB and data copied from
234 the input ``sourceTable``.
235 """
236 table = apdbSchema[tableName]
238 data = {}
239 nSrc = len(sourceTable)
241 for columnDef in table.columns:
242 dtype = column_dtype(columnDef.datatype, nullable=columnDef.nullable)
243 if columnDef.name in sourceTable.columns:
244 data[columnDef.name] = Table.Column(sourceTable[columnDef.name], dtype=dtype.lower())
245 else:
246 if columnDef.nullable:
247 try:
248 data[columnDef.name] = Table.Column([pd.NA]*nSrc, dtype=object)
249 except TypeError:
250 data[columnDef.name] = Table.Column([pd.nan]*nSrc, dtype=dtype)
251 else:
252 data[columnDef.name] = Table.Column([0]*nSrc, dtype=dtype)
253 return Table(data)
256def dropEmptyColumns(apdbSchema, sourceTable, tableName):
257 """Drop empty columns that are nullable.
259 This method uses the table definitions in ``sdm_schemas`` to
260 load the schema of the APDB, and does not actually connect to the APDB.
262 Parameters
263 ----------
264 apdbSchema : `dict` [`str`, `lsst.dax.apdb.schema_model.Table`]
265 Schema from ``sdm_schemas`` containing the table definition to use.
266 sourceTable : `pandas.DataFrame`
267 The input table to remove missing data columns from.
268 tableName : `str`
269 Name of the table in the schema to use.
270 """
271 table = apdbSchema[tableName]
273 nullableList = [columnDef.name for columnDef in table.columns if columnDef.nullable]
274 nullColumns = sourceTable.isnull().all()
275 nullColNames = nullColumns[nullColumns].index.tolist()
276 dropColumns = list(set(nullColNames) & set(nullableList))
277 return sourceTable.drop(columns=dropColumns)
280def make_empty_catalog(apdbSchema, tableName):
281 """Make an empty catalog for a table with a given name.
283 Parameters
284 ----------
285 apdbSchema : `dict` [`str`, `lsst.dax.apdb.schema_model.Table`]
286 Schema from ``sdm_schemas`` containing the table definition to use.
287 tableName : `str`
288 Name of the table in the schema to use.
290 Returns
291 -------
292 catalog : `pandas.DataFrame`
293 An empty catalog.
294 """
295 table = apdbSchema[tableName]
297 data = {
298 columnDef.name: pd.Series(dtype=column_dtype(columnDef.datatype, nullable=columnDef.nullable))
299 for columnDef in table.columns
300 }
301 return pd.DataFrame(data)
304def getMidpointFromTimespan(timespan, allowUnbounded=True):
305 """Safely retrieve the midpoint in TAI from a Timespan.
307 Parameters
308 ----------
309 timespan : `lsst.daf.butler.Timespan` of `astropy.time.Time`
310 A Timespan centered on the midpoint of a visit.
311 allowUnbounded : `bool`, optional
312 If set, return the start or end of an unbounded timespan.
314 Returns
315 -------
316 midpoint : `astropy.time.Time`
317 The midpoint of the timespan.
319 Raises
320 ------
321 ValueError
322 Raised if either the start or end of the timespan is None, and
323 ``allowUnbounded`` is not set.
324 ValueError
325 Raised if the timespan is empty.
326 """
327 if (timespan.begin == Timespan.EMPTY) or (timespan.begin == Timespan.EMPTY):
328 raise ValueError("Cannot compute midpoint: EMPTY Timespan.")
330 try:
331 interval = timespan.end - timespan.begin
332 return (timespan.begin + interval/2).tai
333 except TypeError as e:
334 if allowUnbounded:
335 if timespan.end is not None:
336 return timespan.end.tai
337 elif timespan.begin is not None:
338 return timespan.begin.tai
339 else:
340 raise ValueError("Cannot compute midpoint: unbounded timespan.") from e
341 else:
342 raise ValueError("Cannot compute midpoint: unbounded timespan.") from e
345def makeEmptyForcedSourceTable(schema):
346 """Return a dataframe with the correct columns for diaForcedSources table.
348 Returns
349 -------
350 diaForcedSources : `pandas.DataFrame`
351 Empty dataframe.
352 """
353 diaForcedSources = convertDataFrameToSdmSchema(schema, pd.DataFrame(), tableName="DiaForcedSource")
354 return diaForcedSources
357def getRegion(exposure):
358 """Calculate an enveloping region for an exposure.
360 Parameters
361 ----------
362 exposure : `lsst.afw.image.Exposure`
363 Exposure object with calibrated WCS.
365 Returns
366 -------
367 region : `lsst.sphgeom.Region`
368 Region enveloping an exposure.
369 """
370 # Bounding box needs to be a `Box2D` not a `Box2I` for `wcs.pixelToSky()`
371 bbox = lsst.geom.Box2D(exposure.getBBox())
372 wcs = exposure.getWcs()
374 region = lsst.sphgeom.ConvexPolygon([pp.getVector() for pp in wcs.pixelToSky(bbox.getCorners())])
376 return region
379def paddedRegion(region, margin):
380 """Return a region that has been expanded by a buffer.
382 Parameters
383 ----------
384 region : `lsst.sphgeom.Region`
385 The region to pad.
386 margin : `lsst.sphgeom.Angle`
387 The amount by which to increase the region.
389 Returns
390 -------
391 padded : `lsst.sphgeom.Region`
392 An enlarged copy of ``region``.
393 """
394 # region is almost certainly a (padded) detector bounding box.
395 if isinstance(region, lsst.sphgeom.ConvexPolygon):
396 # This is an ad-hoc, approximate implementation. It should be good
397 # enough for catalog loading, but is not a general-purpose solution.
398 center = lsst.geom.SpherePoint(region.getCentroid())
399 corners = [lsst.geom.SpherePoint(c) for c in region.getVertices()]
400 # Approximate the region as a Euclidian square
401 # geom.Angle(sphgeom.Angle) converter not pybind-wrapped???
402 diagonal_margin = lsst.geom.Angle(margin.asRadians() * math.sqrt(2.0))
403 padded = [c.offset(center.bearingTo(c), diagonal_margin) for c in corners]
404 return lsst.sphgeom.ConvexPolygon.convexHull([c.getVector() for c in padded])
405 elif hasattr(region, "dilatedBy"):
406 return region.dilatedBy(margin)
407 else:
408 return region.getBoundingCircle().dilatedBy(margin)