Skip to content

Add P-amplitude and S/P-amplitude-ratio inversion support#348

Open
MHkhosravi wants to merge 3 commits into
mtuqorg:masterfrom
MHkhosravi:add-ps-amplitude-inversion
Open

Add P-amplitude and S/P-amplitude-ratio inversion support#348
MHkhosravi wants to merge 3 commits into
mtuqorg:masterfrom
MHkhosravi:add-ps-amplitude-inversion

Conversation

@MHkhosravi
Copy link
Copy Markdown

PR: Add P-amplitude and S/P-amplitude-ratio inversions to MTUQ

Branch: add-ps-amplitude-inversion
Fork: https://github.com/MHkhosravi/mtuq
Target: mtuqorg/mtuq master


Summary

This PR adds optional P-amplitude and S/P-amplitude-ratio misfits to MTUQ's
existing waveform and polarity inversion workflows.

The main addition is PolarityPampSPampRatioMisfit, a new misfit class that can
combine first-motion polarity, P-amplitude, and S/P-amplitude-ratio information
with MTUQ's grid-search workflow. All three terms are independently weighted and
can be used in any combination, including polarity-only (backward-compatible with
the existing PolarityMisfit use pattern) and all three jointly.


Main changes

New files

File Description
mtuq/misfit/polarity_amplitudes.py Core implementation: PolarityPampSPampRatioMisfit, radiation-pattern calculator _pol_pamp_spamp_mt, and helper utilities
examples/Waveforms+Polarities+Pamp+SPratio.py End-to-end joint waveform + polarity + P-amp + S/P-ratio example
tests/test_polarity_amplitudes.py Data-free unit tests covering constructor validation, observation parsing, radiation-pattern math, misfit normalisation, and USE→NED conversion

Modified files

File What changed
mtuq/__init__.py Export PolarityPampSPampRatioMisfit and polarities_pamp_spamp_ratio_from_dict from the top-level API
mtuq/misfit/__init__.py Re-export new class alongside existing PolarityMisfit and WaveformMisfit
mtuq/grid_search.py Add grid_search_multicomponent for multi-term weighted misfit grid searches
mtuq/grid/moment_tensor.py Minor additive helpers for grid orientation coordinates (backward-compatible)
mtuq/util/math.py Add to_delta, to_gamma, lune_* coordinate utilities used by new plots
mtuq/util/__init__.py Export new math utilities
mtuq/util/beachball.py Minor additive helpers for beachball geometry
mtuq/graphics/beachball.py Add plot_beachball_withbestDC, plot_beachballs_useddata; relax plot_beachball type constraint to accept array-like moment tensors
mtuq/graphics/__init__.py Export new plot functions
mtuq/graphics/uq/_matplotlib.py Additive _add_marker, _generate_lune helpers used by new scatter plots
mtuq/graphics/uq/_gmt/plot_lune Minor additive support for scatter-mode lune plots
mtuq/misfit/waveform/level2.py Optional progress-bar chunking (guarded; original code path is fully preserved)
pyproject.toml Fix [tool.setuptools][tool.setuptools.packages.find] so that all sub-packages (mtuq.*) are found by setuptools

Motivation

MTUQ already supports waveform-based and polarity-based moment tensor workflows.
This PR extends those capabilities by allowing users to include P-amplitude and
S/P-amplitude-ratio observations as additional misfits.

P-wave first-motion amplitude and S/P amplitude ratio can provide complementary
source information, particularly for smaller events where surface-wave signal is
limited or waveform fits are non-unique. These additional observations can be
read from station picks (e.g., CAP format files) and incorporated alongside the
waveform grid search with user-controlled weights, so the contribution of each
data type can be tuned.


Implementation notes

Radiation pattern

Predicted P polarity, P amplitude, and S/P amplitude ratio are computed from
far-field radiation patterns in NED coordinates using the Aki & Richards
formulation. USE-convention MTUQ moment tensors are converted to NED internally.
Takeoff angles and azimuths come from TauP (default) or FK/CPS metadata.

Optional geometric spreading (rho, vp, vs, distance) converts radiation
patterns to physical amplitude units. When method='taup', these parameters are
extracted automatically from the TauP velocity model at the source depth.

Zero S-radiation robustness (bugfix in this PR)

