lsst.pipe.tasks g5b638a483d+2a6a9422c9
Loading...
Searching...
No Matches
match_tract_catalog.py
Go to the documentation of this file.
1# This file is part of pipe_tasks.
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/>.
21
22__all__ = [
23 'MatchTractCatalogSubConfig', 'MatchTractCatalogSubTask',
24 'MatchTractCatalogConfig', 'MatchTractCatalogTask'
25]
26
27from abc import ABC, abstractmethod
28
29import astropy.table
30import numpy as np
31import pandas as pd
32
33
34import lsst.afw.geom as afwGeom
35import lsst.geom
36import lsst.pex.config as pexConfig
37import lsst.pipe.base as pipeBase
38import lsst.pipe.base.connectionTypes as cT
39from lsst.geom import SpherePoint
40from lsst.obs.base.utils import TableVStack
41from lsst.skymap import BaseSkyMap
42
43from .diff_matched_tract_catalog import DiffMatchedTractCatalogTaskBase
44
45MatchTractCatalogBaseTemplates = {
46 "name_input_cat_ref": "truth_summary",
47 "name_input_cat_target": "objectTable_tract",
48}
49
50
52 pipeBase.PipelineTaskConnections,
53 dimensions=("tract", "skymap"),
54 defaultTemplates=MatchTractCatalogBaseTemplates,
55):
56 cat_ref = cT.Input(
57 doc="Reference object catalog to match from",
58 name="{name_input_cat_ref}",
59 storageClass="ArrowAstropy",
60 dimensions=("tract", "skymap"),
61 deferLoad=True,
62 )
63 cat_target = cT.Input(
64 doc="Target object catalog to match",
65 name="{name_input_cat_target}",
66 storageClass="ArrowAstropy",
67 dimensions=("tract", "skymap"),
68 deferLoad=True,
69 )
70 skymap = cT.Input(
71 doc="Input definition of geometry/bbox and projection/wcs for coadded exposures",
72 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
73 storageClass="SkyMap",
74 dimensions=("skymap",),
75 )
76 cat_output_matched = cT.Output(
77 doc="Target matched catalog with indices of reference matches",
78 name="matched_{name_input_cat_ref}_{name_input_cat_target}",
79 storageClass="ArrowAstropy",
80 dimensions=("tract", "skymap"),
81 )
82 cat_output_ref = cT.Output(
83 doc="Reference matched catalog with indices of target matches",
84 name="match_ref_{name_input_cat_ref}_{name_input_cat_target}",
85 storageClass="ArrowAstropy",
86 dimensions=("tract", "skymap"),
87 )
88 cat_output_target = cT.Output(
89 doc="Target matched catalog with indices of reference matches",
90 name="match_target_{name_input_cat_ref}_{name_input_cat_target}",
91 storageClass="ArrowAstropy",
92 dimensions=("tract", "skymap"),
93 )
94
95 def __init__(self, *, config=None):
96 if not config.output_matched_catalog:
97 del self.cat_output_matched
98 if config.match_multiple_target:
99 # allConnections will change during iteration
100 for name, connection in tuple(self.allConnections.items()):
101 if connection.storageClass == "SkyMap":
102 continue
103 kwargs = {"deferLoad": connection.deferLoad} if hasattr(connection, "deferLoad") else {}
104 delattr(self, name)
105 setattr(
106 self,
107 name,
108 type(connection)(
109 doc=connection.doc,
110 name=connection.name,
111 storageClass=connection.storageClass,
112 dimensions=connection.dimensions,
113 multiple=True,
114 **kwargs
115 ),
116 )
117 self.dimensions = {"skymap"}
118 is_refcat_sharding_none = False
119 if config.refcat_sharding_type != "tract":
120 if config.refcat_sharding_type == "none":
121 is_refcat_sharding_none = True
122 old = self.cat_ref
123 self.cat_ref = cT.Input(
124 doc=old.doc,
125 name=old.name,
126 storageClass=old.storageClass,
127 dimensions=(),
128 deferLoad=old.deferLoad,
129 )
130 else:
131 raise NotImplementedError(f"{config.refcat_sharding_type=} not implemented")
132 if config.target_sharding_type != "tract":
133 if config.target_sharding_type == "none":
134 if config.match_multiple_target:
135 raise ValueError(
136 f"Incompatible {config.match_multiple_target=} with {config.target_sharding_type=}"
137 )
138 old = self.cat_target
139 self.cat_target = cT.Input(
140 doc=old.doc,
141 name=old.name,
142 storageClass=old.storageClass,
143 dimensions=(),
144 deferLoad=old.deferLoad,
145 )
146 if is_refcat_sharding_none:
147 for suffix_old in ("matched", "ref", "target"):
148 name_old = f"cat_output_{suffix_old}"
149 old = getattr(self, name_old)
150 setattr(
151 self,
152 name_old,
153 cT.Output(
154 doc=old.doc,
155 name=old.name,
156 storageClass=old.storageClass,
157 dimensions=(),
158 ),
159 )
160 del self.skymap
161 self.dimensions = set()
162 else:
163 raise NotImplementedError(f"{config.target_sharding_type=} not implemented")
164
165
166class MatchTractCatalogSubConfig(pexConfig.Config):
167 """Config class for the MatchTractCatalogSubTask to define methods returning
168 values that depend on multiple config settings.
169 """
170 @property
171 @abstractmethod
172 def columns_in_ref(self) -> set[str]:
173 raise NotImplementedError()
174
175 @property
176 def columns_ordered_in_ref(self) -> dict[str, None]:
177 raise NotImplementedError()
178
179 @property
180 @abstractmethod
181 def columns_in_target(self) -> set[str]:
182 raise NotImplementedError()
183
184 @property
185 def columns_ordered_in_target(self) -> dict[str, None]:
186 raise NotImplementedError()
187
188
189class MatchTractCatalogSubTask(pipeBase.Task, ABC):
190 """An abstract interface for subtasks of MatchTractCatalogTask to match
191 two tract object catalogs.
192
193 Parameters
194 ----------
195 **kwargs
196 Additional arguments to be passed to the `lsst.pipe.base.Task`
197 constructor.
198 """
199 ConfigClass = MatchTractCatalogSubConfig
200
201 def __init__(self, **kwargs):
202 super().__init__(**kwargs)
203
204 @abstractmethod
205 def run(
206 self,
207 catalog_ref: pd.DataFrame | astropy.table.Table,
208 catalog_target: pd.DataFrame | astropy.table.Table,
209 wcs: afwGeom.SkyWcs = None,
210 ) -> pipeBase.Struct:
211 """Match sources in a reference tract catalog with a target catalog.
212
213 Parameters
214 ----------
215 catalog_ref : `pandas.DataFrame` | `astropy.table.Table`
216 A reference catalog to match objects/sources from.
217 catalog_target : `pandas.DataFrame` | `astropy.table.Table`
218 A target catalog to match reference objects/sources to.
219 wcs : `lsst.afw.image.SkyWcs`
220 A coordinate system to convert catalog positions to sky coordinates.
221
222 Returns
223 -------
224 retStruct : `lsst.pipe.base.Struct`
225 A struct with output_ref and output_target attribute containing the
226 output matched catalogs.
227 """
228 raise NotImplementedError()
229
230
232 pipeBase.PipelineTaskConfig,
233 pipelineConnections=MatchTractCatalogConnections,
234):
235 """Configure a MatchTractCatalogTask, including a configurable matching subtask.
236 """
237 coord_unit = pexConfig.Field[str](
238 doc="lsst.geom unit (or astropy equivalent) of the coordinate columns."
239 "Only used to determine the tract for rows in non-tract-sharded "
240 "catalogs without a tract column.",
241 optional=True,
242 )
243 diff_matched_catalog = pexConfig.ConfigurableField(
244 target=DiffMatchedTractCatalogTaskBase,
245 doc="Task to make a matched catalog out of the match index tables",
246 )
247 match_multiple_target = pexConfig.Field[bool](
248 doc="Whether to match multiple target tract catalogs",
249 default=False,
250 )
251 match_tract_catalog = pexConfig.ConfigurableField(
252 target=MatchTractCatalogSubTask,
253 doc="Task to match sources in a reference tract catalog with a target catalog",
254 )
255 output_matched_catalog = pexConfig.Field[bool](
256 doc="Whether to run the diff_matched_catalog task and write a matched catalog,"
257 " not just the catalogs of match indices",
258 default=False,
259 )
260 refcat_sharding_type = pexConfig.ChoiceField[str](
261 doc="The type of sharding (spatial splitting) for the reference catalog",
262 allowed={"tract": "Tract-based shards", "none": "No sharding at all"},
263 default="tract",
264 )
265 target_sharding_type = pexConfig.ChoiceField[str](
266 doc="The type of sharding (spatial splitting) for the target catalog",
267 allowed={"tract": "Tract-based shards", "none": "No sharding at all"},
268 default="tract",
269 )
270
271 def get_columns_in(self) -> tuple[set, set]:
272 """Get the set of input columns required for matching.
273
274 This function exists for backward compatibility and simply returns
275 the results of get_columns_ordered_in cast to sets.
276 """
277 columns_ref, columns_target = self.get_columns_ordered_in()
278 return set(columns_ref.keys()), set(columns_target.keys())
279
280 def get_columns_ordered_in(self) -> tuple[dict[str, None], dict[str, None]]:
281 """Get the ordered set of input columns required for matching.
282
283 Returns
284 -------
285 columns_ref : `set` [`str`]
286 The set of required input catalog column names.
287 columns_target : `set` [`str`]
288 The set of required target catalog column names.
289 """
290 try:
291 columns_ref, columns_target = (
292 self.match_tract_catalog.columns_ordered_in_ref,
293 self.match_tract_catalog.columns_ordered_in_target,
294 )
295 except AttributeError as err:
296 raise RuntimeError(f'{__class__}.match_tract_catalog must have columns_in_ref and'
297 f' columns_in_target attributes: {err}') from None
299 config_diff = self.diff_matched_catalog.value
300 columns_ref.update({k: None for k in config_diff.columns_in_ref})
301 columns_target.update({k: None for k in config_diff.columns_in_target})
302 return columns_ref, columns_target
303
304
305class MatchTractCatalogTask(pipeBase.PipelineTask):
306 """Match sources in a reference tract catalog with those in a target catalog.
307 """
308 ConfigClass = MatchTractCatalogConfig
309 _DefaultName = "MatchTractCatalog"
310
311 def __init__(self, initInputs, **kwargs):
312 super().__init__(initInputs=initInputs, **kwargs)
313 self.makeSubtask("match_tract_catalog")
314 self.makeSubtask("diff_matched_catalog")
315
316 _astropy_u_to_lsst_geom = {
317 "arcmin": "arcminutes",
318 "deg": "degrees",
319 "rad": "radians",
320 }
321
322 def runQuantum(self, butlerQC, inputRefs, outputRefs):
323 inputs = butlerQC.get(inputRefs)
324 columns_ref, columns_target = (tuple(columns) for columns in self.config.get_columns_ordered_in())
325 skymap = inputs.pop("skymap") if (self.config.refcat_sharding_type != 'none') or (
326 self.config.target_sharding_type != 'none') else None
327 is_refcat_per_tract = self.config.refcat_sharding_type == 'tract'
328 is_target_per_tract = self.config.target_sharding_type == 'tract'
329
330 if self.config.match_multiple_target:
331 if is_target_per_tract:
332 names_columns = [("cat_target", columns_target)]
333 catalogs = {}
334 else:
335 catalogs = {"cat_target": inputs["cat_target"].get(parameters={'columns': columns_target})}
336 names_columns = []
337 if is_refcat_per_tract:
338 names_columns.append(("cat_ref", columns_ref))
339 else:
340 catalogs["cat_ref"] = inputs["cat_ref"].get(parameters={'columns': columns_ref})
341
342 for name, columns in names_columns:
343 extra_values = {}
344 handles = []
345 for idx, (tract, handle) in enumerate(sorted(
346 (inputRef.dataId["tract"], inputHandle)
347 for inputRef, inputHandle in zip(getattr(inputRefs, name), inputs[name], strict=True)
348 )):
349 handles.append(handle)
350 extra_values[idx] = {"tract": tract}
351 catalogs[name] = TableVStack.vstack_handles(
352 handles,
353 extra_values=extra_values,
354 kwargs_get={"parameters": {"columns": columns}}
355 )
356 catalog_ref, catalog_target = catalogs["cat_ref"], catalogs["cat_target"]
357 else:
358 catalog_ref, catalog_target = (
359 inputs[name].get(parameters={'columns': columns})
360 for name, columns in (('cat_ref', columns_ref), ('cat_target', columns_target))
361 )
362 if self.config.match_multiple_target:
363 self._add_tract_column_to_catalogs(catalog_ref, catalog_target, skymap)
364
365 outputs = self.run(
366 catalog_ref=catalog_ref,
367 catalog_target=catalog_target,
368 wcs=None if (self.config.match_multiple_target or (skymap is None)) else (
369 skymap[butlerQC.quantum.dataId["tract"]].wcs),
370 )
371
372 if self.config.match_multiple_target:
373 outputs_new = {}
374
375 for name, catalog_in, name_tract in (
376 ("cat_output_ref", catalog_ref, outputs.name_tract_ref_in),
377 ("cat_output_target", catalog_target, "tract"),
378 ):
379 catalogs_out = []
380 for outputRef in getattr(outputRefs, name):
381 tract = outputRef.dataId["tract"]
382 catalog_out = getattr(outputs, name)
383 catalogs_out.append(catalog_out[catalog_in[name_tract] == tract])
384 outputs_new[name] = catalogs_out
385
386 if self.config.output_matched_catalog:
387 cat_matched = outputs.cat_output_matched
388 catalogs_out = []
389 tract_target = cat_matched[outputs.name_tract_target]
390 patch_target = cat_matched[outputs.name_patch_target]
391 masked_target = tract_target.mask == True # noqa: E712
392
393 tract_ref = cat_matched[outputs.name_tract_ref]
394 tract_out = np.array(tract_target)
395 tract_out[masked_target] = tract_ref[masked_target]
396 cat_matched["tract"] = tract_out
397 cat_matched["tract"].description = (f"{outputs.name_tract_target} if available"
398 f" else {outputs.name_tract_ref}")
399 patch_ref = cat_matched[outputs.name_patch_ref]
400 patch_out = np.array(patch_target)
401 # The patch and target masks should be identical
402 patch_out[masked_target] = patch_ref[masked_target]
403 cat_matched["patch"] = patch_out
404 cat_matched["patch"].description = (f"{outputs.name_patch_target} if available"
405 f" else {outputs.name_patch_ref}")
406
407 for outputRef in outputRefs.cat_output_matched:
408 tract = outputRef.dataId["tract"]
409 catalogs_out.append(cat_matched[tract_out == tract])
410 outputs_new["cat_output_matched"] = catalogs_out
411
412 outputs = pipeBase.Struct(
413 **outputs_new,
414 **{k: v for k, v in outputs.getDict().items() if k not in outputs_new},
415 )
416
417 butlerQC.put(outputs, outputRefs)
418
419 def run(
420 self,
421 catalog_ref: pd.DataFrame | astropy.table.Table,
422 catalog_target: pd.DataFrame | astropy.table.Table,
423 wcs: afwGeom.SkyWcs = None,
424 ) -> pipeBase.Struct:
425 """Match sources in a reference tract catalog with a target catalog.
426
427 Parameters
428 ----------
429 catalog_ref : `pandas.DataFrame` | `astropy.table.Table`
430 A reference catalog to match objects/sources from.
431 catalog_target : `pandas.DataFrame` | `astropy.table.Table`
432 A target catalog to match reference objects/sources to.
433 wcs : `lsst.afw.image.SkyWcs`
434 A coordinate system to convert catalog positions to sky coordinates,
435 if necessary.
436
437 Returns
438 -------
439 retStruct : `lsst.pipe.base.Struct`
440 A struct with output_ref and output_target attribute containing the
441 output matched catalogs. If self.config.output_matched_catalog is
442 True, cat_output_matched is also returned, along with the names of
443 the tract/patch columns, which may have been changed to avoid
444 collisions.
445 """
446 output = self.match_tract_catalog.run(catalog_ref, catalog_target, wcs=wcs)
447 if output.exceptions:
448 self.log.warning('Exceptions: %s', output.exceptions)
449 retStruct = pipeBase.Struct(cat_output_ref=output.cat_output_ref,
450 cat_output_target=output.cat_output_target)
451
452 if self.config.output_matched_catalog:
453 # TODO: Consider making this a config parameter
454 # Making it optional is probably preferable than quietly
455 # checking a hardcoded column name
456 name_tract_ref_in = "tract"
457 diff_prefix_ref = self.config.diff_matched_catalog.value.column_matched_prefix_ref
458 diff_prefix_target = self.config.diff_matched_catalog.value.column_matched_prefix_target
459 name_tract_ref = f"{diff_prefix_ref}tract"
460 name_tract_target = f"{diff_prefix_target}tract"
461 name_patch_ref = f"{diff_prefix_ref}patch"
462 name_patch_target = f"{diff_prefix_target}patch"
463 # Avoid collisions if, for example, both prefixes are ''
464 if (name_tract_ref_in in catalog_ref.colnames) and (name_tract_ref == name_tract_target):
465 prefix_new = "ref_" if diff_prefix_ref != "ref_" else "refcat_"
466 name_new = f"{prefix_new}tract"
467 catalog_ref.rename_column(name_tract_ref, name_new)
468 name_tract_ref = name_new
469 name_tract_ref_in = name_new
470 if name_patch_ref in catalog_ref.colnames:
471 name_new = f"{prefix_new}patch"
472 catalog_ref.rename_column(name_patch_ref, name_new)
473 name_patch_ref = name_new
474
475 outputs_new = self.diff_matched_catalog.run(
476 catalog_ref=catalog_ref,
477 catalog_target=catalog_target,
478 catalog_match_ref=retStruct.cat_output_ref,
479 catalog_match_target=retStruct.cat_output_target,
480 )
481 retStruct = pipeBase.Struct(
482 **retStruct.getDict(),
483 cat_output_matched=outputs_new.cat_matched,
484 name_tract_ref=name_tract_ref,
485 name_tract_ref_in=name_tract_ref_in,
486 name_tract_target=name_tract_target,
487 name_patch_ref=name_patch_ref,
488 name_patch_target=name_patch_target,
489 )
490
491 return retStruct
492
493 def _add_tract_column_to_catalogs(self, catalog_ref, catalog_target, skymap, add_patch: bool = True):
494 """Add a tract column to catalogs that may be missing it.
495
496 Parameters
497 ----------
498 catalog_ref
499 A reference catalog.
500 catalog_target
501 A target catalog.
502 skymap
503 The skymap info to use.
504 add_patch
505 Whether to add a patch column as well.
506 """
507 errors = []
508 if compute_tract_target := ("tract" not in catalog_target.colnames):
509 if self.config.target_sharding_type != "none":
510 errors.append(
511 f"Target catalog has no tract column with {self.config.target_sharding_type=} != 'none'"
512 )
513 if compute_tract_ref := ("tract" not in catalog_ref.colnames):
514 if self.config.refcat_sharding_type != "none":
515 errors.append(
516 f"Ref catalog has no tract column with {self.config.refcat_sharding_type=} != 'none'"
517 )
518 if errors:
519 raise RuntimeError("; ".join(errors))
520 compute_patch_ref = add_patch and ("patch" not in catalog_ref.colnames)
521 compute_patch_target = add_patch and ("patch" not in catalog_target.colnames)
522 unit_dict = self._astropy_u_to_lsst_geom.copy()
523 if compute_tract_target or compute_tract_ref or compute_patch_target or compute_patch_ref:
524 if not self.config.diff_matched_catalog.coord_format.coords_spherical:
525 raise RuntimeError(
526 f"Can't compute tract columns with unless"
527 f" {self.config.diff_matched_catalog.coord_format.coords_spherical} == True"
528 )
529 ref_c, target_c = self.config.diff_matched_catalog.coord_format.format_catalogs(
530 catalog_ref=catalog_ref, catalog_target=catalog_target,
531 )
532 cats_add = []
533 if compute_tract_target or compute_patch_target:
534 cats_add.append((
535 target_c, catalog_target, "target", compute_tract_target, compute_patch_target
536 ))
537 if compute_tract_ref:
538 cats_add.append((ref_c, catalog_ref, "ref", compute_tract_ref, compute_patch_ref))
539 for cat_c, catalog_add, name_c, compute_tract, compute_patch in cats_add:
540 if (unit := getattr(catalog_add[cat_c.column_coord1], "unit")) is None:
541 unit = self.config.coord_unit
542 if unit is None:
543 raise RuntimeError(
544 f"Must specify coord_unit since {name_c} column={cat_c.column_coord1}"
545 f" has no units"
546 )
547 unit = getattr(lsst.geom, unit, getattr(lsst.geom, unit_dict[str(unit)]))
548 else:
549 unit = getattr(lsst.geom, unit_dict[str(unit)])
550 coords = [SpherePoint(ra, dec, unit) for ra, dec in zip(cat_c.coord1, cat_c.coord2)]
551 if compute_tract:
552 catalog_add["tract"] = np.array([skymap.findTract(coord).getId() for coord in coords])
553 if compute_patch:
554 tracts = catalog_add["tract"]
555 patches = np.array([
556 skymap[tract].findPatch(coord).getSequentialIndex()
557 for coord, tract in zip(coords, tracts)
558 ])
559 catalog_add["patch"] = patches
tuple[dict[str, None], dict[str, None]] get_columns_ordered_in(self)
pipeBase.Struct run(self, pd.DataFrame|astropy.table.Table catalog_ref, pd.DataFrame|astropy.table.Table catalog_target, afwGeom.SkyWcs wcs=None)
pipeBase.Struct run(self, pd.DataFrame|astropy.table.Table catalog_ref, pd.DataFrame|astropy.table.Table catalog_target, afwGeom.SkyWcs wcs=None)
_add_tract_column_to_catalogs(self, catalog_ref, catalog_target, skymap, bool add_patch=True)