An Asimov plugin for running simple-pe, a rapid, metric/Fisher-matrix-based parameter estimation code for gravitational-wave signals from compact binary coalescences.
📚 Full documentation and tutorial
Unlike a full stochastic (MCMC/nested-sampling) parameter estimation
pipeline, simple-pe expands the likelihood locally around a matched-filter
trigger to produce an approximate posterior in CPU-minutes rather than
CPU-days -- useful for low-latency estimates, and as a fast cross-check or
seed for a more detailed run. This plugin enables Asimov to build and submit
simple_pe_pipe jobs for that analysis. It provides:
- Configuration templating: renders a
simple_pe_pipeconfig file from a bundled Liquid template, populated from a production's ledger metadata (config_template), for use byasimov manage build. - Trigger-parameter seeding: writes the small
trigger_parameters.jsonfilesimple_pe_pipeuses to seed its local optimisation, before the main config is rendered. - DAG generation and scheduler integration:
simple_pe_pipebuilds a real HTCondor DAG (plus a companion local-run bash script); this plugin submits that DAG via Asimov's own scheduler abstraction (self.scheduler, fromasimov.pipeline.Pipeline), which supports both HTCondor and Slurm -- rather than talking to a scheduler's API directly. - Status tracking: monitors job completion by checking for a genuine posterior samples file.
- DAG-rescue-aware resurrection: like
lalinference_pipe,simple_pe_pipeproduces a real DAGMan workflow; an interrupted run leaves a rescue DAG behind, which is resubmitted automatically. - Dependency-driven post-processing: once a job completes, its samples
are available (via
collect_assets()) to any downstream production with aneeds:dependency on it -- for example an asimov-pesummary production. This plugin doesn't submit that job itself; Asimov's own dependency resolution does.
Requires asimov>=0.7. This plugin does not depend on the simple-pe
Python package itself -- like the sibling asimov-lalinference and
asimov-bayeswave plugins, it shells out to the simple_pe_pipe
executable, which is expected to be installed separately in the environment
pointed at by Asimov's [pipelines] environment config option.
simple-pe is not currently distributed on PyPI or conda-forge; install it
from its GitLab repository (it depends on pycbc and lalsuite, both
available from conda-forge):
conda install -c conda-forge pycbc lalsuite
echo "setuptools<82" > constraints.txt # see note below
echo "numpy<2" >> constraints.txt # see note below
pip install -c constraints.txt "setuptools<82" "numpy<2"
pip install -c constraints.txt git+https://git.ligo.org/stephen-fairhurst/simple-pe.git
pkg_resourcesnote.simple_pe_pipeimportspycbc.waveform, which does a bareimport pkg_resourcesinretrieve_waveform_plugins()(see simple-pe issue #29, still open upstream).setuptoolsremoved the bundledpkg_resourcesmodule starting with 82.0.0 (confirmed directly: 81.0.0 still provides it, 82.0.0 doesn't), sosimple_pe_pipefails to even import withModuleNotFoundError: No module named 'pkg_resources'against an unpinned (or too-recent)setuptools-- confirmed directly via this plugin's own CI. A barepip install "setuptools<82"isn't enough on its own, either: installingsimple-pein a separatepip installright after it can silently re-resolvesetuptoolsback past 82 as one of its own transitive dependencies (also confirmed directly). A constraints file applied to both commands, as above, is what actually holds the pin.
numpy<2note.simple_pe_filter's ownload_trigger_parameters_from_file()wraps the loaded trigger-parameters dict inSimplePESamples(a pesummary-style samples container built for posterior chains, so even a single scalar comes back as a shape-(1,)array rather than a true 0-d array) beforesimple_pe.io.io.estimate_data_length_from_template_parametersdoesint(2**(np.ceil(np.log2(wf_len))))on a value derived from it. NumPy hard-errors this exact implicit shape-(1,)-to-scalar conversion since 2.0 (only aDeprecationWarningbefore that):TypeError: only 0-dimensional arrays can be converted to Python scalars, hit on every real analysis regardless of channel mode -- confirmed directly via this plugin's own e2e CI on the realfilterDAG node. Pinningnumpy<2(alongside every pip install here, for the same re-resolution reason assetuptoolsabove) keeps it a warning instead of a crash without touchingsimple-pe's own source.
A note on the config schema below. The keys used by this plugin's bundled template (
configs/simplepe.ini) --trigger_time,trigger_parameters,outdir,channels,asd,f_low,f_high,approximant,accounting_group,accounting_group_user,generate_corner,disable_pesummary-- and their value formats are confirmed directly against a real, livesimple_pe_pipe --helpand a real successful DAG build in this plugin's own end-to-end CI. Its output-file detection (samples()) is still an unconfirmed best-effort guess, though: it searches for common posterior/samples filenames rather than one exact confirmed name. If you hit a mismatch there (or anywhere else) against a realsimple_pe_piperelease, please open an issue or PR.
pip install asimov-simplepeFor development:
git clone https://github.com/transientlunatic/asimov-simplepe
cd asimov-simplepe
pip install -e ".[test]"Once installed, Asimov discovers this plugin automatically via its
asimov.pipelines entry point. Add a production to an event with
pipeline: simplepe:
kind: analysis
name: simplepe-imrphenomxphm
pipeline: simplepe
waveform:
approximant: IMRPhenomXPHM
likelihood:
minimum frequency:
H1: 20
L1: 20
scheduler:
accounting group: ligo.dev.o4.cbc.pe.simple_peasimov manage build submit will render a simple_pe_pipe ini (and the
accompanying trigger-parameters file) from the bundled template (unless a
config already exists in the event repository), build a DAG, and submit it.
peak_finder (default metric, simple-pe's own Fisher-matrix algorithm)
selects the peak-finding method simple_pe_pipe uses, e.g. production.meta['peak_finder']: scipy.
This isn't optional in practice: simple_pe_pipe's real --peak_finder
CLI option defaults to an empty list, and its DAG-building code creates
every actual analysis stage (the match-filter, metric, corner-plot, and
PESummary jobs) inside a loop over that list -- an empty list means the
loop runs zero times and the DAG silently ends up containing only its
datafind job, no error of any kind. Confirmed directly via this
plugin's own e2e CI, which is why this template always sets it.
production.meta['localization file'] (unset by default) is the path to a
FITS skymap, rendered as localization_file in the ini. simple_pe_analysis
draws its sky-position samples from it; this is how a real analysis uses the
search's BAYESTAR skymap. Without one, every posterior sample has the single
sky position from the trigger, so the other parameters are conditioned on
that position and no skymap can be produced. When a localization file is
given, ra/dec are left out of the trigger parameters, because
simple_pe_analysis raises if given both.
neffective (unset by default) caps the number of effective posterior
samples simple_pe_analysis targets before it stops iterating, e.g.
production.meta['neffective']: 20. Left unset, simple_pe_analysis
uses its own default (1000 effective samples, confirmed directly from
its real source) -- the right choice for real production use. This
plugin's own e2e test sets it low (see tests/test_blueprints/ simplepe_production.yaml): a real analysis against real GW150914 data,
even with a higher-mode-capable, non-precessing approximant (see the
"Known issue" note below), genuinely needs well over 25 wall-clock
minutes on a single CPU to converge to the default 1000, confirmed
directly via this plugin's own e2e CI -- more than is reasonable to
demand from a smoke test of this plugin's own config rendering, DAG
building, and submission (not a validation of simple-pe's actual
scientific output).
Strain data is read from a production's data metadata, using the same
conventions as the other Asimov gravitational-wave pipeline plugins (e.g.
asimov-gwdata, asimov-lalinference, asimov-pycbc):
-
data.channels-- per-interferometer strain channel name, without the leadingIFO:(this plugin adds that itself when rendering the ini).simple_pe_pipealso recognises two special values here, confirmed directly from its own--helptext:GWOSCto read public GWOSC open data instead of a private frame channel (this plugin's own end-to-end test uses this, against GW150914's real data), andINJto havesimple_pe_pipesimulate an injection itself rather than reading any real strain data at all -- no datafind access needed. When any interferometer usesINJ,before_config()also writes aninjection.jsonfile from the production'striggermetadata and the ini references it viainjection = ...--simple_pe_datafindrequires this unconditionally in that case (confirmed directly from its real source, via this plugin's own e2e CI). Known issue:INJmode currently hits a genuine upstreamsimple-pebug oncewrite_converted_injection_parameters()runs (aSimplePESamples- wrapped value eventually produces a NaN GPS time, sending LALSuite into what is for all practical purposes an infinite loop) -- confirmed directly via this plugin's own e2e CI (seeCHANGELOG.mdfor the full trail); this plugin's own code fully supportsINJ(unit-tested) and will use it correctly once fixed upstream, but the e2e test itself usesGWOSCinstead to avoid depending on that fix. -
data.asd-- per-interferometer path to a real, two-column (frequency, ASD) text file. There is no analytic-PSD-model-name shortcut -- confirmed directly from--help("ASD files to use for the analysis") and from this plugin's own e2e CI, which generates a real file from pycbc's analyticaLIGOZeroDetHighPowermodel for its test (a PSD is needed for the Fisher-matrix/SNR calculation regardless of where the strain data itself comes from). There's no Asimov-core-level mechanism for ASDs, so this is the only way to supply one. -
PSDs --
simple_pe_pipeaccepts--psdas a same-shaped alternative to--asd(confirmed directly from its own--help/parsed-Namespace output: both are dict-typed,{}-default options using the sameIFO:pathtoken format as--channels). This plugin resolves the PSD to render from two sources, in priority order:production.psds-- Asimov core's own, built-in mechanism (confirmed directly from its real source,asimov.analysis.GravitationalWaveTransient._collect_psds()): a top-levelpsds:key on the production's ledger entry (a dict ofIFO: path, not nested underdata:), or, if that's not set directly, PSDs pulled automatically from aneeds:-linked upstream production'scollect_assets()["psds"]. This is the real, established Asimov convention for pre-computed PSDs (confirmed directly from a real user's project ledger) -- prefer this.data.psd-- a secondary, plugin-local override for setting a PSD directly on the production alongsidedata.channels/data.asd, used only ifproduction.psdsis empty.
Supplying neither omits the
psd/asdkey entirely, whichsimple_pe_pipewill itself reject. -
data.data files-- Asimov core's own, built-in mechanism for local frame files (confirmed directly from its real source,asimov.analysis.GravitationalWaveTransient.__init__, which defaultsself.meta["data"]["data files"] = {}for every production, and from the sibling asimov-gwdata datafind pipeline, which populates it with exactly this{ifo: [path, ...]}shape). When every interferometer in the analysis has exactly one frame file here, this plugin renders astrain = {...}option alongsidechannels. Confirmed directly fromsimple_pe_pipe's real source: providing--strainat all makes it skipDataFindNode/simple_pe_datafindentirely and read the local frame file directly instead (viagwpy.timeseries.TimeSeries.read()) -- which also means theINJ/GWOSCchannel magic-value detection above never runs, so a real channel name that happens to contain "inj" (e.g. a custom test frame's channel literally called "Injection") is read literally instead of being misrouted into simulate-injection mode and its upstream bug. Known limitation: more than one frame file per interferometer isn't currently supported (simple_pe_pipe's bare--strain IFO:pathini-dict form -- the same format used forchannels/psd/asd-- has no list-literal syntax for stitching several files together); this raises a clear error rather than silently picking one.
This means a production populated by a data-retrieval step (for example
asimov-gwdata) can be picked
up by making the simplepe production needs: that data-retrieval
production -- its PSDs are then picked up automatically via
production.psds, and its channel output can be mapped into
data.channels/data.asd (stripping the data-retrieval step's own
IFO: channel prefix, if it includes one).
simple_pe_pipe seeds its local optimisation from an approximate set of
trigger parameters (masses, spins, sky location). Supply these via a
trigger block on the production:
trigger:
mass1: 36
mass2: 29
spin1z: 0.0
spin2z: 0.0
ra: 1.95
dec: -1.27
distance: 440
phase: 0
psi: 0Any parameter left unset falls back to a sensible default: mass1/mass2
default to 1.4 (solar masses), ra/dec/spin1z/spin2z/phase/psi
default to 0, and distance defaults to 400 Mpc; time is always taken
from the event's event time, not from this block.
Asimov core's own asimov manage build command (asimov/cli/manage.py's
build(), confirmed directly from its real source) only renders a
production's .ini -- and only calls this plugin's
before_config()/make_config() at all -- when no .ini already
exists for that production in the event repository. If one is already
checked in, asimov manage build finds it via
production.event.repository.find_prods(), does nothing, and moves on:
it does not re-render the ini and does not re-run before_config()
(which is what (re)writes trigger_parameters.json/injection.json into
the rundir).
This matters whenever a production's rundir has been cleared/reset (e.g.
after a stale/interrupted run) while its .ini is still checked into the
repository, or after upgrading this plugin to a version that changes what
the ini should contain: simply rerunning asimov manage build is a no-op
in both cases, and asimov manage submit will then fail because the
rundir-side files the (unchanged) ini still references are missing or
stale. To force a real regeneration, remove the existing .ini first
(and, for a clean rundir, the working directory too) before rebuilding:
rm path/to/checkouts/<event>/<category>/<production>.ini
rm -rf path/to/working/<event>/<production>/
asimov manage build
asimov manage submitThis plugin does not run PESummary (or anything else) itself. When a
simplepe production completes, after_completion() only marks it
finished -- post-processing is expressed as a separate production with a
needs: dependency on it, e.g.:
kind: analysis
name: simplepe-test-pesummary
pipeline: pesummary
needs:
- simplepe-imrphenomxphm
postprocessing:
pesummary:
multiprocess: 2Asimov's own dependency resolution builds and submits
simplepe-test-pesummary once simplepe-imrphenomxphm reaches finished;
PESummary picks up its samples via simplepe-imrphenomxphm's
collect_assets() (through production._previous_assets()).
This is also why configs/simplepe.ini sets disable_pesummary = True.
simple_pe_pipe's own DAG otherwise bakes a full PESummary
post-processing job into itself as a child of the analysis node whenever
that option is left at its default False (confirmed directly from its
real main() source) -- duplicating the separate, needs:-linked
PESummary production above, and running PESummary twice for no benefit.
Known issue (root-caused, not a permanent blocker): simple_pe_pipe's
own analysis stage can hit a real crash before producing a posterior
samples file: PESummary's subdominant-multipole rejection-sampling
reweighting (pesummary.core.reweight.rejection_sampling(), called
unconditionally from simple_pe.param_est.pe. reweight_based_on_observed_snrs()) has no guard against a non-finite
weight, and raises OverflowError: Range exceeds valid bounds when one
occurs. This is a different use of PESummary than the
disable_pesummary/PostProcessingNode above: it happens inside
simple_pe_analysis itself, as part of generating posterior samples from
the Fisher-matrix point estimate, and isn't gated by disable_pesummary
at all.
This first looked like a genuine, unfixable upstream numerical-robustness
bug -- but root-causing it directly via this plugin's own e2e CI (see
CHANGELOG.md for the full trail) found the real trigger: the
reweighting step unconditionally measures the observed SNR in the
(3,3)/(4,4) higher multipoles and in precession, regardless of whether
the configured waveform.approximant can represent them. Against a
dominant-mode-only, non-precessing approximant (e.g. IMRPhenomD), those
observed SNRs come back NaN, and the reference distribution built from
them underflows to exactly 0.0 probability for essentially every
sample once zeroed -- not a rare outlier, but systemic corruption. Using
an approximant that actually supports higher modes (e.g. IMRPhenomXHM
or IMRPhenomXPHM -- the latter is simple_pe_filter's/
simple_pe_analysis's own --approximant default) avoids this
entirely, confirmed directly: no OverflowError (or any other crash)
recurs. simple-pe's own real-waveform interpolation grids do make a
precessing+higher-mode approximant meaningfully slower to converge than
a dominant-mode-only one, though -- see the Effective sample target
section above for how this plugin's own e2e test manages that.
Defensive safety net.
simple-pe'sreweight_based_on_observed_snrs()/PESummary'srejection_sampling()still have no guard against a non-finite weight even with a correct approximant choice -- a real analysis could still hit a similarly degenerate weight for other reasons this plugin hasn't characterised.scripts/patch_simple_pe_reweight_guard.pypatches an installedsimple-pe'sreweight_based_on_observed_snrs()in place to zero out any non-finite weight before it reachesrejection_sampling()-- treating a numerically broken sample as zero-probability (rejected) rather than crashing, or (worse) treating it as certain. The correct fix still belongs upstream (in eithersimple-peorpesummary, since any ofrejection_sampling()'s other callers could hit the same crash); this is deliberately just a stopgap. Run it once, in the same Python environmentsimple_pe_analysisruns in, right after installingsimple-peand before running any real analysis:python scripts/patch_simple_pe_reweight_guard.pyIt's idempotent (a no-op if already applied) and fails loudly, rather than silently doing nothing, if
simple-pe's source has changed enough that its exact expected original text can't be found -- see the script's own docstring for the full rationale. This can't be applied automatically byasimov_simplepeitself at import time: the buggy code runs insidesimple_pe_analysis's own HTCondor subprocess, a completely separate Python process from asimov's, so a plugin-level monkeypatch would never reach it -- it has to be applied to the installedsimple-pepackage directly, in whatever environment actually runs the analysis jobs. This plugin's own CI applies the exact same script (see.github/actions/setup-simplepe-env/action.yml).
Unit tests (mocked, no external dependencies):
pip install -e ".[test]"
pytest.github/workflows/e2e.yml also runs a genuine end-to-end test: a real
simple_pe_pipe DAG built and submitted through a real HTCondor scheduler
against real GW150914 GWOSC data, waiting for and validating the real
peak_parameters.json/peak_snrs.json output its analysis node
produces. It also waits (best-effort; not yet a hard pass/fail
requirement) for a real, final posterior_samples.dat -- see the
"Known issue" note under Post-processing and the Effective sample
target section above for the full trail on why a full analysis needs
neffective capped for this to be feasible in CI at all.
.github/workflows/docs.yml also checks that the subcommand and flags used
by every asimov ... command shown in docs/*.rst still exist on the live,
installed asimov CLI, on every pull request
(scripts/lint_tutorial_commands.py).
Contributions welcome! Please submit issues or pull requests to transientlunatic/asimov-simplepe.
MIT -- see LICENSE.