Isotropic or S-nodal sources produce exactly zero S radiation at some or all
stations. Without a guard, log10(0) propagates as -inf through the S/P ratio
and poisons the misfit surface. A np.maximum(s_mag, eps) floor on the S
numerator (both geometric-spreading and plain-radiation branches) keeps the ratio
finite. A dedicated unit test (test_isotropic_sp_ratio_is_finite) covers both
branches.

Misfit normalisation

Each term (polarity, P-amplitude, S/P-ratio) is independently L1- or L2-normalised
by the sum of weighted absolute observed values. This makes each term dimensionless
and comparable when combined via lambda_pol, lambda_pamp, lambda_sprat.
Stations with zero weight, zero polarity (unpicked), missing amplitude, or
non-positive S/P ratio are automatically excluded from the relevant term.

Backward compatibility

  • PolarityMisfit is preserved unchanged.
  • PolarityPampSPampRatioMisfit accepts polarity-only data input for
    drop-in compatibility with existing polarity-based workflows.
  • plot_beachball now accepts both MomentTensor objects and six-element
    USE-convention arrays; previous code only accepted MomentTensor.
  • All new grid and util helpers have conservative defaults so existing scripts
    are unaffected.

Verification

Compilation and import

# From inside mtuq/
python -m compileall mtuq tests examples -q
PYTHONPATH=$PWD python tests/check_import.py   # 57 modules OK, 0 failures

Unit tests (data-free, fast)

PYTHONPATH=$PWD python -m pytest tests/test_polarity_amplitudes.py -v
# 23 passed, 0 failed

Waveform misfit regression

# Requires benchmark data at data/tests/benchmark_cap/ and benchmark_cps/
# Run as standalone scripts (not via pytest — they use __main__ guard):
PYTHONPATH=$PWD python tests/test_misfit.py --no_figures
PYTHONPATH=$PWD python tests/test_grid_search_mt.py --no_figures
PYTHONPATH=$PWD python tests/test_mij.py --no_figures

Example (requires MPI, data download, and ~4 minutes runtime)

# Data in data/examples/20090407201255351/ is already present in the repo.
# The example downloads Greens functions from IRIS syngine on first run.
mpirun -n 4 python examples/Waveforms+Polarities+Pamp+SPratio.py

Full end-to-end validation (polarity+amplitude grid search over 210k sources)

A lightweight validation script was run and produced:

FMT grid size: 210000
Number of misfit evaluations: 210,000
[joint/FMT] finite: True  min=1.068  max=9.49
[pol-only/DC] finite: True  min=0.1176  max=0.9412
Best FMT idx: 36802  moment magnitude: 4.5
VALIDATION OK  EXIT=0

Open questions / flags for reviewer

The following items are scientifically or technically ambiguous and should be
reviewed by domain experts before merging:

  1. P-amplitude units — When lambda_pamp > 0, predicted P amplitudes (from
    radiation pattern × geometric spreading) must have the same units as the
    observed values supplied in pamp_dict. The code does not enforce or document
    this requirement beyond a docstring note. Users who supply raw waveform
    peak amplitudes without normalising by the same 1/(4πρv³r) factor will get
    a misfit that is numerically large but physically meaningless. Consider adding
    a warning or example guidance.

  2. sp_obs_is_log10 flag in example — The example sets sp_obs_is_log10=True
    and supplies spamp_dict values in the range 0.46–2.07. A comment says
    "This CAP field already stores log10(S/P)", which would mean the actual S/P
    ratios are 10^0.46 ≈ 2.9 to 10^2.07 ≈ 117. Whether the original CAP data
    stores log10 or linear ratios should be confirmed against the data source.

  3. FK/CPS methods declared but not implementedmethod='FK_metadata' and
    method='CPS_metadata' are accepted by __init__ but raise
    NotImplementedError immediately when any calculation is attempted. Consider
    raising in __init__ instead to give an earlier, clearer error.

  4. Default grid size reductionFullMomentTensorGridRandom default npts
    was reduced from 1,000,000 to 50,000 and DeviatoricGridRandom from 100,000
    to 50,000. Confirm this is intentional and not a debugging leftover.


