Post-processing#

After generating observation plans, post-processing computes detection probabilities and extracts key optimization metrics for each event.

Extract detection Probability and Optimization metrics

Use a configuration file (e.g., params_ultrasat.ini) to specify all parameters:

Using a configuration file
python earthorbitplan/workflow/postprocess.py --config ./earthorbitplan/config/params_ultrasat.ini

Run the post-processing directly by specifying the data directory and required files:

Running via CLI
python postprocess.py --data-dir ./data --event-table ./data/observing-scenarios.ecsv  --output-file ./data/events.ecsv --sched-dir ./data/schedules

Important

The results are aggregated and saved in an ECSV table (by default, events.ecsv), ready for statistical analysis or further reporting.

Main Processing (earthorbitplan.workflow.postprocess.process)
.. autofunction:: earthorbitplan.workflow.postprocess.process
Kilonova detection rate and Statistics

Calculating poisson-lognormal rate quantiles and formatting

This section provides the script used for generating and calculating the detection rates.

Show Script
import warnings
import numpy as np
from astropy import units as u
from astropy.table import QTable
from scipy import stats

from earthorbitplan.probability.rate import poisson_lognormal_rate_quantiles
from earthorbitplan.utils.path import get_project_root

# ------------------------------------------------------------------------------
# Suppress known warnings for cleaner output
warnings.filterwarnings("ignore", "Wswiglal-redir-stdio")
warnings.filterwarnings("ignore", ".*dubious year.*")
warnings.filterwarnings(
    "ignore", "Tried to get polar motions for times after IERS data is valid.*"
)

# ------------------------------------------------------------------------------
# Load simulated event data
root = get_project_root()
events_file = root / "data" / "events.ecsv"
main_table = QTable.read(events_file)

# Get unique run names
runs = np.unique(main_table["run"])

# Filter events by objective_value cutoff
cutoff = main_table["cutoff"][0]
main_table = main_table[main_table["objective_value"] >= cutoff]

# Group events by run
event_tables_by_run = {run: main_table[main_table["run"] == run] for run in runs}

# ------------------------------------------------------------------------------
# Set merger rate priors from O3 R&P Table II (last column)
lo, mid, hi = 100, 240, 510  # In Gpc^-3 yr^-1

# Log-normal width for 90% interval
(standard_90pct_interval,) = np.diff(stats.norm.interval(0.9))
log_target_rate_mu = np.log(mid)
log_target_rate_sigma = np.log(hi / lo) / standard_90pct_interval

# Get effective rate for each run
log_simulation_effective_rate_by_run = {
    key: np.log(value.to_value(u.Gpc**-3 * u.yr**-1))
    for key, value in main_table.meta["effective_rate"].items()
}

# ------------------------------------------------------------------------------
# Compute median and quantiles for each run
prob_quantiles = np.asarray([0.5, 0.05, 0.95])  # Median, 5%, 95%
run_duration = 1.5  # years

mu = np.asarray(
    [
        log_target_rate_mu
        + np.log(run_duration)
        - log_simulation_effective_rate_by_run[run]
        + np.log(
            [
                np.sum(_)
                for _ in [
                    np.ones_like(event_tables_by_run[run]["objective_value"]),
                    event_tables_by_run[run]["detection_probability_known_position"],
                ]
            ]
        )
        for run in runs
    ]
)

# Compute Poisson-Lognormal rate quantiles for all runs
rate_quantiles = poisson_lognormal_rate_quantiles(
    prob_quantiles[np.newaxis, np.newaxis, :],
    mu.T[:, :, np.newaxis],
    log_target_rate_sigma,
)

# ------------------------------------------------------------------------------
# Utility: Format a table as reStructuredText grid table
def make_rst_table(headers, rows):
    columns = [headers] + rows
    n_cols = len(headers)
    col_widths = [max(len(str(row[i])) for row in columns) for i in range(n_cols)]

    def sep(char="+", fill="-"):
        return char + char.join(fill * (w + 2) for w in col_widths) + char

    def fmt_row(row):
        return (
            "| "
            + " | ".join(str(cell).ljust(w) for cell, w in zip(row, col_widths))
            + " |"
        )

    lines = [
        sep(),
        fmt_row(headers),
        sep("=", "="),
    ]
    for row in rows:
        lines.append(fmt_row(row))
        lines.append(sep())
    return "\n".join(lines)

# Example: Prepare headers and format quantile results
headers = ["Run"] + list(runs)
labels = ["Number of events selected", "Number of events detected"]
rst_rows = []

for label, row in zip(labels, rate_quantiles):
    formatted = [
        ":math:`{}_{{-{}}}^{{+{}}}`".format(*np.rint([mid, mid - lo, hi - mid]).astype(int))
        for mid, lo, hi in row
    ]
    rst_rows.append([label] + formatted)

rst_table = make_rst_table(headers, rst_rows)

# Print the table for RST documentation
print(rst_table)
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
Cell In[1], line 22
     18 # ------------------------------------------------------------------------------
     19 # Load simulated event data
     20 root = get_project_root()
     21 events_file = root / "data" / "events.ecsv"
---> 22 main_table = QTable.read(events_file)
     23 
     24 # Get unique run names
     25 runs = np.unique(main_table["run"])

File ~/checkouts/readthedocs.org/user_builds/earthorbitplan/envs/latest/lib/python3.11/site-packages/astropy/table/connect.py:62, in TableRead.__call__(self, *args, **kwargs)
     59 units = kwargs.pop("units", None)
     60 descriptions = kwargs.pop("descriptions", None)
