Coverage for python/lsst/ap/association/diaForcedSource.py: 32%
73 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-27 04:02 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-27 04:02 -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"""Methods for force photometering direct and difference images at DiaObject
23locations.
24"""
26__all__ = ["DiaForcedSourceTask", "DiaForcedSourcedConfig"]
28import numpy as np
30import lsst.afw.table as afwTable
31from lsst.daf.base import DateTime
32import lsst.geom as geom
33from lsst.meas.base import ForcedMeasurementTask
34import lsst.pex.config as pexConfig
35import lsst.pipe.base as pipeBase
36from lsst.utils.timer import timeMethod
39class DiaForcedSourcedConfig(pexConfig.Config):
40 """Configuration for the generic DiaForcedSourcedTask class.
41 """
42 forcedMeasurement = pexConfig.ConfigurableField(
43 target=ForcedMeasurementTask,
44 doc="Subtask to force photometer DiaObjects in the direct and "
45 "difference images.",
46 )
47 dropColumns = pexConfig.ListField(
48 dtype=str,
49 doc="Columns produced in forced measurement that can be dropped upon "
50 "creation and storage of the final pandas data.",
51 )
53 def setDefaults(self):
54 self.forcedMeasurement.plugins = ["base_TransformedCentroidFromCoord",
55 "base_PsfFlux"]
56 self.forcedMeasurement.doReplaceWithNoise = False
57 self.forcedMeasurement.copyColumns = {
58 "id": "diaObjectId",
59 "coord_ra": "coord_ra",
60 "coord_dec": "coord_dec"}
61 self.forcedMeasurement.slots.centroid = "base_TransformedCentroidFromCoord"
62 self.forcedMeasurement.slots.psfFlux = "base_PsfFlux"
63 self.forcedMeasurement.slots.shape = None
64 self.dropColumns = ['coord_ra', 'coord_dec', 'parent',
65 'base_TransformedCentroidFromCoord_x',
66 'base_TransformedCentroidFromCoord_y',
67 'base_PsfFlux_instFlux',
68 'base_PsfFlux_instFluxErr', 'base_PsfFlux_area',
69 'slot_PsfFlux_area', 'base_PsfFlux_flag',
70 'slot_PsfFlux_flag',
71 'base_PsfFlux_flag_noGoodPixels',
72 'slot_PsfFlux_flag_noGoodPixels',
73 'base_PsfFlux_flag_edge', 'slot_PsfFlux_flag_edge',
74 'base_PsfFlux_chi2', 'slot_PsfFlux_chi2',
75 'base_PsfFlux_npixels', 'slot_PsfFlux_npixels',
76 ]
79class DiaForcedSourceTask(pipeBase.Task):
80 """Task for measuring and storing forced sources at DiaObject locations
81 in both difference and direct images.
82 """
83 ConfigClass = DiaForcedSourcedConfig
84 _DefaultName = "diaForcedSource"
86 def __init__(self, **kwargs):
87 pipeBase.Task.__init__(self, **kwargs)
88 self.makeSubtask("forcedMeasurement",
89 refSchema=afwTable.SourceTable.makeMinimalSchema())
91 @timeMethod
92 def run(self,
93 dia_objects,
94 updatedDiaObjectIds,
95 exposure,
96 diffim,
97 idGenerator):
98 """Measure forced sources on the direct and difference images.
100 Parameters
101 ----------
102 dia_objects : `pandas.DataFrame`
103 Catalog of previously observed and newly created DiaObjects
104 contained within the difference and direct images. DiaObjects
105 must be indexed on the ``diaObjectId`` column.
106 updatedDiaObjectIds : `numpy.ndarray`
107 Array of diaObjectIds that were updated during this dia processing.
108 Used to assure that the pipeline includes all diaObjects that were
109 updated in case one falls on the edge of the CCD.
110 exposure : `lsst.afw.image.Exposure`
111 Direct image exposure.
112 diffim : `lsst.afw.image.Exposure`
113 Difference image.
114 idGenerator : `lsst.meas.base.IdGenerator`
115 Object that generates source IDs and random number generator seeds.
117 Returns
118 -------
119 output_forced_sources : `pandas.DataFrame`
120 Catalog of calibrated forced photometered fluxes on both the
121 difference and direct images at DiaObject locations.
122 """
124 afw_dia_objects = self._convert_from_pandas(dia_objects)
126 idFactoryDiff = idGenerator.make_table_id_factory()
128 diffForcedSources = self.forcedMeasurement.generateMeasCat(
129 diffim,
130 afw_dia_objects,
131 diffim.getWcs(),
132 idFactory=idFactoryDiff)
133 self.forcedMeasurement.run(
134 diffForcedSources, diffim, afw_dia_objects, diffim.getWcs())
136 directForcedSources = self.forcedMeasurement.generateMeasCat(
137 exposure,
138 afw_dia_objects,
139 exposure.getWcs(),
140 idFactory=idFactoryDiff)
141 self.forcedMeasurement.run(
142 directForcedSources, exposure, afw_dia_objects, exposure.getWcs())
144 output_forced_sources = self._calibrate_and_merge(diffForcedSources,
145 directForcedSources,
146 diffim,
147 exposure)
149 output_forced_sources = self._trim_to_exposure(output_forced_sources,
150 updatedDiaObjectIds,
151 exposure)
152 return output_forced_sources.set_index(
153 ["diaObjectId", "diaForcedSourceId"],
154 drop=False)
156 def _convert_from_pandas(self, input_objects):
157 """Create minimal schema SourceCatalog from a pandas DataFrame.
159 We need a catalog of this type to run within the forced measurement
160 subtask.
162 Parameters
163 ----------
164 input_objects : `pandas.DataFrame`
165 DiaObjects with locations and ids. ``
167 Returns
168 -------
169 outputCatalog : `lsst.afw.table.SourceTable`
170 Output catalog with minimal schema.
171 """
172 schema = afwTable.SourceTable.makeMinimalSchema()
174 outputCatalog = afwTable.SourceCatalog(schema)
175 outputCatalog.reserve(len(input_objects))
177 for obj_id, df_row in input_objects.iterrows():
178 outputRecord = outputCatalog.addNew()
179 outputRecord.setId(obj_id)
180 outputRecord.setCoord(
181 geom.SpherePoint(df_row["ra"],
182 df_row["dec"],
183 geom.degrees))
184 return outputCatalog
186 def _calibrate_and_merge(self,
187 diff_sources,
188 direct_sources,
189 diff_exp,
190 direct_exp):
191 """Take the two output catalogs from the ForcedMeasurementTasks and
192 calibrate, combine, and convert them to Pandas.
194 Parameters
195 ----------
196 diff_sources : `lsst.afw.table.SourceTable`
197 Catalog with PsFluxes measured on the difference image.
198 direct_sources : `lsst.afw.table.SourceTable`
199 Catalog with PsfFluxes measured on the direct (calexp) image.
200 diff_exp : `lsst.afw.image.Exposure`
201 Difference exposure ``diff_sources`` were measured on.
202 direct_exp : `lsst.afw.image.Exposure`
203 Direct (calexp) exposure ``direct_sources`` were measured on.
205 Returns
206 -------
207 output_catalog : `pandas.DataFrame`
208 Catalog calibrated diaForcedSources.
209 """
210 diff_calib = diff_exp.getPhotoCalib()
211 direct_calib = direct_exp.getPhotoCalib()
213 diff_fluxes = diff_calib.instFluxToNanojansky(diff_sources,
214 "slot_PsfFlux")
215 direct_fluxes = direct_calib.instFluxToNanojansky(direct_sources,
216 "slot_PsfFlux")
218 output_catalog = diff_sources.asAstropy().to_pandas()
219 output_catalog.rename(columns={"id": "diaForcedSourceId",
220 "slot_PsfFlux_instFlux": "psfFlux",
221 "slot_PsfFlux_instFluxErr": "psfFluxErr",
222 "slot_Centroid_x": "x",
223 "slot_Centroid_y": "y"},
224 inplace=True)
225 output_catalog.loc[:, "psfFlux"] = diff_fluxes[:, 0]
226 output_catalog.loc[:, "psfFluxErr"] = diff_fluxes[:, 1]
228 output_catalog["scienceFlux"] = direct_fluxes[:, 0]
229 output_catalog["scienceFluxErr"] = direct_fluxes[:, 1]
231 visit_info = direct_exp.visitInfo
232 ccdVisitId = direct_exp.info.id
233 midpointMjdTai = visit_info.date.get(system=DateTime.MJD)
234 output_catalog["ccdVisitId"] = ccdVisitId
235 output_catalog["midpointMjdTai"] = midpointMjdTai
236 output_catalog["band"] = diff_exp.getFilter().bandLabel
237 output_catalog["time_processed"] = DateTime.now().toPython()
238 # TODO: propagate actual flags (DM-42355)
239 output_catalog["flags"] = 0
241 # Drop superfluous columns from output DataFrame.
242 output_catalog.drop(columns=self.config.dropColumns, inplace=True)
244 return output_catalog
246 def _trim_to_exposure(self, catalog, updatedDiaObjectIds, exposure):
247 """Remove DiaForcedSources that are outside of the bounding box region.
249 Paramters
250 ---------
251 catalog : `pandas.DataFrame`
252 DiaForcedSources to check against the exposure bounding box.
253 updatedDiaObjectIds : `numpy.ndarray`
254 Array of diaObjectIds that were updated during this dia processing.
255 Used to assure that the pipeline includes all diaObjects that were
256 updated in case one falls on the edge of the CCD.
257 exposure : `lsst.afw.image.Exposure`
258 Exposure to check against.
260 Returns
261 -------
262 output : `pandas.DataFrame`
263 DataFrame trimmed to only the objects within the exposure bounding
264 box.
265 """
266 bbox = geom.Box2D(exposure.getBBox())
268 xS = catalog.loc[:, "x"]
269 yS = catalog.loc[:, "y"]
271 return catalog[
272 np.logical_or(bbox.contains(xS, yS),
273 np.isin(catalog.loc[:, "diaObjectId"],
274 updatedDiaObjectIds))]