diff --git a/src/hpmcm/cell.py b/src/hpmcm/cell.py index a76e39b..01b69f9 100644 --- a/src/hpmcm/cell.py +++ b/src/hpmcm/cell.py @@ -232,7 +232,7 @@ def _newObject( return ObjectData(cluster, object_id, mask) def _emptyCountsMaps(self) -> np.ndarray: - to_fill = np.zeros(np.ceil(self.n_pix).astype(int)) + to_fill = np.zeros(np.ceil(self.n_pix / self.pixel_match_scale).astype(int)) return to_fill def _singleCatalogCountsMap( @@ -242,6 +242,7 @@ def _singleCatalogCountsMap( df, n_pix=self.n_pix, weight_name=weight_name, + pixel_match_scale=self.pixel_match_scale, ) def _buildClusterData( @@ -250,7 +251,7 @@ def _buildClusterData( return ClusterData(i_cluster, footprint, sources, pixel_match_scale=self.pixel_match_scale) def _getFootprints(self, counts_map: np.ndarray) -> dict: - return utils.getFootprints(counts_map, buf=self.buf) + return utils.getFootprints(counts_map, buf=self.buf, pixel_match_scale=self.pixel_match_scale) def _associateSourcesToFootprints( self, @@ -260,6 +261,7 @@ def _associateSourcesToFootprints( return utils.associateSourcesToFootprints( data, cluster_key, + pixel_match_scale=self.pixel_match_scale, ) def getRaDec( @@ -308,21 +310,6 @@ def _newObject( ) -> ObjectData: return ShearObjectData(cluster, object_id, mask) - def _emptyCountsMaps(self) -> np.ndarray: - pixel_match_scale = self.pixel_match_scale - to_fill = np.zeros(np.ceil(self.n_pix / pixel_match_scale).astype(int)) - return to_fill - - def _singleCatalogCountsMap( - self, df: pandas.DataFrame, weight_name: str | None = None - ) -> np.ndarray: - return utils.fillCountsMapFromDf( - df, - n_pix=self.n_pix, - weight_name=weight_name, - pixel_match_scale=self.pixel_match_scale, - ) - def _buildClusterData( self, i_cluster: int, footprint: Footprint, sources: np.ndarray ) -> ClusterData: @@ -339,14 +326,3 @@ def _getFootprints(self, counts_map: np.ndarray) -> dict: counts_map, buf=0, pixel_match_scale=self.pixel_match_scale ) - def _associateSourcesToFootprints( - self, - data: list[pandas.DataFrame], - cluster_key: np.ndarray, - ) -> list[np.ndarray]: - return utils.associateSourcesToFootprints( - data, - cluster_key, - pixel_match_scale=self.pixel_match_scale, - ) - diff --git a/src/hpmcm/match.py b/src/hpmcm/match.py index fdbaed2..80958bd 100644 --- a/src/hpmcm/match.py +++ b/src/hpmcm/match.py @@ -291,9 +291,40 @@ def analysisLoop( sys.stdout.write(" Done!\n") sys.stdout.flush() - def extractStats(self) -> dict[str, pandas.DataFrame]: + def _getCentralIds( + self, cell_data: CellData + ) -> tuple[frozenset[int], frozenset[int]]: + """Return (object_ids, cluster_ids) whose centroids lie in the inner cell region. + + The inner region is ``[0, cell_data.size[i])`` in x_cell_coadd coordinates, + which corresponds to ``[cell_data.buf, cell_data.buf + cell_data.size[i])`` + in the internal x_cell convention stored on each object/cluster. + """ + lo_x, hi_x = float(cell_data.buf), float(cell_data.buf + cell_data.size[0]) + lo_y, hi_y = float(cell_data.buf), float(cell_data.buf + cell_data.size[1]) + obj_ids = frozenset( + oid + for oid, obj in cell_data.object_dict.items() + if lo_x <= obj.x_cent < hi_x and lo_y <= obj.y_cent < hi_y + ) + clust_ids = frozenset( + cid + for cid, cl in cell_data.cluster_dict.items() + if lo_x <= cl.x_cent < hi_x and lo_y <= cl.y_cent < hi_y + ) + return obj_ids, clust_ids + + def extractStats(self, central_only: bool = True) -> dict[str, pandas.DataFrame]: """Extracts cluster statisistics + Parameters + ---------- + central_only: + When ``True`` (default), exclude objects and clusters whose centroid + (in x_cell_coadd coordinates) falls outside the inner cell region + ``[0, cell_size)``. This avoids double-counting edge objects that + are also captured by adjacent cells. + Returns ------- Dict with keys: @@ -314,18 +345,23 @@ def extractStats(self) -> dict[str, pandas.DataFrame]: if i_cell not in self.cell_dict: continue cell_data = self.cell_dict[i_cell] - cluster_assoc_tables.append( - output_tables.ClusterAssocTable.buildFromCellData(cell_data).data, - ) - object_assoc_tables.append( - output_tables.ObjectAssocTable.buildFromCellData(cell_data).data, - ) - cluster_stats_tables.append( - output_tables.ClusterStatsTable.buildFromCellData(cell_data).data, - ) - object_stats_tables.append( - output_tables.ObjectStatsTable.buildFromCellData(cell_data).data, - ) + + ca = output_tables.ClusterAssocTable.buildFromCellData(cell_data).data + oa = output_tables.ObjectAssocTable.buildFromCellData(cell_data).data + cs = output_tables.ClusterStatsTable.buildFromCellData(cell_data).data + os_ = output_tables.ObjectStatsTable.buildFromCellData(cell_data).data + + if central_only: + c_obj, c_clust = self._getCentralIds(cell_data) + ca = ca[ca["cluster_id"].isin(c_clust)] + oa = oa[oa["object_id"].isin(c_obj)] + cs = cs[cs["cluster_id"].isin(c_clust)] + os_ = os_[os_["object_id"].isin(c_obj)] + + cluster_assoc_tables.append(ca) + object_assoc_tables.append(oa) + cluster_stats_tables.append(cs) + object_stats_tables.append(os_) if ix == 0: pass elif ix % 10 == 0: @@ -370,7 +406,7 @@ def _geometryDict(self) -> dict[str, Any]: "catalog_id_map": {str(k): int(v) for k, v in self.catalog_id_map.items()}, } - def save(self, save_dir: str | Path) -> None: + def save(self, save_dir: str | Path, central_only: bool = True) -> None: """Persist the full match state to a directory. Saves geometry, association/stats tables, and one parquet per catalog @@ -381,6 +417,10 @@ def save(self, save_dir: str | Path) -> None: ---------- save_dir: Directory to write into (created if absent). + central_only: + Passed through to :meth:`extractStats`; when ``True`` (default), + only objects and clusters whose centroid lies in the inner cell + region are written. """ save_dir = Path(save_dir) save_dir.mkdir(parents=True, exist_ok=True) @@ -388,7 +428,7 @@ def save(self, save_dir: str | Path) -> None: with open(save_dir / "geometry.json", "w") as fh: json.dump(_to_json_safe(self._geometryDict()), fh, indent=2) - stats = self.extractStats() + stats = self.extractStats(central_only=central_only) for name, df in stats.items(): df.to_parquet(save_dir / f"{name}.parquet") @@ -560,8 +600,10 @@ def _reconstructCells( cluster = cell._buildClusterData(cluster_id, fp, sources) cluster.extract(cell) - cluster.x_cent = float(sr["x_cent"]) - cluster.y_cent = float(sr["y_cent"]) + # x_cent saved in x_cell_coadd convention (0 at inner edge); + # restore to x_cell convention (0 at outer edge) for internal use + cluster.x_cent = float(sr["x_cent"]) + cell.buf + cluster.y_cent = float(sr["y_cent"]) + cell.buf cluster.dist_2 = (ca_rows["distance"].values / pix_to_arcsec) ** 2 cluster.rms_dist = float(sr["dist_rms"]) / pix_to_arcsec cluster.snr_mean = float(sr["snr"]) @@ -575,8 +617,9 @@ def _reconstructCells( obj = cell._newObject(cluster, obj_id, mask) or_ = os_idx.loc[obj_id] obj.dist_2 = (obj_rows["distance"].values / pix_to_arcsec) ** 2 - obj.x_cent = float(or_["x_cent"]) - obj.y_cent = float(or_["y_cent"]) + # x_cent saved in x_cell_coadd convention; restore to x_cell + obj.x_cent = float(or_["x_cent"]) + cell.buf + obj.y_cent = float(or_["y_cent"]) + cell.buf obj.rms_dist = float(or_["dist_rms"]) / pix_to_arcsec obj.snr_mean = float(or_["snr"]) obj.snr_rms = float(or_["snr_rms"]) diff --git a/src/hpmcm/output_tables.py b/src/hpmcm/output_tables.py index 4dfba4b..c3dd098 100644 --- a/src/hpmcm/output_tables.py +++ b/src/hpmcm/output_tables.py @@ -98,8 +98,8 @@ class ObjectStatsTable(TableInterface): ), ra=TableColumnInfo(float, "RA of object centroid"), dec=TableColumnInfo(float, "DEC of object centroid"), - x_cent=TableColumnInfo(float, "X-value of object centroid in cell pixels"), - y_cent=TableColumnInfo(float, "Y-value of object centroid in cell pixels"), + x_cent=TableColumnInfo(float, "X-value of object centroid in x_cell_coadd coords (0 at inner left edge)"), + y_cent=TableColumnInfo(float, "Y-value of object centroid in x_cell_coadd coords (0 at inner left edge)"), x_pix=TableColumnInfo(float, "X-value of object centroid in global WCS pixels"), y_pix=TableColumnInfo(float, "Y-value of object centroid in global WCS pixels"), snr=TableColumnInfo(float, "Mean signal-to-noise ratio"), @@ -163,6 +163,9 @@ def buildFromCellData(cell_data: CellData) -> ObjectStatsTable: dist_rms *= cell_data.matcher.pixToArcsec() x_pix = x_cents + cell_data.min_pix[0] y_pix = y_cents + cell_data.min_pix[1] + # Convert centroids from x_cell (0 at outer edge) to x_cell_coadd (0 at inner edge) + x_cents_coadd = x_cents - cell_data.buf + y_cents_coadd = y_cents - cell_data.buf return ObjectStatsTable( cluster_id=cluster_ids, @@ -172,8 +175,8 @@ def buildFromCellData(cell_data: CellData) -> ObjectStatsTable: dist_rms=dist_rms, ra=ra, dec=dec, - x_cent=x_cents, - y_cent=y_cents, + x_cent=x_cents_coadd, + y_cent=y_cents_coadd, x_pix=x_pix, y_pix=y_pix, snr=snrs, @@ -260,8 +263,8 @@ class ClusterStatsTable(TableInterface): ), ra=TableColumnInfo(float, "RA of cluster centroid"), dec=TableColumnInfo(float, "DEC of cluster centroid"), - x_cent=TableColumnInfo(float, "X-value of cluster centroid in cell pixels"), - y_cent=TableColumnInfo(float, "Y-value of cluster centroid in cell pixels"), + x_cent=TableColumnInfo(float, "X-value of cluster centroid in x_cell_coadd coords (0 at inner left edge)"), + y_cent=TableColumnInfo(float, "Y-value of cluster centroid in x_cell_coadd coords (0 at inner left edge)"), x_pix=TableColumnInfo(float, "X-value of cluster centroid in global WCS pixels"), y_pix=TableColumnInfo(float, "Y-value of cluster centroid in global WCS pixels"), snr=TableColumnInfo(float, "Mean signal-to-noise ratio"), @@ -327,6 +330,9 @@ def buildFromCellData(cell_data: CellData) -> ClusterStatsTable: dist_rms *= cell_data.matcher.pixToArcsec() x_pix = x_cents + cell_data.min_pix[0] y_pix = y_cents + cell_data.min_pix[1] + # Convert centroids from x_cell (0 at outer edge) to x_cell_coadd (0 at inner edge) + x_cents_coadd = x_cents - cell_data.buf + y_cents_coadd = y_cents - cell_data.buf return ClusterStatsTable( cluster_id=cluster_ids, @@ -336,8 +342,8 @@ def buildFromCellData(cell_data: CellData) -> ClusterStatsTable: dist_rms=dist_rms, ra=ra, dec=dec, - x_cent=x_cents, - y_cent=y_cents, + x_cent=x_cents_coadd, + y_cent=y_cents_coadd, x_pix=x_pix, y_pix=y_pix, snr=snrs, @@ -464,6 +470,8 @@ def buildJoinedObjectTable( catalog_ids: list[int], source_cols: SourceColsType = None, object_shear: str | Path | None = None, + central_only: bool = True, + cell_size: float | None = None, ) -> pandas.DataFrame: """Build a wide joined table from an ObjectStatsTable and source catalogs. @@ -500,6 +508,19 @@ def buildJoinedObjectTable( shear statistics are joined on ``object_id``. The ``cluster_id`` column is dropped before joining because it is already present in ``object_stats``. + central_only: + When ``True`` (default), rows whose centroid (``x_cent``, ``y_cent`` + in x_cell_coadd coordinates) has a negative x or y component are + dropped before joining. When ``cell_size`` is also given, objects + whose centroid is >= ``cell_size`` in either axis are also removed. + This mirrors the filtering applied by + :meth:`~hpmcm.match.Match.extractStats` and is a no-op when the + input already comes from that method with ``central_only=True``. + cell_size: + Inner cell size in pixels. When provided together with + ``central_only=True``, objects with ``x_cent >= cell_size`` or + ``y_cent >= cell_size`` are also removed. Pass ``matcher.cell_size`` + when calling with unfiltered stats. Returns ------- @@ -507,6 +528,13 @@ def buildJoinedObjectTable( renamed ``{col}_{catalog_id}``. Objects that have no source in a given catalog will have ``NaN`` for that catalog's columns. """ + if central_only: + mask = (object_stats["x_cent"] >= 0) & (object_stats["y_cent"] >= 0) + if cell_size is not None: + mask &= (object_stats["x_cent"] < cell_size) & (object_stats["y_cent"] < cell_size) + object_stats = object_stats[mask] + object_assoc = object_assoc[object_assoc["object_id"].isin(object_stats["object_id"])] + catalog_file_map = dict(zip(catalog_ids, input_files)) base = object_stats.set_index("object_id") @@ -544,13 +572,14 @@ def buildJoinedObjectTable( def buildJoinedClusterTable( - cluster_stats: pandas.DataFrame, cluster_assoc: pandas.DataFrame, input_files: list[str], catalog_ids: list[int], source_cols: SourceColsType = None, cluster_shear: str | Path | None = None, + central_only: bool = True, + cell_size: float | None = None, ) -> pandas.DataFrame: """Build a wide joined table from a ClusterStatsTable and source catalogs. @@ -582,6 +611,16 @@ def buildJoinedClusterTable( cluster_shear: Optional path to a ``ClusterShearTable`` parquet file. When provided, shear statistics are joined on ``cluster_id``. + central_only: + When ``True`` (default), clusters whose centroid (``x_cent``, + ``y_cent`` in x_cell_coadd coordinates) has a negative component are + dropped before joining. When ``cell_size`` is also given, clusters + with centroid >= ``cell_size`` are also removed. + cell_size: + Inner cell size in pixels. When provided together with + ``central_only=True``, clusters with ``x_cent >= cell_size`` or + ``y_cent >= cell_size`` are also removed. Pass ``matcher.cell_size`` + when calling with unfiltered stats. Returns ------- @@ -589,6 +628,13 @@ def buildJoinedClusterTable( renamed ``{col}_{catalog_id}``. Clusters that have no source in a given catalog will have ``NaN`` for that catalog's columns. """ + if central_only: + mask = (cluster_stats["x_cent"] >= 0) & (cluster_stats["y_cent"] >= 0) + if cell_size is not None: + mask &= (cluster_stats["x_cent"] < cell_size) & (cluster_stats["y_cent"] < cell_size) + cluster_stats = cluster_stats[mask] + cluster_assoc = cluster_assoc[cluster_assoc["cluster_id"].isin(cluster_stats["cluster_id"])] + catalog_file_map = dict(zip(catalog_ids, input_files)) base = cluster_stats.set_index("cluster_id") diff --git a/src/hpmcm/shear_match.py b/src/hpmcm/shear_match.py index 9fbdb64..1136038 100644 --- a/src/hpmcm/shear_match.py +++ b/src/hpmcm/shear_match.py @@ -270,9 +270,16 @@ def _buildCellData( ) -> CellData: return ShearCellData(self, id_offset, corner, size, idx, self.cell_buffer) - def extractShearStats(self) -> dict[str, pandas.DataFrame]: + def extractShearStats(self, central_only: bool = True) -> dict[str, pandas.DataFrame]: """Extract shear stats + Parameters + ---------- + central_only: + When ``True`` (default), exclude objects and clusters whose centroid + falls outside the inner cell region. Mirrors the behaviour of + :meth:`~hpmcm.match.Match.extractStats`. + Returns ------- Dict with keys: @@ -289,12 +296,17 @@ def extractShearStats(self) -> dict[str, pandas.DataFrame]: continue cell_data = self.cell_dict[i_cell] assert isinstance(cell_data, ShearCellData) - cluster_shear_stats_tables.append( - output_tables.ShearTable.buildClusterShearStats(cell_data).data - ) - object_shear_stats_tables.append( - output_tables.ShearTable.buildObjectShearStats(cell_data).data - ) + + cs = output_tables.ShearTable.buildClusterShearStats(cell_data).data + os_ = output_tables.ShearTable.buildObjectShearStats(cell_data).data + + if central_only: + c_obj, c_clust = self._getCentralIds(cell_data) + cs = cs[cs["cluster_id"].isin(c_clust)] + os_ = os_[os_["object_id"].isin(c_obj)] + + cluster_shear_stats_tables.append(cs) + object_shear_stats_tables.append(os_) return { "cluster_shear": pandas.concat(cluster_shear_stats_tables), diff --git a/tests/test_output_tables_unit.py b/tests/test_output_tables_unit.py index 509576a..b6ed0d5 100644 --- a/tests/test_output_tables_unit.py +++ b/tests/test_output_tables_unit.py @@ -43,6 +43,8 @@ def object_tables(): "ra": [0.1, 0.2, 0.3], "dec": [0.1, 0.2, 0.3], "catalog_mask": [3, 1, 2], + "x_cent": [50.0, 60.0, 70.0], + "y_cent": [50.0, 60.0, 70.0], } ) # object 100 matches sources in both catalogs; 101 only cat 0; 102 only cat 1 @@ -176,7 +178,13 @@ def test_buildJoinedObjectTable_no_assoc_for_catalog(source_parquets, object_tab @pytest.fixture def cluster_tables(): cluster_stats = pd.DataFrame( - {"cluster_id": [200, 201], "ra": [0.1, 0.2], "dec": [0.1, 0.2]} + { + "cluster_id": [200, 201], + "ra": [0.1, 0.2], + "dec": [0.1, 0.2], + "x_cent": [50.0, 60.0], + "y_cent": [50.0, 60.0], + } ) # cluster 200 has one source from cat 0 (source_idx=0) and one from cat 1 (source_idx=0) # cluster 201 has one source from cat 0 (source_idx=1) @@ -233,6 +241,79 @@ def test_buildJoinedClusterTable_preserves_all_stats_rows(source_parquets, clust assert set(result.cluster_id) == set(cluster_stats.cluster_id) +def test_buildJoinedObjectTable_central_only(source_parquets, object_tables): + """central_only drops objects with negative x_cent or y_cent.""" + input_files, catalog_ids, _, _ = source_parquets + object_stats, object_assoc = object_tables + + # Add a buffer-zone object (x_cent < 0) that should be removed + buf_stats = pd.concat([ + object_stats, + pd.DataFrame({"object_id": [999], "cluster_id": [299], + "ra": [0.9], "dec": [0.9], "catalog_mask": [1], + "x_cent": [-5.0], "y_cent": [50.0]}), + ], ignore_index=True) + buf_assoc = pd.concat([ + object_assoc, + pd.DataFrame({"object_id": [999], "source_id": [10], "catalog_id": [0], + "distance": [0.0], "cell_idx": [0]}), + ], ignore_index=True) + + result_filtered = buildJoinedObjectTable( + buf_stats, buf_assoc, input_files, catalog_ids, central_only=True + ) + result_all = buildJoinedObjectTable( + buf_stats, buf_assoc, input_files, catalog_ids, central_only=False + ) + + assert 999 not in result_filtered["object_id"].values + assert 999 in result_all["object_id"].values + # Original objects are unaffected + assert set([100, 101, 102]).issubset(result_filtered["object_id"].values) + + +def test_buildJoinedObjectTable_central_only_with_cell_size(source_parquets, object_tables): + """When cell_size is given, objects with x_cent >= cell_size are also removed.""" + input_files, catalog_ids, _, _ = source_parquets + object_stats, object_assoc = object_tables + + # object 102 has x_cent=70; with cell_size=65 it should be dropped + result = buildJoinedObjectTable( + object_stats, object_assoc, input_files, catalog_ids, + central_only=True, cell_size=65, + ) + assert 102 not in result["object_id"].values + assert 100 in result["object_id"].values + assert 101 in result["object_id"].values + + +def test_buildJoinedClusterTable_central_only(source_parquets, cluster_tables): + """central_only drops clusters with negative centroid coordinates.""" + input_files, catalog_ids, _, _ = source_parquets + cluster_stats, cluster_assoc = cluster_tables + + buf_stats = pd.concat([ + cluster_stats, + pd.DataFrame({"cluster_id": [299], "ra": [0.9], "dec": [0.9], + "x_cent": [-5.0], "y_cent": [50.0]}), + ], ignore_index=True) + buf_assoc = pd.concat([ + cluster_assoc, + pd.DataFrame({"cluster_id": [299], "source_idx": [0], "catalog_id": [0], + "distance": [0.0], "cell_idx": [0]}), + ], ignore_index=True) + + result_filtered = buildJoinedClusterTable( + buf_stats, buf_assoc, input_files, catalog_ids, central_only=True + ) + result_all = buildJoinedClusterTable( + buf_stats, buf_assoc, input_files, catalog_ids, central_only=False + ) + + assert 299 not in result_filtered["cluster_id"].values + assert 299 in result_all["cluster_id"].values + + # --------------------------------------------------------------------------- # object_shear / cluster_shear optional join # --------------------------------------------------------------------------- diff --git a/tests/test_wcs_match.py b/tests/test_wcs_match.py index c946aa9..d62aed4 100644 --- a/tests/test_wcs_match.py +++ b/tests/test_wcs_match.py @@ -83,3 +83,64 @@ def testWCSMatch(setup_data: int) -> None: cell = matcher.cell_dict[matcher.getCellIdx(1, 1)] _od = cell.analyze(None, 4) _cluster = list(cell.cluster_dict.values())[0] + + +def testWCSMatchPixelMatchScale(setup_data: int) -> None: + """Verify pixel_match_scale > 1 works the same as pms=1 for WcsMatch. + + Runs the same single-cell analysis with pixel_match_scale=2 and checks + that: + - Objects are detected and clustered without error + - footprint.extent() is scaled correctly (slice bounds × pms) + - Match efficiency is still reasonable + """ + assert setup_data == 0 + + source_tablesfiles = sorted( + glob.glob(os.path.join(DATADIR, f"shear_*_{SHEAR_ST}_cleaned_{TRACT}_ns.pq")) + ) + source_tablesfiles.append(os.path.join(DATADIR, f"object_{TRACT}.pq")) + source_tablesfiles.reverse() + source_tablesfiles = [source_tablesfiles[0], source_tablesfiles[1]] + catalog_ids: list[int] = list(np.arange(len(source_tablesfiles))) + + matcher = hpmcm.WcsMatch.create( + REF_DIR, + REGION_SIZE, + pixel_size=PIXEL_SIZE, + pixel_R2_cut=PIXEL_R2CUT, + pixel_match_scale=2, + ) + assert matcher.pixel_match_scale == 2 + + matcher.reduceData(source_tablesfiles, catalog_ids) + matcher.analysisLoop(range(1, 2), range(1, 2)) + + stats = matcher.extractStats() + assert stats is not None + + cell = matcher.cell_dict[matcher.getCellIdx(1, 1)] + assert cell.pixel_match_scale == 2 + + # Verify the counts map is half-resolution (pixel_match_scale = 2) + # so the footprint slice bounds are in counts-map coords, and + # extent() should scale them back to cell-pixel coords. + for cluster in cell.cluster_dict.values(): + assert cluster.pixel_match_scale == 2 + x0, x1, y0, y1 = cluster.footprint.extent(cluster.pixel_match_scale) + # extent with pms=2 should differ from extent(1) by factor 2 + x0_raw, x1_raw, y0_raw, y1_raw = cluster.footprint.extent(1) + assert x0 == x0_raw * 2 + assert x1 == x1_raw * 2 + assert y0 == y0_raw * 2 + assert y1 == y1_raw * 2 + + obj_lists = hpmcm.classify.classifyObjects(matcher, SNRCut=10.0) + n_good = len(obj_lists["ideal"]) + bad_list = [ + "edge_mixed", "edge_missing", "edge_extra", "orphan", + "missing", "two_missing", "many_missing", "extra", "caught", + ] + n_bad = np.sum([len(obj_lists[x]) for x in bad_list]) + effic = n_good / (n_good + n_bad) + assert effic > 0.75