Scanning GCN events for optical counterparts#

SkyPortal can crossmatch broker alerts against the localization of a GCN event and raise whatever falls inside it as candidates to scan. This works for any event with a localization, e.g. Einstein Probe and Swift XRT error circles, Fermi and GW skymaps, against any broker implementing query_alerts (BOOM, Babamul, ALeRCE, Fink, Lasair, …).

How it works#

The gcn_crossmatch service walks every event still inside its active window. For each one, and for each active broker:

  1. The localization is reduced to a search cone. Cone localizations (EP, Swift XRT) give this directly; skymaps are bounded using the stored 90% credible contour. A localization that cannot be bounded, or whose bound exceeds max_radius_deg, is skipped and logged — never guessed at.

  2. The broker is queried inside that cone, restricted to an alert-JD window around the event, with quality cuts applied broker-side (see below).

  3. Returned alerts are checked for real containment against the localization’s HEALPix tiles. As the cone over-selects, this decides candidate membership.

  4. Survivors become an Obj plus a Candidate against the configured filter, so they appear on the scanning page.

  5. Each match is annotated with how it relates to the event (below).

Events are re-queried while they stay active, because alerts keep arriving after the trigger. Each pass resumes from the newest alert already seen rather than from wall-clock time, so late-arriving alerts are not skipped.

A one-shot archival pass also searches the window before the event. Those alerts cannot have been caused by it, so they rule a candidate out: a position already flaring last month is a variable, not a counterpart. Such matches carry prior_activity: true on the annotation.

Configuring the quality-cut filter#

Without cuts, every artifact, asteroid and variable star inside the error region is reported. The cuts are expressed as a broker filter, so they are versioned and editable in the filter builder rather than frozen in code.

  1. Create a filter on the broker (for BOOM, an aggregation pipeline) holding the cuts you want. A reasonable ZTF starting point is in ZTF_QUALITY_CUTS in skyportal/utils/gcn_crossmatch.py: real/bogus thresholds (rb, drb), positive subtractions only, solar-system rejection, stellar rejection by sgscore/distpsnr, and a PS1 red-star colour cut.

  2. Create the corresponding SkyPortal Filter, whose altdata carries {"boom": {"filter_id": "<broker filter uuid>"}}.

  3. Point the service at it:

gcn_crossmatch:
  enabled: True
  filter_id: 42        # the SkyPortal Filter id

The service reads that filter’s active version and prepends the cone to it, so cuts run on the broker and only survivors cross the wire. With filter_id unset it falls back to the built-in cuts, which works but is frozen — and without a filter no Candidate can be created, so nothing reaches the scanning page.

Choose the filter’s group deliberately. A candidate is visible to the filter’s group. Annotations inherit the event’s groups, so the link between an object and a restricted event stays restricted, but the candidate itself does not. For a proprietary stream, use a filter whose group matches that stream’s audience.

What gets crossmatched#

A Filter is one crossmatch configuration. It already names the broker, the stream (hence the survey and the alert programs) and the group that sees the candidates, so nothing about which alerts to search lives in config.yaml. Opt a filter in through its altdata:

altdata:
  gcn_crossmatch:
    enabled: true
    # optional: restrict to matching events. Same shape as DefaultGcnTag --
    # an absent or empty list means no restriction.
    filters:
      gcn_tags: ["Einstein Probe"]
    # optional: any setting from the table below, for this filter alone
    delta_t_after: 7.0

Adding a second survey is a second filter, not a code change: point it at an LSST stream and the matching broker, and it runs alongside with its own windows, its own cuts and its own audience.

Progress is tracked per (event, filter), so two filters sharing a broker never interfere.

Configuration reference#

These are the defaults every opted-in filter inherits, under gcn_crossmatch in config.yaml. Any of them can be overridden per filter.

| Setting | Default | Meaning | |—|—|—| | enabled | False | Run the service at all | | poll_interval | 300 | Seconds between passes | | max_event_age | 31.0 | Days; older events are no longer crossmatched | | recheck_interval_minutes | 60.0 | Minimum gap before re-querying an event | | delta_t_before / delta_t_after | 1.0 / 31.0 | Query window around the event, in days | | archival / archival_days | True / 31.0 | One-shot pre-event search | | max_radius_deg | 5.0 | Skip localizations bounding wider than this | | credible_level | 90 | Contour used to bound non-cone localizations | | cumprob | 0.95 | Cumulative probability defining “inside” | | max_alerts | 500 | Cap per event, per broker, per pass |

Scanning the results#

Matches appear on the candidates page under the configured filter, like any other candidates. Each carries an annotation with origin GCN-crossmatch, keyed by event, holding the fields the reviewer needs:

| Field | Meaning | |—|—| | delta_t | Days between the event and the alert | | distance_arcmin | Separation from the localization centre | | distance_ratio | That separation as a fraction of the error radius | | age | Days since the object’s first detection | | drb | Deep-learning real/bogus score | | sgscore, distpsnr | Star/galaxy score and distance to the nearest PS1 source | | ssdistnr, ssmagnr | Proximity to a known solar-system object | | ndethist | Number of prior detections | | event_mjd | Event time, MJD | | prior_activity | Set when the position was already active before the event | | dist_mean, dist_std | For a 3D (GW) skymap: the distance the event implies at this position, in Mpc |

Because these are annotations, they can be sorted and filtered on the scanning page — delta_t and distance_arcmin are the usual first cut, and prior_activity is the quickest way to discard variables.

dist_mean/dist_std are the conditional distance at the candidate’s own pixel, not the skymap’s marginal distance: for a localization spanning a range of distances the two differ, and “inside the localization volume” means the former. They are recorded rather than cut on, because an alert rarely has a host redshift at discovery — compare them against one when it is known.

Once a candidate is saved to a group, it appears as a source on that event’s page in the usual way.

Per-event progress and requeuing#

GET  /api/gcn_event/{dateobs}/crossmatch
POST /api/gcn_event/{dateobs}/crossmatch     # requires Manage GCNs

GET returns per-broker progress: when the event was last queried, how far through the alert stream it has got, how many matches it has produced, and any error. POST resets that state so the next pass re-queries from the start of the window, archival pass included; you can use it after changing the filter or the search parameters.

Finding the events#

The gcn_events page filters by group, so a proprietary stream can be separated from public alerts. Group filtering narrows within what you can already read; it never widens access.