Coverage for python/lsst/ap/association/utils.py: 16%
102 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-11 02:07 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-11 02:07 -0700
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")
28from collections.abc import Mapping
29import os
31from lsst.dax.apdb import Apdb, ApdbTables, schema_model
32import felis.datamodel
33import numpy as np
34import pandas as pd
35from astropy.table import Table
36import yaml
38from lsst.daf.butler import Timespan
39import lsst.dax.apdb as daxApdb
42# The first entry in the returned mapping is for nullable columns,
43# the second entry is for non-nullable columns.
44_dtype_map: Mapping[felis.datamodel.DataType, tuple[str, str]] = {
45 felis.datamodel.DataType.double: ("float64", "float64"), # Cassandra utilities need np.nan not pd.NA
46 felis.datamodel.DataType.float: ("float32", "float32"), # Cassandra utilities need np.nan not pd.NA
47 felis.datamodel.DataType.timestamp: ("datetime64[ms]", "datetime64[ms]"),
48 felis.datamodel.DataType.long: ("Int64", "int64"),
49 felis.datamodel.DataType.int: ("Int32", "int32"),
50 felis.datamodel.DataType.short: ("Int16", "int16"),
51 felis.datamodel.DataType.byte: ("Int8", "int8"),
52 felis.datamodel.DataType.binary: ("object", "object"),
53 felis.datamodel.DataType.char: ("object", "object"),
54 felis.datamodel.DataType.text: ("object", "object"),
55 felis.datamodel.DataType.string: ("object", "object"),
56 felis.datamodel.DataType.unicode: ("object", "object"),
57 felis.datamodel.DataType.boolean: ("boolean", "bool"),
58}
61def column_dtype(felis_type: felis.datamodel.DataType | daxApdb.schema_model.ExtraDataTypes,
62 nullable=False) -> str:
63 """Return Pandas data type for a given Felis column type.
65 Parameters
66 ----------
67 felis_type : `felis.datamodel.DataType`
68 Felis type, on of the enums defined in `felis.datamodel` module.
70 Returns
71 -------
72 column_dtype : `type` or `str`
73 Type that can be used for columns in Pandas.
75 Raises
76 ------
77 TypeError
78 Raised if type is cannot be handled.
79 """
80 try:
81 return _dtype_map[felis_type][0] if nullable else _dtype_map[felis_type][1]
82 except KeyError:
83 raise TypeError(f"Unexpected Felis type: {felis_type}")
86def readSdmSchemaFile(schemaFile: str,
87 schemaName: str = "ApdbSchema",
88 ):
89 """Read a schema file in YAML format.
91 Parameters
92 ----------
93 schemaFile : `str`
94 Fully specified path to the file to be read.
95 schemaName : `str`, optional
96 Name of the table of schemas to read from the file.
98 Returns
99 -------
100 schemaTable : dict[str, schema_model.Table]
101 A dict of the schemas in the given table defined in the specified file.
103 Raises
104 ------
105 ValueError
106 If the schema file can't be parsed.
107 """
108 schemaFile = os.path.expandvars(schemaFile)
109 with open(schemaFile) as yaml_stream:
110 schemas_list = list(yaml.load_all(yaml_stream, Loader=yaml.SafeLoader))
111 schemas_list = [schema for schema in schemas_list if schema.get("name") == schemaName]
112 if not schemas_list:
113 raise ValueError(f"Schema file {schemaFile!r} does not define schema {schemaName!r}")
114 elif len(schemas_list) > 1:
115 raise ValueError(f"Schema file {schemaFile!r} defines multiple schemas {schemaName!r}")
116 felis_schema = felis.datamodel.Schema.model_validate(schemas_list[0])
117 schema = daxApdb.schema_model.Schema.from_felis(felis_schema)
118 schemaTable = {}
120 for singleTable in schema.tables:
121 schemaTable[singleTable.name] = singleTable
122 return schemaTable
125def readSchemaFromApdb(apdb: Apdb) -> dict[str, schema_model.Table | None]:
126 """Extract the schema from an APDB instance.
128 Parameters
129 ----------
130 apdb : `lsst.dax.apdb.Apdb`
131 AP database connection object.
133 Returns
134 -------
135 schemaTable : dict[str, schema_model.Table | None]
136 A dict of the schemas in the given table defined in the specified file.
137 """
138 return {table.table_name(): apdb.tableDef(table) for table in ApdbTables}
141def checkSdmSchemaColumns(apdbSchema, colNames, tableName):
142 """Check if supplied column names exists in the schema.
144 Parameters
145 ----------
146 apdbSchema : `dict` [`str`, `lsst.dax.apdb.schema_model.Table`]
147 Schema from ``sdm_schemas`` containing the table definition to use.
148 colNames : `list` of ``str`
149 Names of the columns to check for in the table.
150 tableName : `str`
151 Name of the table in the schema to use.
153 Returns
154 -------
155 missing : `list` of `str`
156 All column names that are not in the schema
157 """
158 table = apdbSchema[tableName]
159 missing = []
161 names = [columnDef.name for columnDef in table.columns]
162 for col in colNames:
163 if col not in names:
164 missing.append(col)
165 return missing
168def convertDataFrameToSdmSchema(apdbSchema, sourceTable, tableName):
169 """Force a table to conform to the schema defined by the APDB.
171 This method uses the table definitions in ``sdm_schemas`` to
172 load the schema of the APDB, and does not actually connect to the APDB.
174 Parameters
175 ----------
176 apdbSchema : `dict` [`str`, `lsst.dax.apdb.schema_model.Table`]
177 Schema from ``sdm_schemas`` containing the table definition to use.
178 sourceTable : `pandas.DataFrame`
179 The input table to convert.
180 tableName : `str`
181 Name of the table in the schema to use.
183 Returns
184 -------
185 `pandas.DataFrame`
186 A table with the correct schema for the APDB and data copied from
187 the input ``sourceTable``.
188 """
189 if sourceTable.empty:
190 make_empty_catalog(apdbSchema, tableName)
191 table = apdbSchema[tableName]
193 data = {}
194 nSrc = len(sourceTable)
196 for columnDef in table.columns:
197 dtype = column_dtype(columnDef.datatype, nullable=columnDef.nullable)
198 if columnDef.name in sourceTable.columns:
199 data[columnDef.name] = pd.Series(sourceTable[columnDef.name], dtype=dtype,
200 index=sourceTable.index)
201 else:
202 if columnDef.nullable:
203 try:
204 data[columnDef.name] = pd.Series([pd.NA]*nSrc, dtype=dtype, index=sourceTable.index)
205 except TypeError:
206 data[columnDef.name] = pd.Series([np.nan]*nSrc, dtype=dtype, index=sourceTable.index)
207 else:
208 data[columnDef.name] = pd.Series([0]*nSrc, dtype=dtype, index=sourceTable.index)
209 return pd.DataFrame(data)
212def convertTableToSdmSchema(apdbSchema, sourceTable, tableName):
213 """Force a table to conform to the schema defined by the APDB.
215 This method uses the table definitions in ``sdm_schemas`` to
216 load the schema of the APDB, and does not actually connect to the APDB.
218 Parameters
219 ----------
220 apdbSchema : `dict` [`str`, `lsst.dax.apdb.schema_model.Table`]
221 Schema from ``sdm_schemas`` containing the table definition to use.
222 sourceTable : `astropy.table.Table`
223 The input table to convert.
224 tableName : `str`
225 Name of the table in the schema to use.
227 Returns
228 -------
229 `astropy.table.Table`
230 A table with the correct schema for the APDB and data copied from
231 the input ``sourceTable``.
232 """
233 table = apdbSchema[tableName]
235 data = {}
236 nSrc = len(sourceTable)
238 for columnDef in table.columns:
239 dtype = column_dtype(columnDef.datatype, nullable=columnDef.nullable)
240 if columnDef.name in sourceTable.columns:
241 data[columnDef.name] = Table.Column(sourceTable[columnDef.name], dtype=dtype.lower())
242 else:
243 if columnDef.nullable:
244 try:
245 data[columnDef.name] = Table.Column([pd.NA]*nSrc, dtype=object)
246 except TypeError:
247 data[columnDef.name] = Table.Column([pd.nan]*nSrc, dtype=dtype)
248 else:
249 data[columnDef.name] = Table.Column([0]*nSrc, dtype=dtype)
250 return Table(data)
253def dropEmptyColumns(apdbSchema, sourceTable, tableName):
254 """Drop empty columns that are nullable.
256 This method uses the table definitions in ``sdm_schemas`` to
257 load the schema of the APDB, and does not actually connect to the APDB.
259 Parameters
260 ----------
261 apdbSchema : `dict` [`str`, `lsst.dax.apdb.schema_model.Table`]
262 Schema from ``sdm_schemas`` containing the table definition to use.
263 sourceTable : `pandas.DataFrame`
264 The input table to remove missing data columns from.
265 tableName : `str`
266 Name of the table in the schema to use.
267 """
268 table = apdbSchema[tableName]
270 nullableList = [columnDef.name for columnDef in table.columns if columnDef.nullable]
271 nullColumns = sourceTable.isnull().all()
272 nullColNames = nullColumns[nullColumns].index.tolist()
273 dropColumns = list(set(nullColNames) & set(nullableList))
274 return sourceTable.drop(columns=dropColumns)
277def make_empty_catalog(apdbSchema, tableName):
278 """Make an empty catalog for a table with a given name.
280 Parameters
281 ----------
282 apdbSchema : `dict` [`str`, `lsst.dax.apdb.schema_model.Table`]
283 Schema from ``sdm_schemas`` containing the table definition to use.
284 tableName : `str`
285 Name of the table in the schema to use.
287 Returns
288 -------
289 catalog : `pandas.DataFrame`
290 An empty catalog.
291 """
292 table = apdbSchema[tableName]
294 data = {
295 columnDef.name: pd.Series(dtype=column_dtype(columnDef.datatype, nullable=columnDef.nullable))
296 for columnDef in table.columns
297 }
298 return pd.DataFrame(data)
301def getMidpointFromTimespan(timespan, allowUnbounded=True):
302 """Safely retrieve the midpoint in TAI from a Timespan.
304 Parameters
305 ----------
306 timespan : `lsst.daf.butler.Timespan` of `astropy.time.Time`
307 A Timespan centered on the midpoint of a visit.
308 allowUnbounded : `bool`, optional
309 If set, return the start or end of an unbounded timespan.
311 Returns
312 -------
313 midpoint : `astropy.time.Time`
314 The midpoint of the timespan.
316 Raises
317 ------
318 ValueError
319 Raised if either the start or end of the timespan is None, and
320 ``allowUnbounded`` is not set.
321 ValueError
322 Raised if the timespan is empty.
323 """
324 if (timespan.begin == Timespan.EMPTY) or (timespan.begin == Timespan.EMPTY):
325 raise ValueError("Cannot compute midpoint: EMPTY Timespan.")
327 try:
328 interval = timespan.end - timespan.begin
329 return (timespan.begin + interval/2).tai
330 except TypeError as e:
331 if allowUnbounded:
332 if timespan.end is not None:
333 return timespan.end.tai
334 elif timespan.begin is not None:
335 return timespan.begin.tai
336 else:
337 raise ValueError("Cannot compute midpoint: unbounded timespan.") from e
338 else:
339 raise ValueError("Cannot compute midpoint: unbounded timespan.") from e
342def makeEmptyForcedSourceTable(schema):
343 """Return a dataframe with the correct columns for diaForcedSources table.
345 Returns
346 -------
347 diaForcedSources : `pandas.DataFrame`
348 Empty dataframe.
349 """
350 diaForcedSources = convertDataFrameToSdmSchema(schema, pd.DataFrame(), tableName="DiaForcedSource")
351 return diaForcedSources