Files NOT included in this PR

  • data/tests/benchmark_cap/, data/tests/benchmark_cps/ (downloaded test data,
    now gitignored)
  • data/examples/ (gitignored)
  • build/, *.egg-info/, __pycache__/, .pytest_cache/ (gitignored)
  • Any generated *.png, *.nc, *.json output files
  • mtuq_test/ validation outputs

Rebase note

At time of preparation this branch (add-ps-amplitude-inversion) is 7 commits
behind upstream/master
. The upstream commits touch mtuq/__init__.py,
mtuq/graphics/beachball.py, and pyproject.toml — all in different lines from
our changes. git merge-tree confirms a clean three-way merge.

Rebase before opening the PR:

git fetch upstream
git rebase upstream/master
# Review carefully — 3 files overlap (different lines, should be clean)
# Then force-push to your fork:
git push --force-with-lease origin add-ps-amplitude-inversion

- Add PolarityPampSPampRatioMisfit for joint polarity, P-amplitude,
  and S/P-amplitude-ratio objectives
- Add multicomponent grid search with per-component weighting,
  optional normalization, and component result output
- Add example for waveform, polarity, P-amplitude, and S/P-ratio inversion
- Add beachball/data plotting support for polarity-amplitude results
- Include mtuq subpackages in wheel packaging
- Add focused tests for polarity-amplitude helpers
Copilot AI review requested due to automatic review settings May 23, 2026 22:10
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends MTUQ’s inversion workflow to optionally include P-wave amplitude and S/P amplitude-ratio information alongside existing waveform and polarity inversions, adding new misfit/grid-search utilities and plotting helpers to support joint analyses.

Changes:

  • Added PolarityPampSPampRatioMisfit (polarity + P-amplitude + log10(S/P)) with dictionary-to-observation helpers and unit tests.
  • Added grid_search_multicomponent plus batching/progress-bar improvements to support multi-term misfit evaluation and combination.
  • Expanded plotting utilities (beachballs and lune plots) and added a new end-to-end example script.

Reviewed changes

Copilot reviewed 15 out of 17 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
mtuq/misfit/polarity_amplitudes.py New joint polarity/P-amp/S/P misfit implementation and helpers
tests/test_polarity_amplitudes.py Data-free unit tests for new misfit and helpers
mtuq/grid_search.py Adds multi-component grid search + optional batching
mtuq/grid/moment_tensor.py Adds orientation-grid helpers and changes random-grid defaults
mtuq/util/math.py Adds MT decomposition/lune-coordinate utilities used by new plots
mtuq/util/__init__.py Upgrades ProgressCallback; refactors DataArray min/max helpers
mtuq/misfit/waveform/level2.py Optional chunking when using the new progress bar
mtuq/graphics/beachball.py Accepts array-like MTs; adds best-DC overlay and used-data plotting
mtuq/graphics/uq/_matplotlib.py Adds lune reference points/labels; tweaks lune axes/figure size
mtuq/graphics/uq/_gmt/plot_lune Adds lune reference points/labels in GMT backend
mtuq/misfit/__init__.py Re-exports new misfit class and dict helper
mtuq/__init__.py Exposes new misfit from top-level API
mtuq/graphics/__init__.py Exports new beachball plotting functions
examples/Waveforms+Polarities+Pamp+SPratio.py New end-to-end joint inversion example
pyproject.toml Fixes setuptools package discovery for mtuq.*
.gitignore Adjusts ignored benchmark/example data + adds pytest cache
Comments suppressed due to low confidence (2)

mtuq/grid/moment_tensor.py:24

  • The default npts for FullMomentTensorGridRandom changed from 1,000,000 to 50,000. This is a significant behavioral change for users relying on default grid density and can affect inversion quality. Please confirm this is intentional and consider keeping the old default or documenting the change prominently (e.g., release notes).
def FullMomentTensorGridRandom(magnitudes=[1.], npts=50000):
    """ Grid with randomly-drawn full moment tensors

    Given input parameters ``magnitudes`` (`list`) and ``npts`` (`int`),
    returns an ``UnstructuredGrid`` of size `npts*len(magnitudes)`.

mtuq/grid/moment_tensor.py:100

  • The default npts for DeviatoricGridRandom changed from 100,000 to 50,000. Like the full MT grid, this reduces default search resolution and may change results for existing scripts. Please confirm this is intentional and consider documenting it or retaining prior defaults for backward compatibility.
def DeviatoricGridRandom(magnitudes=[1.], npts=50000):
    """ Grid with randomly-drawn deviatoric moment tensors

    Given input parameters ``magnitudes`` (`list`) and ``npts`` (`int`),
    returns an ``UnstructuredGrid`` of size `npts*len(magnitudes)`.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread mtuq/misfit/polarity_amplitudes.py Outdated
