lsst.meas.astrom gfced8664c3+05157bd769
Loading...
Searching...
No Matches
matcher_probabilistic.py
Go to the documentation of this file.
1# This file is part of meas_astrom.
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 "CatalogExtras", "ComparableCatalog", "ConvertCatalogCoordinatesConfig", "MatchProbabilisticConfig",
24 "MatcherProbabilistic",
25]
26
27from dataclasses import dataclass
28import logging
29import time
30from typing import Callable
31import warnings
32
33import astropy.table
34import lsst.pex.config as pexConfig
35import numpy as np
36from scipy.spatial import cKDTree
37from smatch.matcher import Matcher
38
39logger_default = logging.getLogger(__name__)
40
41
42def _mul_column(column: np.array, value: float):
43 if value is not None and value != 1:
44 column *= value
45 return column
46
47
48def _radec_to_xyz(ra, dec):
49 """Convert input ra/dec coordinates to spherical unit vectors.
50
51 Parameters
52 ----------
53 ra, dec: `numpy.ndarray`
54 Arrays of right ascension/declination in degrees.
55
56 Returns
57 -------
58 vectors : `numpy.ndarray`, (N, 3)
59 Output unit vectors.
60 """
61 if ra.size != dec.size:
62 raise ValueError('ra and dec must be same size')
63 ras = np.radians(ra)
64 decs = np.radians(dec)
65 vectors = np.empty((ras.size, 3))
66
67 sin_dec = np.sin(np.pi / 2 - decs)
68 vectors[:, 0] = sin_dec * np.cos(ras)
69 vectors[:, 1] = sin_dec * np.sin(ras)
70 vectors[:, 2] = np.cos(np.pi / 2 - decs)
71
72 return vectors
73
74
75@dataclass
77 """Store frequently-reference (meta)data relevant for matching a catalog.
78
79 Parameters
80 ----------
81 catalog : `astropy.table.Table`
82 A Table to store extra information for.
83 select : `numpy.array`
84 A numpy boolean array of the same length as catalog to be used for
85 target selection.
86 """
87
88 n: int
89 indices: np.array
90 select: np.array
91
92 coordinate_factor: float = None
93
95 self,
96 catalog: astropy.table.Table,
97 select: np.array = None,
98 coordinate_factor: float = None,
99 ):
100 self.n = len(catalog)
101 self.select = np.ones(self.n, dtype=bool) if select is None else select
102 self.indices = np.flatnonzero(select) if select is not None else np.arange(self.n)
103 self.coordinate_factor = coordinate_factor
104
105
106@dataclass(frozen=True)
108 """A catalog with sources with coordinate columns in some standard format/units.
109
110 catalog : `astropy.table.Table`
111 A catalog with comparable coordinate columns.
112 column_coord1 : `str`
113 The first spatial coordinate column name.
114 column_coord2 : `str`
115 The second spatial coordinate column name.
116 coord1 : `numpy.array`
117 The first spatial coordinate values.
118 coord2 : `numpy.array`
119 The second spatial coordinate values.
120 extras : `CatalogExtras`
121 Extra cached (meta)data for the `catalog`.
122 """
123
124 catalog: astropy.table.Table
125 column_coord1: str
126 column_coord2: str
127 coord1: np.array
128 coord2: np.array
129 extras: CatalogExtras
130
131
132class ConvertCatalogCoordinatesConfig(pexConfig.Config):
133 """Configuration for the MatchProbabilistic matcher."""
134
135 column_ref_coord1 = pexConfig.Field[str](
136 default='ra',
137 doc='The reference table column for the first spatial coordinate (usually x or ra).',
138 )
139 column_ref_coord2 = pexConfig.Field[str](
140 default='dec',
141 doc='The reference table column for the second spatial coordinate (usually y or dec).'
142 'Units must match column_ref_coord1.',
143 )
144 column_target_coord1 = pexConfig.Field[str](
145 default='coord_ra',
146 doc='The target table column for the first spatial coordinate (usually x or ra).'
147 'Units must match column_ref_coord1.',
148 )
149 column_target_coord2 = pexConfig.Field[str](
150 default='coord_dec',
151 doc='The target table column for the second spatial coordinate (usually y or dec).'
152 'Units must match column_ref_coord2.',
153 )
154 coords_spherical = pexConfig.Field[bool](
155 default=True,
156 doc='Whether column_*_coord[12] are spherical coordinates (ra/dec) or not (pixel x/y).',
157 )
158 coords_ref_factor = pexConfig.Field[float](
159 default=1.0,
160 doc='Multiplicative factor for reference catalog coordinates.'
161 'If coords_spherical is true, this must be the number of degrees per unit increment of '
162 'column_ref_coord[12]. Otherwise, it must convert the coordinate to the same units'
163 ' as the target coordinates.',
164 )
165 coords_target_factor = pexConfig.Field[float](
166 default=1.0,
167 doc='Multiplicative factor for target catalog coordinates.'
168 'If coords_spherical is true, this must be the number of degrees per unit increment of '
169 'column_target_coord[12]. Otherwise, it must convert the coordinate to the same units'
170 ' as the reference coordinates.',
171 )
172 coords_ref_to_convert = pexConfig.DictField[str, str](
173 default=None,
174 optional=True,
175 dictCheck=lambda x: len(x) == 2,
176 doc='Dict mapping sky coordinate columns to be converted to pixel columns.',
177 )
178 mag_zeropoint_ref = pexConfig.Field[float](
179 default=31.4,
180 doc='Magnitude zeropoint for reference catalog.',
181 )
182 return_converted_coords = pexConfig.Field[float](
183 default=True,
184 doc='Whether to return converted coordinates for matching or only write them.',
185 )
186
188 self,
189 catalog_ref: astropy.table.Table,
190 catalog_target: astropy.table.Table,
191 select_ref: np.array = None,
192 select_target: np.array = None,
193 radec_to_xy_func: Callable = None,
194 **kwargs,
195 ):
196 """Format matched catalogs that may require coordinate conversions.
197
198 Parameters
199 ----------
200 catalog_ref : `astropy.table.Table`
201 A reference catalog for comparison to `catalog_target`.
202 catalog_target : `astropy.table.Table`
203 A target catalog with measurements for comparison to `catalog_ref`.
204 select_ref : `numpy.ndarray`, (Nref,)
205 A boolean array of len `catalog_ref`, True for valid match candidates.
206 select_target : `numpy.ndarray`, (Ntarget,)
207 A boolean array of len `catalog_target`, True for valid match candidates.
208 radec_to_xy_func : `typing.Callable`
209 Function taking equal-length ra, dec arrays and returning an ndarray of
210 - ``x``: current parameter (`float`).
211 - ``extra_args``: additional arguments (`dict`).
212 kwargs
213 Additional keyword arguments to pass to radec_to_xy_func.
214
215 Returns
216 -------
217 compcat_ref, compcat_target : `ComparableCatalog`
218 Comparable catalogs corresponding to the input reference and target.
219 """
220 convert_ref = self.coords_ref_to_convert
221 if convert_ref and not callable(radec_to_xy_func):
222 raise TypeError('radec_to_xy_func must be callable if converting ref coords')
223
224 # Set up objects with frequently-used attributes like selection bool array
225 extras_ref, extras_target = (
226 CatalogExtras(catalog, select=select, coordinate_factor=coord_factor)
227 for catalog, select, coord_factor in zip(
228 (catalog_ref, catalog_target),
229 (select_ref, select_target),
231 )
232 )
233
234 compcats = []
235
236 # Retrieve coordinates and multiply them by scaling factors
237 for catalog, extras, (column1, column2), convert in (
238 (catalog_ref, extras_ref, (self.column_ref_coord1, self.column_ref_coord2), convert_ref),
239 (catalog_target, extras_target, (self.column_target_coord1, self.column_target_coord2), False),
240 ):
241 coord1, coord2 = (
242 _mul_column(catalog[column], extras.coordinate_factor)
243 for column in (column1, column2)
244 )
245 if convert:
246 xy_ref = radec_to_xy_func(coord1, coord2, self.coords_ref_factor, **kwargs)
247 for idx_coord, column_out in enumerate(self.coords_ref_to_convert.values()):
248 coord = np.array([xy[idx_coord] for xy in xy_ref])
249 catalog[column_out] = coord
250 if convert_ref:
251 column1, column2 = self.coords_ref_to_convert.values()
253 coord1, coord2 = catalog[column1], catalog[column2]
254
255 compcats.append(ComparableCatalog(
256 catalog=catalog, column_coord1=column1, column_coord2=column2,
257 coord1=coord1, coord2=coord2, extras=extras,
258 ))
259
260 return compcats[0], compcats[1]
261
262
263class MatchProbabilisticConfig(pexConfig.Config):
264 """Configuration for the MatchProbabilistic matcher."""
265
266 column_ref_order = pexConfig.Field(
267 dtype=str,
268 default=None,
269 optional=True,
270 doc='Name of column in reference catalog specifying order for matching'
271 ' Derived from columns_ref_flux if not set.',
272 )
273
274 @property
275 def columns_in_ref(self) -> set[str]:
276 return set(self.columns_ordered_in_ref)
277
278 @property
279 def columns_ordered_in_ref(self) -> dict[str, None]:
280 columns_all = [
281 self.coord_format.column_ref_coord1,
282 self.coord_format.column_ref_coord2,
283 ]
284 for columns in (
285 self.columns_ref_flux,
286 self.columns_ref_meas,
289 self.columns_ref_copy,
290 ):
291 columns_all.extend(columns)
292 if self.column_ref_order:
293 columns_all.append(self.column_ref_order)
294
295 return {k: None for k in columns_all}
296
297 @property
298 def columns_in_target(self) -> set[str]:
300
301 @property
302 def columns_ordered_in_target(self) -> dict[str, None]:
303 columns_all = [
304 self.coord_format.column_target_coord1,
305 self.coord_format.column_target_coord2,
306 ]
307 for columns in (
313 ):
314 columns_all.extend(columns)
315 return {k: None for k in columns_all}
316
317 columns_ref_copy = pexConfig.ListField(
318 dtype=str,
319 default=[],
320 listCheck=lambda x: len(set(x)) == len(x),
321 optional=True,
322 doc='Reference table columns to copy unchanged into both match tables',
323 )
324 columns_ref_flux = pexConfig.ListField(
325 dtype=str,
326 default=[],
327 optional=True,
328 doc="List of reference flux columns to nansum total magnitudes from if column_order is None",
329 )
330 columns_ref_meas = pexConfig.ListField(
331 dtype=str,
332 doc='The reference table columns to compute match likelihoods from '
333 '(usually centroids and fluxes/magnitudes)',
334 )
335 columns_ref_select_true = pexConfig.ListField(
336 dtype=str,
337 default=tuple(),
338 doc='Reference table columns to require to be True for selecting sources',
339 )
340 columns_ref_select_false = pexConfig.ListField(
341 dtype=str,
342 default=tuple(),
343 doc='Reference table columns to require to be False for selecting sources',
344 )
345 columns_target_copy = pexConfig.ListField(
346 dtype=str,
347 default=[],
348 listCheck=lambda x: len(set(x)) == len(x),
349 optional=True,
350 doc='Target table columns to copy unchanged into both match tables',
351 )
352 columns_target_meas = pexConfig.ListField(
353 dtype=str,
354 doc='Target table columns with measurements corresponding to columns_ref_meas',
355 )
356 columns_target_err = pexConfig.ListField(
357 dtype=str,
358 doc='Target table columns with standard errors (sigma) corresponding to columns_ref_meas',
359 )
360 columns_target_select_true = pexConfig.ListField(
361 dtype=str,
362 default=[],
363 doc='Target table columns to require to be True for selecting sources',
364 )
365 columns_target_select_false = pexConfig.ListField(
366 dtype=str,
367 default=[],
368 doc='Target table columns to require to be False for selecting sources',
369 )
370 coord_format = pexConfig.ConfigField(
371 dtype=ConvertCatalogCoordinatesConfig,
372 doc="Configuration for coordinate conversion",
373 )
374 mag_brightest_ref = pexConfig.Field(
375 dtype=float,
376 default=-np.inf,
377 doc='Bright magnitude cutoff for selecting reference sources to match.'
378 ' Ignored if column_ref_order is None.'
379 )
380 mag_faintest_ref = pexConfig.Field(
381 dtype=float,
382 default=np.inf,
383 doc='Faint magnitude cutoff for selecting reference sources to match.'
384 ' Ignored if column_ref_order is None.'
385 )
386 match_dist_max = pexConfig.Field(
387 dtype=float,
388 default=0.5,
389 doc='Maximum match distance. Units must be arcseconds if coords_spherical, '
390 'or else match those of column_*_coord[12] multiplied by coords_*_factor.',
391 )
392 match_n_max = pexConfig.Field(
393 dtype=int,
394 default=10,
395 optional=True,
396 doc='Maximum number of spatial matches to consider (in ascending distance order).',
397 check=lambda x: x >= 1,
398 )
399 match_n_finite_min = pexConfig.Field(
400 dtype=int,
401 default=2,
402 optional=True,
403 doc='Minimum number of columns with a finite value to measure match likelihood',
404 check=lambda x: x >= 2,
405 )
406 order_ascending = pexConfig.Field(
407 dtype=bool,
408 default=False,
409 optional=True,
410 doc='Whether to order reference match candidates in ascending order of column_ref_order '
411 '(should be False if the column is a flux and True if it is a magnitude.',
412 )
413
414 def validate(self):
415 super().validate()
416 n_ref_meas = len(self.columns_ref_meas)
417 n_target_meas = len(self.columns_target_meas)
418 n_target_err = len(self.columns_target_err)
419 match_n_finite_min = self.match_n_finite_min
420 errors = []
421 if n_target_meas != n_ref_meas:
422 errors.append(f"{len(self.columns_target_meas)=} != {len(self.columns_ref_meas)=}")
423 if n_target_err != n_ref_meas:
424 errors.append(f"{len(self.columns_target_err)=} != {len(self.columns_ref_meas)=}")
425 if not (n_ref_meas >= match_n_finite_min):
426 errors.append(
427 f"{len(self.columns_ref_meas)=} !>= {self.match_n_finite_min=}, no matches possible"
428 )
429 if self.column_ref_order is None:
430 if len(self.columns_ref_flux) == 0:
431 errors.append("Must specify one of columns_ref_flux or column_ref_order")
432 if errors:
433 raise ValueError("\n".join(errors))
434
435
436def default_value(dtype):
437 if dtype is str:
438 return ''
439 elif np.issubdtype(dtype, np.signedinteger):
440 return np.inf
441 elif np.issubdtype(dtype, np.unsignedinteger):
442 return -np.inf
443 return None
444
445
447 """A probabilistic, greedy catalog matcher.
448
449 Parameters
450 ----------
451 config: `MatchProbabilisticConfig`
452 A configuration instance.
453 """
454
455 config: MatchProbabilisticConfig
456
458 self,
459 config: MatchProbabilisticConfig,
460 ):
461 self.config = config
462
463 def match(
464 self,
465 catalog_ref: astropy.table.Table,
466 catalog_target: astropy.table.Table,
467 select_ref: np.array = None,
468 select_target: np.array = None,
469 logger: logging.Logger = None,
470 logging_n_rows: int = None,
471 **kwargs
472 ):
473 """Match catalogs.
474
475 Parameters
476 ----------
477 catalog_ref : `astropy.table.Table`
478 A reference catalog to match in order of a given column (i.e. greedily).
479 catalog_target : `astropy.table.Table`
480 A target catalog for matching sources from `catalog_ref`. Must contain measurements with errors.
481 select_ref : `numpy.array`
482 A boolean array of the same length as `catalog_ref` selecting the sources that can be matched.
483 select_target : `numpy.array`
484 A boolean array of the same length as `catalog_target` selecting the sources that can be matched.
485 logger : `logging.Logger`
486 A Logger for logging.
487 logging_n_rows : `int`
488 The number of sources to match before printing a log message.
489 kwargs
490 Additional keyword arguments to pass to `format_catalogs`.
491
492 Returns
493 -------
494 catalog_out_ref : `astropy.table.Table`
495 A catalog of identical length to `catalog_ref`, containing match information for rows selected by
496 `select_ref` (including the matching row index in `catalog_target`).
497 catalog_out_target : `astropy.table.Table`
498 A catalog of identical length to `catalog_target`, containing the indices of matching rows in
499 `catalog_ref`.
500 exceptions : `dict` [`int`, `Exception`]
501 A dictionary keyed by `catalog_target` row number of the first exception caught when matching.
502 """
503 if logger is None:
504 logger = logger_default
505
506 t_init = time.process_time()
507 config = self.config
508
509 # Transform any coordinates, if required
510 # Note: The returned objects contain the original catalogs, as well as
511 # transformed coordinates, and the selection of sources for matching.
512 # These might be identical to the arrays passed as kwargs, but that
513 # depends on config settings.
514 # For the rest of this function, the selection arrays will be used,
515 # but the indices of the original, unfiltered catalog will also be
516 # output, so some further indexing steps are needed.
517 with warnings.catch_warnings():
518 # We already issued a deprecation warning; no need to repeat it.
519 warnings.filterwarnings(action="ignore", category=FutureWarning)
520 ref, target = config.coord_format.format_catalogs(
521 catalog_ref=catalog_ref, catalog_target=catalog_target,
522 select_ref=select_ref, select_target=select_target,
523 **kwargs
524 )
525
526 # If no order is specified, take nansum of all flux columns for a 'total flux'
527 # Note: it won't actually be a total flux if bands overlap significantly
528 # (or it might define a filter with >100% efficiency
529 column_order = (
530 catalog_ref[config.column_ref_order][ref.extras.select]
531 if config.column_ref_order is not None else
532 np.nansum([catalog_ref[col][ref.extras.select] for col in config.columns_ref_flux], axis=0)
533 )
534 order = np.argsort(column_order if config.order_ascending else -column_order)
535
536 n_ref_select = len(ref.extras.indices)
537
538 coords_spherical = config.coord_format.coords_spherical
539 coords_ref, coords_target = (
540 (cat.coord1[cat.extras.select], cat.coord2[cat.extras.select])
541 for cat in (ref, target)
542 )
543
544 # Generate K-d tree to compute distances
545 logger.info('Generating cKDTree with match_n_max=%d', config.match_n_max)
546
547 if coords_spherical:
548 match_dist_max = config.match_dist_max/3600.
549 with Matcher(coords_target[0], coords_target[1]) as matcher:
550 idxs_target_select = matcher.query_knn(
551 coords_ref[0], coords_ref[1],
552 distance_upper_bound=match_dist_max,
553 k=config.match_n_max,
554 )
555 # Call scipy for non-spherical case
556 # The spherical case won't trigger, but the implementation is left for comparison, if needed
557 else:
558 match_dist_max = np.radians(config.match_dist_max/3600.)
559 # Convert ra/dec sky coordinates to spherical vectors for accurate distances
560 func_convert = _radec_to_xyz if coords_spherical else np.vstack
561 vec_ref, vec_target = (
562 func_convert(coords[0], coords[1])
563 for coords in (coords_ref, coords_target)
564 )
565 tree_obj = cKDTree(vec_target)
566 _, idxs_target_select = tree_obj.query(
567 vec_ref,
568 distance_upper_bound=match_dist_max,
569 k=config.match_n_max,
570 )
571
572 n_target_select = len(target.extras.indices)
573 n_matches = np.sum(idxs_target_select != n_target_select, axis=1)
574 n_matched_max = np.sum(n_matches == config.match_n_max)
575 if n_matched_max > 0:
576 logger.warning(
577 '%d/%d (%.2f%%) selected true objects have n_matches=n_match_max(%d)',
578 n_matched_max, n_ref_select, 100.*n_matched_max/n_ref_select, config.match_n_max
579 )
580
581 # Pre-allocate outputs
582 target_row_match = np.full(target.extras.n, np.iinfo(np.int64).min, dtype=np.int64)
583 ref_candidate_match = np.zeros(ref.extras.n, dtype=bool)
584 ref_row_match = np.full(ref.extras.n, np.iinfo(np.int64).min, dtype=np.int64)
585 ref_match_count = np.zeros(ref.extras.n, dtype=np.int32)
586 ref_match_meas_finite = np.zeros(ref.extras.n, dtype=np.int32)
587 ref_chisq = np.full(ref.extras.n, np.nan, dtype=float)
588
589 # Need the original reference row indices for output
590 idx_orig_ref, idx_orig_target = (np.argwhere(cat.extras.select)[:, 0] for cat in (ref, target))
591
592 # Retrieve required columns, including any converted ones (default to original column name)
593 columns_convert = config.coord_format.coords_ref_to_convert
594 if columns_convert is None:
595 columns_convert = {}
596 data_ref = np.array([
597 ref.catalog[columns_convert.get(column, column)][ref.extras.indices[order]]
598 for column in config.columns_ref_meas
599 ])
600 data_target = np.array([
601 target.catalog[col][target.extras.select] for col in config.columns_target_meas
602 ])
603 errors_target = np.array([
604 target.catalog[col][target.extras.select] for col in config.columns_target_err
605 ])
606
607 exceptions = {}
608 # The kdTree uses len(inputs) as a sentinel value for no match
609 matched_target = {n_target_select, }
610 index_ref = idx_orig_ref[order]
611 # Fill in the candidate column
612 ref_candidate_match[index_ref] = True
613
614 # Count this as the time when disambiguation begins
615 t_begin = time.process_time()
616
617 # Exclude unmatched sources
618 matched_ref = idxs_target_select[order, 0] != n_target_select
619 order = order[matched_ref]
620 idx_first = idxs_target_select[order, 0]
621 data_ref_matched = data_ref[:, matched_ref]
622 chi_0 = (data_target[:, idx_first] - data_ref_matched)/errors_target[:, idx_first]
623 chi_finite_0 = np.isfinite(chi_0)
624 n_finite_0 = np.sum(chi_finite_0, axis=0)
625 chi_0[~chi_finite_0] = 0
626 chisq_sum_0 = np.sum(chi_0*chi_0, axis=0)
627 n_meas = len(config.columns_ref_meas)
628 n_ambiguous = 0
629
630 logger.info('Disambiguating %d/%d matches/targets', len(order), len(ref.catalog))
631 for index_n, index_row_select in enumerate(order):
632 index_row = idx_orig_ref[index_row_select]
633 found = idxs_target_select[index_row_select, :]
634 # Unambiguous match, short-circuit some evaluations
635 if (found[1] == n_target_select) and (found[0] not in matched_target):
636 n_finite = n_finite_0[index_n]
637 if not (n_finite >= config.match_n_finite_min):
638 continue
639 idx_chisq_min = 0
640 n_matched = 1
641 chisq_sum = chisq_sum_0[index_n]
642 else:
643 # Select match candidates from nearby sources not already matched
644 # Note: set lookup is apparently fast enough that this is a few percent faster than:
645 # found = [x for x in found[found != n_target_select] if x not in matched_target]
646 # ... at least for ~1M sources
647 found = [x for x in found if x not in matched_target]
648 n_found = len(found)
649 if n_found == 0:
650 continue
651 # This is an ndarray of n_found rows x len(data_ref/target) columns
652 chi = (
653 data_target[:, found] - data_ref_matched[:, index_n].reshape((n_meas, 1))
654 )/errors_target[:, found]
655 finite = np.isfinite(chi)
656 n_finite = np.sum(finite, axis=0)
657 # Require some number of finite chi_sq to match
658 chisq_good = n_finite >= config.match_n_finite_min
659 if not any(chisq_good):
660 continue
661 try:
662 chisq_sum = np.zeros(n_found, dtype=float)
663 chisq_sum[chisq_good] = np.nansum(chi[:, chisq_good] ** 2, axis=0)
664 idx_chisq_min = np.nanargmin(chisq_sum / n_finite)
665 n_finite = n_finite[idx_chisq_min]
666 n_matched = len(chisq_good)
667 chisq_sum = chisq_sum[idx_chisq_min]
668 n_ambiguous += 1
669 except Exception as error:
670 # Can't foresee any exceptions, but they shouldn't prevent
671 # matching subsequent sources
672 exceptions[index_row] = error
673 ref_match_meas_finite[index_row] = n_finite
674 ref_match_count[index_row] = n_matched
675 ref_chisq[index_row] = chisq_sum
676 idx_match_select = found[idx_chisq_min]
677 row_target = target.extras.indices[idx_match_select]
678 ref_row_match[index_row] = row_target
679
680 target_row_match[row_target] = index_row
681 matched_target.add(idx_match_select)
682
683 if logging_n_rows and ((index_n + 1) % logging_n_rows == 0):
684 t_elapsed = time.process_time() - t_begin
685 logger.info(
686 'Processed %d/%d in %.2fs at sort value=%.3f',
687 index_n + 1, n_ref_select, t_elapsed, column_order[order[index_n]],
688 )
689
690 data_ref = {
691 'match_candidate': ref_candidate_match,
692 'match_row': ref_row_match,
693 'match_count': ref_match_count,
694 'match_chi2': ref_chisq,
695 'match_n_chi2_finite': ref_match_meas_finite,
696 }
697 data_target = {
698 'match_candidate': target.extras.select if target.extras.select is not None else (
699 np.ones(target.extras.n, dtype=bool)),
700 'match_row': target_row_match,
701 }
702
703 for (columns, out_original, out_matched, in_original, in_matched, matches, name_cat) in (
704 (
705 self.config.columns_ref_copy,
706 data_ref,
707 data_target,
708 ref,
709 target,
710 target_row_match,
711 'target',
712 ),
713 (
714 self.config.columns_target_copy,
715 data_target,
716 data_ref,
717 target,
718 ref,
719 ref_row_match,
720 'reference',
721 ),
722 ):
723 matched = matches >= 0
724 idx_matched = matches[matched]
725 logger.info('Matched %d/%d %s sources', np.sum(matched), len(matched), name_cat)
726
727 for column in columns:
728 values = in_original.catalog[column]
729 out_original[column] = values
730 dtype = in_original.catalog[column].dtype
731
732 # Pandas object columns can have mixed types - check for that
733 if dtype is object:
734 types = list(set((type(x) for x in values)))
735 if len(types) != 1:
736 raise RuntimeError(f'Column {column} dtype={dtype} has multiple types={types}')
737 dtype = types[0]
738
739 value_fill = default_value(dtype)
740
741 # Without this, the dtype would be '<U1' for an empty Unicode string
742 if dtype is str:
743 dtype = f'<U{max(len(x) for x in values)}'
744
745 column_match = np.full(in_matched.extras.n, value_fill, dtype=dtype)
746 column_match[matched] = in_original.catalog[column][idx_matched]
747 out_matched[f'match_{column}'] = column_match
748
749 logger.info(
750 'Completed match disambiguating in %.2fs (total %.2fs) with %d disambiguated',
751 time.process_time() - t_begin,
752 time.process_time() - t_init,
753 n_ambiguous,
754 )
755
756 catalog_out_ref = astropy.table.Table(data_ref)
757 for column, description in {
758 'match_candidate': 'Whether the object was selected as a candidate for matching',
759 'match_row': 'The index of the best matched row in the target table, if any',
760 'match_count': 'The number of candidate matching target objects, i.e. those within the match'
761 ' distance but excluding objects already matched to a ref object',
762 'match_chi2': 'The sum of all finite reduced chi-squared values over all match columns',
763 'match_n_chi2_finite': 'The number of match columns with finite chi-squared',
764 }.items():
765 catalog_out_ref[column].description = description
766 catalog_out_target = astropy.table.Table(data_target)
767
768 return catalog_out_ref, catalog_out_target, exceptions
__init__(self, astropy.table.Table catalog, np.array select=None, float coordinate_factor=None)
format_catalogs(self, astropy.table.Table catalog_ref, astropy.table.Table catalog_target, np.array select_ref=None, np.array select_target=None, Callable radec_to_xy_func=None, **kwargs)
match(self, astropy.table.Table catalog_ref, astropy.table.Table catalog_target, np.array select_ref=None, np.array select_target=None, logging.Logger logger=None, int logging_n_rows=None, **kwargs)