---> 62 out = self.registry.read(cls, *args, **kwargs)
     64 # For some readers (e.g., ascii.ecsv), the returned `out` class is not
     65 # guaranteed to be the same as the desired output `cls`.  If so,
     66 # try coercing to desired class without copying (io.registry.read
     67 # would normally do a copy).  The normal case here is swapping
     68 # Table <=> QTable.
     69 if cls is not out.__class__:

File ~/checkouts/readthedocs.org/user_builds/earthorbitplan/envs/latest/lib/python3.11/site-packages/astropy/io/registry/core.py:200, in UnifiedInputRegistry.read(self, cls, format, cache, *args, **kwargs)
    196 try:
    197     ctx = get_readable_fileobj(
    198         args[0], encoding="binary", cache=cache
    199     )
--> 200     fileobj = ctx.__enter__()
    201 except OSError:
    202     raise

File ~/.asdf/installs/python/3.11.14/lib/python3.11/contextlib.py:137, in _GeneratorContextManager.__enter__(self)
    135 del self.args, self.kwds, self.func
    136 try:
--> 137     return next(self.gen)
    138 except StopIteration:
    139     raise RuntimeError("generator didn't yield") from None

File ~/checkouts/readthedocs.org/user_builds/earthorbitplan/envs/latest/lib/python3.11/site-packages/astropy/utils/data.py:364, in get_readable_fileobj(name_or_obj, encoding, cache, show_progress, remote_timeout, sources, http_headers, use_fsspec, fsspec_kwargs, close_files)
    355 if is_url:
    356     name_or_obj = download_file(
    357         name_or_obj,
    358         cache=cache,
   (...)    362         http_headers=http_headers,
    363     )
--> 364 fileobj = io.FileIO(name_or_obj, "r")
    365 if is_url and not cache:
    366     delete_fds.append(fileobj)

FileNotFoundError: [Errno 2] No such file or directory: '/home/docs/checkouts/readthedocs.org/user_builds/earthorbitplan/checkouts/latest/data/events.ecsv'
Display Detection Rate Table
Rate of Selected and Detected Events

Run

O5

O6

Number of events selected

\(43_{-26}^{+56}\)

\(55_{-33}^{+72}\)

Number of events detected

\(19_{-12}^{+26}\)

\(25_{-16}^{+34}\)

Functions for propagating errors in rates
earthorbitplan.workflow.probability.rate.poisson_lognormal_rate_quantiles
earthorbitplan.probability.rate.poisson_lognormal_rate_quantiles(*args, **kwargs)#

Find the quantiles of a Poisson distribution with a log-normal prior on its rate.

Parameters:
  • p (float) – The quantiles at which to find the number of counts.

  • mu (float) – The mean of the log of the rate.

  • sigma (float) – The standard deviation of the log of the rate.

Returns:

k – The number of events.

Return type:

float

Notes

This algorithm treats the Poisson count k as a continuous real variable so that it can use the scipy.optimize.root_scalar root finding/polishing algorithms.

See also

You can explore, edit and run the calculations directly in a Jupyter environment:

Open in Colab

Function for selected and etected events

Python module to directly process selection and detection rates.

earthorbitplan.workflow.selection_detection_rate.summarize_selected_detected_events(events_file, quantiles=(0.5, 0.05, 0.95), merger_rate_lo=50, merger_rate_mid=130, merger_rate_hi=290, run_duration=1.0, poisson_lognormal_rate_quantiles=None, output_file=None, verbose=True)[source] [edit on github]#

Summarize selected and detected events by class and run, returning a LaTeX table of rate quantiles.

Computes the 5%, 50%, and 95% quantiles of the merger rate for each run using a Poisson-lognormal model, following the O4 Rate & Population (R&P) methodology (see GWTC-4 R&P).

The log-expected rate for each run $r$ and source class $c$ is: .. math:

\mu_{r,c} = \log(\mathrm{target\ median}) + \log(\mathrm{run\ duration})
          - \log(\mathrm{effective\ rate}_{\mathrm{sim},r}) + \log(N_{r,c})

where \(N_{r,c}\) is the number of selected or detected events for class $c$ in run $r$. Note the effective simulation rate depends only on the run, not the class.

Parameters:
  • events_file (str or Path) – Path to the ECSV table of candidate events.

  • quantiles (sequence of float, optional) – Probability quantiles to compute (default: (0.5, 0.05, 0.95)).

  • merger_rate_lo (float, optional) – Lower bound of target merger rate (Gpc^-3 yr^-1).

  • merger_rate_mid (float, optional) – Median of target merger rate (Gpc^-3 yr^-1).

  • merger_rate_hi (float, optional) – Upper bound of target merger rate (Gpc^-3 yr^-1).

  • run_duration (float, optional) – Duration of the observing run in years (default: 1.5).

  • poisson_lognormal_rate_quantiles (callable) – Function to calculate Poisson-lognormal rate quantiles.

  • verbose (bool, optional) – If True, displays the tabular in Jupyter via IPython.display.Latex.

Returns:

Full LaTeX table (begin{table}…end{table}) as a string. Rows = observing runs x {Selected, Detected}. Columns = BNS, NSBH, All. Style: Xhline thick rules, multirow for run names, textbf headers. Requires: multirow, makecell packages in LaTeX preamble.

Return type:

str

Notes

Source classes: BNS (both components < 3 Msun), NSBH (one component > 3 Msun), All (no mass cut). Column order is BNS => NSBH => All, from most specific to most inclusive.

References