From 926a08bc10e8b85b6adb5d8e779e1afcbd34b79a Mon Sep 17 00:00:00 2001 From: Kira Date: Wed, 15 Jul 2026 08:38:33 -0700 Subject: [PATCH 1/2] add prost probabilistic host association to catalog crossmatch - Fix register_local_catalog to also inject into DEFAULT_RELEASES so prost's get_catalogs() can resolve custom catalog names - Add method='prost' to catalog_crossmatch with auto-registration of all FITS catalogs, z-column detection, and confident-match printout - Add --method prost/conesearch flag to crossmatch_alerts.py CLI - Add shebang to summarize_rubin_alerts.py - Update README with prost explainer and example usage Co-Authored-By: Claude Sonnet 4.6 --- README.md | 23 +++++ src/thor/crossmatch_alerts.py | 26 ++++- src/thor/summarize_rubin_alerts.py | 1 + src/thor/utils/filter_functions.py | 154 +++++++++++++++++++++++------ src/thor/utils/prost_catalogs.py | 4 + 5 files changed, 174 insertions(+), 34 deletions(-) mode change 100644 => 100755 src/thor/summarize_rubin_alerts.py diff --git a/README.md b/README.md index 05b651c..943e354 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,29 @@ python src/thor/crossmatch_alerts.py --start 06-28-2026 --end 06-30-2026 --addit To apply additional TDE-specific filtering, pass `--additional_filtering tde_filter`. Add flag `--save_raw_alerts` to save raw alerts to `data/lsst_alert_download/raw_files/`, and flag `--save_results` in order to save the crossmatch details locally to `data/lsst_alert_download/`. A summary of results will be printed in command line, but the `--scan` flag can also be included to open a temp jupyter notebook in browser and use Babamul's scanning tool. +`catalog_crossmatch` also supports probabilistic host association via [astro_prost](https://github.com/alexandergagliano/galaxy-association) using `method='prost'`. This runs a full Bayesian host-galaxy association across all catalogs in `data/catalogs/`, scoring candidates by offset (and redshift where available) rather than returning all matches within a fixed radius. Each transient is assigned a single best host with a posterior probability; hosts with posterior > 0.3 are printed. + +```python +from thor.utils.filter_functions import catalog_crossmatch + +# default cone search +results = catalog_crossmatch(ra=150.1, dec=2.2) + +# probabilistic host association with default offset-only priors +results = catalog_crossmatch(ra=150.1, dec=2.2, method='prost') + +# custom priors and likelihoods (any scipy.stats distribution) +from scipy.stats import gamma, halfnorm, uniform +results = catalog_crossmatch( + ra=150.1, dec=2.2, + method='prost', + priors={"offset": uniform(loc=0, scale=10)}, + likes={"offset": gamma(a=0.75)}, +) +``` + +Priors and likelihoods must be `scipy.stats` distributions and are keyed by property name (`"offset"`, `"redshift"`). See the [astro_prost documentation](https://github.com/alexandergagliano/galaxy-association) for the full list of supported properties and association options. + ### Data diff --git a/src/thor/crossmatch_alerts.py b/src/thor/crossmatch_alerts.py index 310b3a3..660c1e3 100644 --- a/src/thor/crossmatch_alerts.py +++ b/src/thor/crossmatch_alerts.py @@ -135,6 +135,12 @@ def main(): action="store_true", help="Open a temporary Jupyter notebook to scan candidates with scan_alerts (default: off).", ) + parser.add_argument( + "--method", + default="conesearch", + choices=["conesearch", "prost"], + help="Crossmatch method: conesearch (default) or prost (probabilistic host association).", + ) args = parser.parse_args() # ── Fetch alerts ────────────────────────────────────────────────────────── @@ -163,8 +169,25 @@ def main(): # ── Crossmatch against all available catalogs ───────────────────────────── crossmatched_objects = filter_functions.catalog_crossmatch( alerts=filtered_objects, + method=args.method, ) + timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ") + repo_root = Path(__file__).resolve().parents[2] + out_dir = repo_root / "data" / "lsst_alert_download" + + # ── prost returns a DataFrame; handle separately ────────────────────────── + if args.method == "prost": + if args.save_result: + out_dir.mkdir(parents=True, exist_ok=True) + out_file = out_dir / f"crossmatch_candidates_{timestamp}.csv" + crossmatched_objects.to_csv(out_file, index=False) + print(f"\nSaved {len(crossmatched_objects):,} prost results to {out_file}") + if args.scan: + _launch_scan_notebook(crossmatched_objects['name'].tolist()) + return + + # ── conesearch path ─────────────────────────────────────────────────────── # ── Optional additional filtering ───────────────────────────────────────── if args.additional_filtering == "tde_filter": crossmatched_objects = filter_functions.filter_alerts( @@ -192,9 +215,6 @@ def main(): for obj_id, obj in crossmatched_objects.items() } - timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ") - repo_root = Path(__file__).resolve().parents[2] - out_dir = repo_root / "data" / "lsst_alert_download" out_dir.mkdir(parents=True, exist_ok=True) out_file = out_dir / f"crossmatch_candidates_{timestamp}.json" diff --git a/src/thor/summarize_rubin_alerts.py b/src/thor/summarize_rubin_alerts.py old mode 100644 new mode 100755 index a4f60ae..d6b9a38 --- a/src/thor/summarize_rubin_alerts.py +++ b/src/thor/summarize_rubin_alerts.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 """ Fetch and visualise LSST alerts for a given date range. diff --git a/src/thor/utils/filter_functions.py b/src/thor/utils/filter_functions.py index a6d284a..5a538c5 100644 --- a/src/thor/utils/filter_functions.py +++ b/src/thor/utils/filter_functions.py @@ -394,10 +394,12 @@ def catalog_crossmatch( catalog_name: str | list[str] | None = None, catalog_path: str | None = None, radius_arcsec: float = 5.0, -) -> pd.DataFrame: + method: str = "conesearch", + priors: dict | None = None, + likes: dict | None = None, +): """ Crossmatch alerts or coordinates against one or all .fits catalogs in catalog_path. - Using a cone search with defined radius, keeping the closest match only. Parameters ---------- @@ -407,27 +409,37 @@ def catalog_crossmatch( RA(s) in degrees. Used instead of alerts when provided. dec : float or list of float, optional Dec(s) in degrees. Used instead of alerts when provided. - catalog_name : str or None - Filename of a specific catalog to use (e.g. 'COSMOS2025_cut.fits'). + catalog_name : str or list[str] or None + Filename(s) of specific catalog(s) to use (e.g. 'COSMOS2025_cut.fits'). If None, crossmatches against all .fits files in catalog_path. - catalog_path : str + catalog_path : str or Path, optional Directory containing .fits catalogs. Defaults to data/catalogs/ at the repo root. radius_arcsec : float - Match radius in arcseconds. Default 5.0. + Match radius in arcseconds (conesearch only). Default 5.0. + method : str + 'conesearch' (default) or 'prost'. conesearch keeps the closest match per + catalog; prost runs full probabilistic host association via astro_prost. + priors : dict, optional + Prior distributions for prost (e.g. {"offset": uniform(...)}). If None, + defaults to offset-only with uniform(0, 10) prior. + likes : dict, optional + Likelihood functions for prost (e.g. {"offset": gamma(...)}). If None, + defaults to gamma(a=0.75) offset likelihood. Returns ------- - df : pd.DataFrame - One row per input with at least one catalog match. Columns: LSST_objectID, - then one bool column per catalog named by catalog stem. + For method='conesearch': dict keyed by objectId (or integer index), values are + dicts with per-catalog row data. + For method='prost': pd.DataFrame returned directly by associate_sample. """ if alerts is None and ra is None: raise ValueError("Provide either alerts or ra/dec coordinates.") if catalog_path is None: catalog_path = Path(__file__).resolve().parents[3] / "data" / "catalogs" + catalog_path = Path(catalog_path) - fits_files = sorted([f for f in os.listdir(catalog_path) if f.endswith('.fits')]) + fits_files = sorted(f for f in os.listdir(catalog_path) if f.endswith('.fits')) if not fits_files: raise FileNotFoundError(f"No .fits files found in {catalog_path}") @@ -435,27 +447,38 @@ def catalog_crossmatch( fits_files = [catalog_name] if isinstance(catalog_name, str) else list(catalog_name) if alerts is not None: - input_coords = SkyCoord( - ra=[a.candidate.ra for a in alerts], - dec=[a.candidate.dec for a in alerts], - unit="deg", - ) + ra_list = [a.candidate.ra for a in alerts] + dec_list = [a.candidate.dec for a in alerts] + name_list = [a.objectId for a in alerts] else: - ra_list = [ra] if isinstance(ra, (int, float)) else list(ra) + ra_list = [ra] if isinstance(ra, (int, float)) else list(ra) dec_list = [dec] if isinstance(dec, (int, float)) else list(dec) - input_coords = SkyCoord(ra=ra_list, dec=dec_list, unit="deg") + name_list = [str(i) for i in range(len(ra_list))] - # stem -> (matched bool array, catalog row dicts) - catalog_results = {} + if method == "prost": + return _crossmatch_prost( + ra_list, dec_list, name_list, fits_files, catalog_path, priors, likes + ) + + # --- conesearch (original behaviour) --- + input_coords = SkyCoord(ra=ra_list, dec=dec_list, unit="deg") + + def _safe_val(v): + import numpy.ma as ma + if isinstance(v, ma.core.MaskedConstant): + return None + if hasattr(v, 'item'): + return v.item() + return v + catalog_results = {} for fname in fits_files: stem = fname.replace('.fits', '') - path = os.path.join(catalog_path, fname) - cat = Table.read(path) + cat = Table.read(catalog_path / fname) names = [n for n in cat.colnames if len(cat[n].shape) <= 1] cat = cat[names] - ra_col = next((c for c in cat.colnames if c.lower() == 'ra'), None) + ra_col = next((c for c in cat.colnames if c.lower() == 'ra'), None) dec_col = next((c for c in cat.colnames if c.lower() == 'dec'), None) if ra_col is None or dec_col is None: print(f"Skipping {fname}: no ra/dec columns found.") @@ -466,15 +489,6 @@ def catalog_crossmatch( sep_arcsec = sep.to(u.arcsec).value within = sep_arcsec <= radius_arcsec - def _safe_val(v): - import numpy.ma as ma - if isinstance(v, ma.core.MaskedConstant): - return None - if hasattr(v, 'item'): - return v.item() - return v - - # pre-build row dicts for matched entries row_dicts = [] for j, (i, matched) in enumerate(zip(idx, within)): if matched: @@ -499,3 +513,81 @@ def _safe_val(v): print(f"Total: {len(result)}/{len(inputs)} inputs matched in at least one catalog.") return result + +# Prost catalog keys are cached across calls so FITS files are only loaded once. +_PROST_REGISTERED: set[str] = set() + +_Z_COL_CANDIDATES = ("z", "z_phot", "zphot", "photoz", "z_best", "redshift") +_ZSTD_COL_CANDIDATES = ("z_err", "z_phot_err", "zphot_err", "ez_best", "redshift_err") + + +def _fits_stem_to_prost_key(fname: str) -> str: + """COSMOS2025_cut.fits -> cosmos2025""" + return fname.replace('.fits', '').replace('_cut', '').lower() + + +def _crossmatch_prost(ra_list, dec_list, name_list, fits_files, catalog_path, priors, likes): + from scipy.stats import gamma, uniform + from thor.utils.prost_catalogs import register_local_catalog + from astro_prost import associate_sample + + prost_keys = [] + for fname in fits_files: + key = _fits_stem_to_prost_key(fname) + if key not in _PROST_REGISTERED: + df = Table.read(catalog_path / fname).to_pandas() + cols_lower = {c.lower(): c for c in df.columns} + + z_col = next((cols_lower[c] for c in _Z_COL_CANDIDATES if c in cols_lower), None) + if z_col is None: + print(f"Skipping {fname} for prost: no redshift column found.") + continue + + z_std_col = next((cols_lower[c] for c in _ZSTD_COL_CANDIDATES if c in cols_lower), None) + + # ensure required columns exist + if 'id' not in cols_lower: + df = df.reset_index(drop=True) + df.insert(0, 'id', df.index.astype(str)) + + register_local_catalog(key, df, catalog_label=fname, z_col=z_col, z_std_col=z_std_col) + _PROST_REGISTERED.add(key) + print(f"Registered {fname} as prost catalog '{key}' (z={z_col}, z_std={z_std_col}).") + + prost_keys.append(key) + + if not prost_keys: + raise ValueError("No catalogs could be registered for prost (missing redshift columns?).") + + if priors is None: + priors = {"offset": uniform(loc=0, scale=10)} + if likes is None: + likes = {"offset": gamma(a=0.75)} + + transient_catalog = pd.DataFrame({ + 'name': name_list, + 'ra': ra_list, + 'dec': dec_list, + }) + + results = associate_sample( + transient_catalog, + priors=priors, + likes=likes, + catalogs=prost_keys, + name_col='name', + coord_cols=('ra', 'dec'), + save=False, + ) + + confident = results[results['host_total_posterior'] > 0.3] + print(f"\nProst: {len(confident)}/{len(results)} transients with host_total_posterior > 0.3:") + for _, row in confident.iterrows(): + print( + f" {row['name']} -> host {row['host_objID']} " + f"(cat={row['best_cat']}, posterior={row['host_total_posterior']:.3f}, " + f"ra={row['host_ra']:.5f}, dec={row['host_dec']:.5f})" + ) + + return results + diff --git a/src/thor/utils/prost_catalogs.py b/src/thor/utils/prost_catalogs.py index e25feb1..aa9481d 100644 --- a/src/thor/utils/prost_catalogs.py +++ b/src/thor/utils/prost_catalogs.py @@ -39,6 +39,7 @@ from astropy.coordinates import SkyCoord from scipy.stats import norm +import astro_prost.associate as _prost_associate from astro_prost.helpers import ( GalaxyCatalog, build_galaxy_array, @@ -270,4 +271,7 @@ def register_local_catalog( z_col=z_col, z_std_col=z_std_col, ) + # prost's get_catalogs() resolves catalog names via DEFAULT_RELEASES before + # ever reaching GalaxyCatalog, so we must register the name there too. + _prost_associate.DEFAULT_RELEASES.setdefault(name, "local") _patch_galaxy_catalog_init() From 5f028ca173e8fd3e9cc4f4c591802aebddaf36e8 Mon Sep 17 00:00:00 2001 From: Kira Date: Wed, 15 Jul 2026 09:46:51 -0700 Subject: [PATCH 2/2] use prost for crossmatching --- README.md | 27 ++--- .../Example_LSST_Catalog_Crossmatching.ipynb | 39 +++++-- src/thor/crossmatch_alerts.py | 100 ++++++++++++++++-- src/thor/utils/filter_functions.py | 6 +- src/thor/utils/prost_catalogs.py | 11 +- 5 files changed, 142 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 943e354..0a36939 100644 --- a/README.md +++ b/README.md @@ -29,33 +29,20 @@ This uses the Babamul alert broker to fetch alerts, which requires user credenti To fetch alerts, crossmatch against available catalogs, and save candidates, run: ```bash -python src/thor/crossmatch_alerts.py --start 06-28-2026 --end 06-30-2026 --additional_filtering tde_filter --scan +python src/thor/crossmatch_alerts.py --start 06-28-2026 --end 06-30-2026 ``` To apply additional TDE-specific filtering, pass `--additional_filtering tde_filter`. Add flag `--save_raw_alerts` to save raw alerts to `data/lsst_alert_download/raw_files/`, and flag `--save_results` in order to save the crossmatch details locally to `data/lsst_alert_download/`. A summary of results will be printed in command line, but the `--scan` flag can also be included to open a temp jupyter notebook in browser and use Babamul's scanning tool. -`catalog_crossmatch` also supports probabilistic host association via [astro_prost](https://github.com/alexandergagliano/galaxy-association) using `method='prost'`. This runs a full Bayesian host-galaxy association across all catalogs in `data/catalogs/`, scoring candidates by offset (and redshift where available) rather than returning all matches within a fixed radius. Each transient is assigned a single best host with a posterior probability; hosts with posterior > 0.3 are printed. +Pass `--method prost` to use probabilistic host association via [astro_prost](https://github.com/alexandergagliano/galaxy-association) instead of cone search. For efficiency, this will run a 10 arcsec consearch first to remove alerts with no nearby hosts. -```python -from thor.utils.filter_functions import catalog_crossmatch - -# default cone search -results = catalog_crossmatch(ra=150.1, dec=2.2) - -# probabilistic host association with default offset-only priors -results = catalog_crossmatch(ra=150.1, dec=2.2, method='prost') - -# custom priors and likelihoods (any scipy.stats distribution) -from scipy.stats import gamma, halfnorm, uniform -results = catalog_crossmatch( - ra=150.1, dec=2.2, - method='prost', - priors={"offset": uniform(loc=0, scale=10)}, - likes={"offset": gamma(a=0.75)}, -) +```bash +python src/thor/crossmatch_alerts.py --start 06-28-2026 --end 06-30-2026 --method prost --additional_filtering tde_filter --scan ``` -Priors and likelihoods must be `scipy.stats` distributions and are keyed by property name (`"offset"`, `"redshift"`). See the [astro_prost documentation](https://github.com/alexandergagliano/galaxy-association) for the full list of supported properties and association options. +Results are saved as a `.csv` (one row per transient) rather than `.json`. Transients with a host posterior > 0.3 are printed to the command line. + +The script currently uses galaxy offset as the default scoring property, with a `uniform(0, 10)` prior and `gamma(a=0.75)` likelihood. Redshift information is loaded from catalogs where available and can be included in scoring via custom priors. For more flexibility, see the Prost section of the [crossmatching notebook](docs/notebooks/Example_LSST_Catalog_Crossmatching.ipynb) or the [astro_prost documentation](https://github.com/alexandergagliano/galaxy-association) for the full list of supported properties and association options. ### Data diff --git a/docs/notebooks/Example_LSST_Catalog_Crossmatching.ipynb b/docs/notebooks/Example_LSST_Catalog_Crossmatching.ipynb index 0506e12..5ec11f2 100644 --- a/docs/notebooks/Example_LSST_Catalog_Crossmatching.ipynb +++ b/docs/notebooks/Example_LSST_Catalog_Crossmatching.ipynb @@ -591,6 +591,8 @@ "metadata": {}, "outputs": [], "source": [ + "# test Prost out of the box / basic usage\n", + "\n", "import pandas as pd\n", "from astro_prost import associate_sample\n", "from scipy.stats import gamma, halfnorm, uniform\n", @@ -603,7 +605,7 @@ "})\n", "\n", "# define a set of catalogs to search -- options are glade, decals, panstarrs, and skymapper\n", - "catalogs = [\"decals\"]\n", + "catalogs = [\"decals\", \"glade\"]\n", "\n", "# define priors and likelihoods\n", "priorfunc_offset = uniform(loc=0, scale=10)\n", @@ -632,14 +634,37 @@ "metadata": {}, "outputs": [], "source": [ - "from pathlib import Path \n", + "# test prost with custom patch to handle unsuported catalogs\n", + "\n", + "from pathlib import Path \n", + "import pandas as pd \n", "from astropy.table import Table \n", - "from thor.utils.prost_catalogs import register_local_catalog \n", - " \n", - "CATALOG_DIR = Path.cwd().parents[1] / \"data\" / \"catalogs\" \n", - " \n", + "from thor.utils.prost_catalogs import register_local_catalog\n", + "from astro_prost import associate_sample\n", + "from scipy.stats import gamma, halfnorm, uniform\n", + "\n", + "\n", + "\n", + "# custom local catalogs \n", + "CATALOG_DIR = Path.cwd().parents[1] / \"data\" / \"catalogs\"\n", "df = Table.read(CATALOG_DIR / \"COSMOS2025_cut.fits\").to_pandas() \n", - "register_local_catalog(\"cosmos2025\", df) \n", + "register_local_catalog(\"cosmos2025\", df)\n", + "\n", + "# define a transient catalog \n", + "transient_catalog = pd.DataFrame({\n", + " 'name': ['MyTransient'],\n", + " 'ra': [150.1],\n", + " 'dec': [2.2]\n", + "})\n", + "\n", + "# define a set of catalogs to search -- options are glade, decals, panstarrs, and skymapper\n", + "catalogs = [\"cosmos2025\"]\n", + "\n", + "# define priors and likelihoods\n", + "priorfunc_offset = uniform(loc=0, scale=10)\n", + "likefunc_offset = gamma(a=0.75)\n", + "priors = {\"offset\": priorfunc_offset}\n", + "likes = {\"offset\": likefunc_offset}\n", "\n", "# associate\n", "hosts = \\\n", diff --git a/src/thor/crossmatch_alerts.py b/src/thor/crossmatch_alerts.py index 660c1e3..1fcc3b2 100644 --- a/src/thor/crossmatch_alerts.py +++ b/src/thor/crossmatch_alerts.py @@ -1,5 +1,6 @@ import argparse import json +import math import os import subprocess import tempfile @@ -109,6 +110,72 @@ def _print_match_report(crossmatched_objects): print(divider) +def _print_prost_report(results_df): + matched = results_df[results_df['best_cat'].notna()] + n = len(matched) + print(f"\nMatched Object IDs: {n}") + + if n > 100: + print(f"More than 100 matches ({n} total) — skipping per-object summary.") + return + + if n == 0: + return + + def _isnan(v): + try: + return math.isnan(v) + except (TypeError, ValueError): + return v is None + + id_w = max(len("LSST Object ID"), matched['name'].astype(str).str.len().max()) + cat_w = 30 + z_w = 8 + sep_w = 10 + post_w = 10 + + header = ( + f"{'LSST Object ID':<{id_w}} " + f"{'Catalog':<{cat_w}} " + f"{'z':>{z_w}} " + f"{'Sep (\")' :>{sep_w}} " + f"{'Posterior':>{post_w}}" + ) + divider = "-" * len(header) + print(divider) + print(header) + print(divider) + + for _, row in matched.iterrows(): + first = True + for prefix in ("host", "host_2"): + objid = row.get(f"{prefix}_objID") + post_val = row.get(f"{prefix}_total_posterior") + if objid is None or _isnan(objid): + continue + if _isnan(post_val) or post_val < 0.3: + continue + z_val = row.get(f"{prefix}_redshift_mean") + sep_val = row.get(f"{prefix}_offset_mean") + cat = row.get("best_cat", "—") + + z_str = f"{z_val:.3f}" if not _isnan(z_val) else "—" + sep_str = f"{sep_val:.2f}" if not _isnan(sep_val) else "—" + post_str = f"{post_val:.3f}" if not _isnan(post_val) else "—" + id_str = str(row['name']) if first else "" + + print( + f"{id_str:<{id_w}} " + f"{cat:<{cat_w}} " + f"{z_str:>{z_w}} " + f"{sep_str:>{sep_w}} " + f"{post_str:>{post_w}}" + ) + first = False + + print(divider) + + def main(): dotenv.load_dotenv() parser = argparse.ArgumentParser(description="Fetch LSST alerts and crossmatch against catalogs.") @@ -166,11 +233,35 @@ def main(): # ── Deduplicate to unique objects ───────────────────────────────────────── filtered_objects = filter_functions.deduplicate_alerts(filtered_alerts) + # ── Optional additional filtering (alert-based, method-independent) ─────── + if args.additional_filtering == "tde_filter": + filtered_objects = filter_functions.filter_alerts( + filtered_objects, + filter_functions.tde_filter, + ) + # ── Crossmatch against all available catalogs ───────────────────────────── + if args.method == "prost": + # Pre-filter with a fast cone search to avoid running prost on alerts + # with no catalog coverage at all. + cone_matches = filter_functions.catalog_crossmatch( + alerts=filtered_objects, + method="conesearch", + radius_arcsec=10.0, + ) + n_before = len(filtered_objects) + filtered_objects = [a for a in filtered_objects if a.objectId in cone_matches] + n_after = len(filtered_objects) + print(f"10\" pre-filter: cut {n_before - n_after} candidates, running prost on {n_after}.") + + import time + _t0 = time.monotonic() crossmatched_objects = filter_functions.catalog_crossmatch( alerts=filtered_objects, method=args.method, ) + _elapsed_min = (time.monotonic() - _t0) / 60 + print(f"\nRan crossmatch in {_elapsed_min:.1f} minutes on {len(filtered_objects):,} alerts from {args.start} to {args.end}.") timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ") repo_root = Path(__file__).resolve().parents[2] @@ -178,6 +269,7 @@ def main(): # ── prost returns a DataFrame; handle separately ────────────────────────── if args.method == "prost": + _print_prost_report(crossmatched_objects) if args.save_result: out_dir.mkdir(parents=True, exist_ok=True) out_file = out_dir / f"crossmatch_candidates_{timestamp}.csv" @@ -187,14 +279,6 @@ def main(): _launch_scan_notebook(crossmatched_objects['name'].tolist()) return - # ── conesearch path ─────────────────────────────────────────────────────── - # ── Optional additional filtering ───────────────────────────────────────── - if args.additional_filtering == "tde_filter": - crossmatched_objects = filter_functions.filter_alerts( - crossmatched_objects, - filter_functions.tde_filter, - ) - # ── Report ──────────────────────────────────────────────────────────────── if not crossmatched_objects: print("\nNo crossmatch candidates found.") diff --git a/src/thor/utils/filter_functions.py b/src/thor/utils/filter_functions.py index 5a538c5..ae1ccde 100644 --- a/src/thor/utils/filter_functions.py +++ b/src/thor/utils/filter_functions.py @@ -522,8 +522,10 @@ def _safe_val(v): def _fits_stem_to_prost_key(fname: str) -> str: - """COSMOS2025_cut.fits -> cosmos2025""" - return fname.replace('.fits', '').replace('_cut', '').lower() + """ASTRODEEP_ABELL2744_cut.fits -> astrodeepabell2744 (matches prost's sanitize_input)""" + import re + stem = fname.replace('.fits', '').replace('_cut', '') + return re.sub(r'[_\-\s]', '', stem).lower() def _crossmatch_prost(ra_list, dec_list, name_list, fits_files, catalog_path, priors, likes): diff --git a/src/thor/utils/prost_catalogs.py b/src/thor/utils/prost_catalogs.py index aa9481d..0762afe 100644 --- a/src/thor/utils/prost_catalogs.py +++ b/src/thor/utils/prost_catalogs.py @@ -47,6 +47,7 @@ SIZE_FLOOR, SIGMA_SIZE_FLOOR, REDSHIFT_FLOOR, + sanitize_input, ) # ---- Runtime patch so custom catalogs survive GalaxyCatalog.__init__ -------- @@ -265,13 +266,15 @@ def register_local_catalog( z_std_col : str or None Column name for redshift uncertainty. If None, uses ``Z_STD_FRAC * z``. """ - _CUSTOM_CATALOGS[name] = make_local_catalog_fn( + # prost's sanitize_input() strips underscores/hyphens/spaces before any + # lookup, so we must store under the sanitized key to match what get_catalogs() + # and GalaxyCatalog will actually query. + key = sanitize_input(name) + _CUSTOM_CATALOGS[key] = make_local_catalog_fn( df, catalog_label=catalog_label or name, z_col=z_col, z_std_col=z_std_col, ) - # prost's get_catalogs() resolves catalog names via DEFAULT_RELEASES before - # ever reaching GalaxyCatalog, so we must register the name there too. - _prost_associate.DEFAULT_RELEASES.setdefault(name, "local") + _prost_associate.DEFAULT_RELEASES.setdefault(key, "local") _patch_galaxy_catalog_init()