Comment on lines +63 to +67
- ``'waveform'``
Determine predicted polarity and amplitudes directly from full-waveform
synthetics


Comment thread mtuq/misfit/polarity_amplitudes.py Outdated
Comment on lines +649 to +651
if isinstance(data, tuple):
return True

Comment on lines +689 to +696
if norm == 'L2':
denominator = np.sum(weighted_obs**2)
if denominator <= 0.0:
return np.zeros(n_mt, dtype=np.float64)
numerator = np.sum(
(weights[None, :] * residual)**2,
axis=1)
return numerator / denominator
Comment thread mtuq/misfit/polarity_amplitudes.py Outdated
if r_i <= 0.0 or rho_i <= 0.0 or vp_i <= 0.0 or vs_i <= 0.0:
p_amplitude[:, _i] = Rp
sp_amplitude_ratio[:, _i] = np.log10(
np.sqrt(Rsv**2 + Rsh**2) / np.maximum(np.abs(Rp), eps)
Comment thread mtuq/misfit/polarity_amplitudes.py Outdated
Comment on lines +945 to +956
return

#model = _model_type(greens)
#method = _method_type(method)

#if model != method:
# print()
# print(' Possible inconsistency?')
# print(' Green''s functions are from: %s' % model)
# print(' Polarities are from: %s' % method)
# print()

Comment thread mtuq/util/math.py
Comment on lines +639 to +646
def _decompose_moment_tensor_ned(mt, vector=True):
mt_array = _full_mt_array_ned(mt)
eigenvalues, eigenvectors = np.linalg.eig(mt_array)
max_value = eigenvalues[np.argsort(np.abs(eigenvalues))[2]]
vol = np.sum(eigenvalues) / 3.0
m_star = eigenvalues - vol
sorted_index = np.argsort(np.abs(m_star))
max_value_star = m_star[sorted_index[2]]
Comment thread mtuq/util/math.py
Comment on lines +673 to +678
def _mom2other_ned(mt):
mt_array = _full_mt_array_ned(mt)
eigenvalues, eigenvectors = np.linalg.eig(mt_array)
sorted_index = np.argsort(eigenvalues)
p_axis = _v2trpl(eigenvectors[:, sorted_index[0]].copy())
t_axis = _v2trpl(eigenvectors[:, sorted_index[2]].copy())
Comment thread mtuq/grid_search.py Outdated
Comment on lines +601 to +603
scale = np.percentile(values, percentile)
if scale <= 0.0:
return values
Comment on lines +288 to +299
results_polarity_amplitude = grid_search_multicomponent(
polarity_amp_data, greens, polarity_amplitude_misfit, origin, grid
)

if comm.rank == 0:
results = results_bw + results_sw

# `grid` index corresponding to minimum misfit
idx = results.source_idxmin()

best_mt = grid.get(idx)
lune_dict = grid.get_dict(idx)
Comment thread mtuq/util/__init__.py
"""Returns coordinates for the first finite global min/max of a DataArray."""
values = np.asarray(da.values)
if values.size == 0:
raise ValueError("Cannot index an empty DataArray")
@MHkhosravi MHkhosravi marked this pull request as draft May 26, 2026 17:59
@MHkhosravi MHkhosravi marked this pull request as ready for review May 26, 2026 18:08
@MHkhosravi
Copy link
Copy Markdown
Author

MHkhosravi commented May 26, 2026

Addressed the Copilot review feedback in commit 54490c9.

Main fixes:

  • Clarified the supported methods.
  • Disambiguated polarity-only tuples from observation bundles.
  • Made the L2 weights linear.
  • Kept S/P ratios finite in the invalid-geometric-spreading fallback.
  • Hardened percentile normalization and all-NaN DataArray indexing.
  • Switched symmetric moment-tensor eigensolves to eigh.
  • Restored the default random-grid sizes.
  • Included the polarity/amplitude term in the example joint objective.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants