[magnitudes] Add Mwpd duration-amplitude moment magnitude plugin#125
[magnitudes] Add Mwpd duration-amplitude moment magnitude plugin#125comoglu wants to merge 15 commits into
Conversation
Native port of Early-est 1.2.9's Mwpd (Lomax & Michelini 2009): a per-station moment magnitude from the integral of the vertical displacement over the source duration T0. Amplitude processor (type "Mwpd"): from the raw vertical window it restitutes to velocity, high-passes at the BRB-HP corner (0.15 Hz), integrates to displacement and accumulates the running double integral into separate positive/negative displacement lobes. The source duration T0 is estimated from the high-frequency envelope (1-5 Hz band-pass, squared, boxcar-smoothed, 90/80/50/20 %-of-peak level crossings). It emits the displacement integral at T0 in nm*s and carries T0 as the amplitude period. Magnitude processor (type "Mwpd"): forms M0 from the Tsuboi constant and applies the INGV_EE distance correction, the PREM step depth correction and the >90 s duration ramp. Builds as plugin mwpd; no component combiner (vertical P only). Bindings documented in descriptions/global_mwpd.xml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…progressive updates Reworks the Mwpd amplitude/magnitude processors after offline + real-time playback testing against Mwp/mb/CMT: - Source-duration T0 estimated on the SQUARED (power) HF envelope via the 90/80/50/20%-of-peak last-crossing logic (Lomax & Michelini 2009, Fig. 2); envelope band ~1 Hz. - Integration over min(maxDuration, S-P): the displacement double-integral is accumulated in positive/negative lobes and read at the window end, with the window capped at the theoretical S-P time (libtau/iasp91) so it excludes S/surface-wave energy. Matches Early-est's MAX_MWPD_DUR window behaviour; the eager short-T0 cut under-integrated and read ~0.4 mag low. - Completeness gate: emit only once data covers the integration window; incomplete stations are skipped rather than reported on a partial window. - Progressive updates (setUpdateEnabled) so the value is computed incrementally as data streams; finalizes when the window is complete. - Moment scaling gated on magnitude (Mwpd += 0.45*(raw-7.2) for raw>7.2), eq (5a), instead of T0>90 s. - S-P cap via a configurable travel-time table (libtau/iasp91). On a teleseismic M~7.3 test this gives Mwpd 7.2, consistent with Mwp 7.3 / mb 7.2. Constant remains Early-est's Tsuboi form; absolute calibration vs GCMT to follow.
…gration Calibration against GCMT (and Lomax & Michelini 2009, Table 1) showed Mwpd reading increasingly low with magnitude (saturation). Root cause, confirmed from the Early-est source: the displacement high-pass is filter_hp_bu_co_0_005_n_2 = Butterworth 0.005 Hz, order 2 -- not 0.15 Hz (MWPD_GAIN_FREQUENCY is only the gain-reference frequency, not a filter). A 0.15 Hz high-pass removes periods >7 s and strips the long-period moment that dominates great-event integrals. Changes: - highpassCorner 0.005 Hz / order 2 (was 0.15 Hz / 4); config key renamed gainFrequency -> highpassCorner. - HF envelope band restored to 1-5 Hz (filter_bp_bu_co_1_5_n_4). - Integrate the displacement over min(T0, S-P, MAX_MWPD_DUR=1200 s) instead of a fixed window; finalize once the T0 search terminates or the window is full (progressive updates), else keep waiting / drop unresolved (MWPD_INVALID). - Moment scaling switched to Early-est's active duration-gated form (T0>90 s ramp 90->110 s) * (raw-7.2) * 0.45. Validated against GCMT: big events (M7.6-8.4) mean -0.15 vs CMT with no saturation (e.g. M8.4 7.33->8.28); full run mean ~-0.05, sigma ~0.16, slope ~1.0 (no high-M droop).
Method, amplitude/magnitude processing, the 0.005 Hz BRB-HP and T0-based integration, configuration notes, the GCMT calibration result (slope ~1.02, sigma ~0.15, no saturation) and references (Lomax & Michelini 2009; Tsuboi 1995).
…e docs Embeds the validation against GCMT (972 events, Mw 6.0-8.8): Mwpd-vs-Mw scatter figure plus per-magnitude and per-depth difference tables (slope 1.02, mean +0.05, sigma 0.15, no saturation), in global_mwpd.rst.
|
Interesting addition, thank you. From the technical side I prefer to implement as much as possible in private namespace to not expose symbol which won't be used outside the plugin and which are cluttering the symbol table. I still have no final recipe to do that with multiple object files, but |
…l table Per review (gempa-jabe): the plugin's own classes and helper functions were exported (16 global symbols) although nothing outside the plugin uses them -- the loader needs only createSCPlugin and the processors register via static factories. Give the cross-object-file symbols (readMwpdConfig, the moment/ distance/depth corrections, and the two processor classes) hidden visibility via a small MWPD_LOCAL attribute; single-TU helpers were already in anonymous namespaces. Result: zero exported Mwpd symbols, createSCPlugin unchanged, plugin still loads and registers.
|
Thanks! Addressed in 5fd3a1b: the plugin's classes and helpers used across object files now get hidden visibility via a small MWPD_LOCAL |
Drop the now-unused t0Resolved flag (resolution is tested via durRaw>0), which also clears a -Wunused-but-set-variable warning, and correct the setup debug label from "gainFreq" to "HPcorner".
- Clarify no-saturation applies to long-rupture events (tsunami / great quakes). - Cite "Lomax & Michelini 2009" explicitly for eq. 3 and eq. 5a. - Reword the progressive-computation note for real-time application. - Replace the SeisComP "estimateMw identity" jargon with a plain statement that no further Mw conversion is applied and the network value is a robust average of station Mwpd. - Note Mwpd(RT) (Lomax & Michelini 2013) as a lower-latency real-time variant and add the reference.
|
This looks pretty good. I haven't tried it but I trust you. 👍 Certainly a very valuable addition, thanks a lot! In your comparison with Mw(GCMT) it would seem to me that Mw is slightly but systematically overestimated for Mw around 6 - 6.5 - is this just a visual impression? Generally speaking, even small trends can be corrected for by defining a mapping Mwpd -> Mw(Mwpd) just like we have it for other Mw proxies. Please evaluate if that would make sense. Can also be added later, of course. Technically there are some minor code formatting suggestions especially avoid one-liners like
but write them as |
|
Thanks, @jsaul ! You are right on the low end , it's a real bias but small. It is about +0.07 on average for Mw 6-7, and it's inside the ~0.14 scatter. It's not really a clean slope either (7-7.5 actually sits at −0.05). Since Mwpd already comes out as a moment magnitude, I didn't want to bake in a network-specific fit, so I left it uncorrected by default. But anyone who wants to flatten it can do it from config with the usual scmag knob parameter magnitudes. Mwpd.multiplier/.offset for a linear station correction, or magnitudes.Mwpd.MwMapping for a proper Mw(Mwpd) mapping like the other proxies. I added a note about that to the docs. Formatting is fixed too, expanding the one-liner ifs. Will push shortly. |
…ction Note the small low-end bias (mean +0.07 for Mw 6-7, within scatter) and point to the standard scmag mechanisms to correct it without code changes: the linear station correction (multiplier/offset) and the MwMapping proxy mapping, as used for other Mw proxies. Raised in review (jsaul).
0d08cfe to
27a3235
Compare
|
Awesome job, thanks again! @gempa-jabe do you have any objections to merging? |
| // emits a growing Mwpd and finalizes as soon as T0 resolves/terminates, | ||
| // rather than waiting the full (long) signal window. maxDuration is just | ||
| // the upper bound on how long we keep looking. | ||
| setUpdateEnabled(true); |
There was a problem hiding this comment.
Actually this is not the decision of the AmplitudeProcessor. This is application controlled. And applications expect this value to be set to false initially. If we need update in scamp then it should be added there.
There was a problem hiding this comment.
Important point. As scamp only computes amplitudes, an amplitude update would be required for each incoming data record until the AmplitudeProcessor_Mwpd decides that it has finished its job, which may take several minutes in case of very large EQs. These updates would be needed in order to allow the downstream (especially scmag) to capture all intermediate amplitudes to actually make use of them. It will then also have to generate intermediate station magnitudes to continuously update the network magnitude.
My concern would be that this might stress the processing/messaging at a time (soon after a potentially catastrophic EQ) where smooth processing is absolutely crucial.
gempa-jabe
left a comment
There was a problem hiding this comment.
As usual, I prefer private namespaces for plugin classes to not expose symbols into the global namespaces which are actually not required. Using private linkage for the plugin is my preference.
…a-jabe) Address gempa-jabe's review by relying on AmplitudeProcessor's existing configuration instead of parallel plugin-specific machinery: - Drop setUpdateEnabled(true) from the amplitude processor. Whether to emit progressive amplitude updates is application-controlled and defaults to off; forcing it on would make scamp update the amplitude on every record (and scmag regenerate station/network magnitudes) for minutes after a great EQ. The value finalizes once the T0 search terminates or the maxDuration window is complete. (Also raised by jsaul re: post-event messaging load.) - Drop the plugin's tttBackend/tttModel keys and read the travel-time table for the S-P cap from the standard amplitudes.ttt.interface/model settings (AmplitudeProcessor::Config::ttInterface/ttModel), defaulting to libtau/iasp91 when unset. - Drop the duplicate minimumDistance/maximumDistance keys. The amplitude processor already reads amplitudes.Mwpd.minDist/maxDist into Config::minimumDistance/maximumDistance and enforces the distance gate; the teleseismic 5-105 deg range is applied as the default. The magnitude processor keeps using the base _minimumDistanceDeg/_maximumDistanceDeg. Update global_mwpd.xml to drop the four removed parameters.
Per gempa-jabe's preference for private linkage, move AmplitudeProcessor_Mwpd and MagnitudeProcessor_Mwpd out of the shared header and into anonymous namespaces in amplitude.cpp / magnitude.cpp. Each class is used only within its own translation unit (it self-registers via a static InterfaceFactory), so it needs no external linkage at all and now stays out of the symbol table entirely rather than relying on hidden visibility. The header keeps only what genuinely crosses object files: the MwpdConfig struct, readMwpdConfig and the moment/distance/depth correction helpers, which cannot be anonymous and retain hidden visibility (MWPD_LOCAL). No functional change. Verified against 7.3.0: clean build, plugin loads and both Mwpd processors register; none of the plugin's own symbols are exported (only createSCPlugin).
Retire the plugin-specific maxDuration key in favour of the AmplitudeProcessor's standard signalEnd window, as discussed with gempa-jabe (PR review): the two were the same quantity (the end of the post-P integration / T0-search window), so the separate key was a duplicate that silently overrode the generic signalEnd config. The amplitude processor now sets MWPD_DEFAULT_MAX_DUR (1200 s) as the default signalEnd in its constructor and no longer overwrites it in applyConfig, so the standard amplitudes.Mwpd.signalEnd setting controls the cap. computeAmplitude reads the cap from the configured signalEnd (cached in _maxDur). analysisPreP is kept: it is the pre-P integration offset, not the noise window, and gempa-jabe confirmed it is fine to keep as its own parameter. Drop the maxDuration binding from global_mwpd.xml and update the rst / debug text. Verified against 7.3.0: default signalEnd resolves to 1200 s and an amplitudes.Mwpd.signalEnd override (600 s) takes effect; plugin builds, loads and both Mwpd processors register.
…on amplitude) Mwpd is a duration amplitude: it must observe the streaming data to detect when the source stops (T0) and integrate up to that point. With updates disabled the base AmplitudeProcessor calls computeAmplitude only once the full signalEnd window (default 1200 s / MAX_MWPD_DUR) has streamed (amplitudeprocessor.cpp: unlockCalculation requires progress >= 100), so in real time / playback the value never finalizes at T0 and no Mwpd is produced. Re-enable updates so computeAmplitude runs progressively as data arrives. It returns InProgress on every record until T0 resolves and then emits the amplitude exactly once, so -- despite updates being enabled -- no intermediate amplitudes reach the messaging (addressing the concern raised in review). scamp offers no configuration to enable updates, so a self-terminating amplitude must enable its own. Verified on a SeisComP 8.0.0 playback: without this, computeAmplitude never completes; with it, Mwpd finalizes at T0 as intended.
|
A correction to my earlier change. I removed setUpdateEnabled(true) after your review, but testing it in a real playback showed it stops Mwpd from working at all. With updates disabled, the base only calls computeAmplitude once the full signalEnd window (1200 s) has streamed. Mwpd is a duration amplitude, it has to watch the data to find where the source stops (T0), so it needs progressive evaluation. Without it, T0 never resolves in time, and no Mwpd is produced. @jsaul, on your concern about messaging load: I tested this on a real playback (Japan M7.7) with updates enabled. computeAmplitude returns InProgress on every record until T0 resolves and then emits the amplitude exactly once, so there is no per-record spam. scamp stayed at 3.9-6.6% CPU the whole run and produced a clean network Mwpd (~7.4, 10 stations, 6.97-7.65 range) with no messaging issues. So I think the concern does not apply here in practice, but let me know if you would rather see it differently. I have put updates back on (8451f49) with a comment in the code explaining why. |
That explanation does not make much sense to me. Updates just allow computation before the full time window has been streamed. But that is not guaranteed, also the time steps are not guaranteed and rely on the size of the records. It could be possible that you will receive one huge record covering the entire 1200s window. Not common but possible. So the internal logic should be able to handle that. Would record size of 4096 by also cause issues by e.g. computing a different T0? What would be the difference in feeding data sample by sample? |
|
Amplitude updates for large events with long rupture duration are extremely useful of course. But with the extreme example by @gempa-jabe in mind, what if records are really short and updates are triggered by every short record? That may indeed be more updates than ever needed by the downstream. IMHO there needs to be a parameter that controls the minimum interval between amplitude updates sent to the messaging. Probably once every 30 seconds would be a good default value. Also the downstream must be able to know whether an amplitude computation is finished or not yet. |
|
Good point. didn't want to just move on, so I had Claude walk the actual code path instead of assuming. TimeWindowProcessor::fill() (common/libs/seiscomp/processing/timewindowprocessor.cpp:84-86) just does _data.append(n, samples) — strictly append-only, no truncation, no ring buffer, no eviction. Whatever chunking arrives on the wire, the data/i2 handed to computeAmplitude is always the complete buffer from the start of the signal window, on every call. AmplitudeProcessor_Mwpd carries no cross-call state — its only members are the config, the output array, the tt-table pointer, and the cached _maxDur. Nothing stores T0 or level-crossing progress between calls. Every computeAmplitude call rebuilds velocity, the HP/BP filters, and rescans the 90/80/50/20% crossing loop from iPick to n from scratch, with fresh filter instances and purely local state. So for a given cumulative sample count n, the array being scanned is bit-identical no matter how it arrived — one 1200s record or a thousand small ones. Record size changes when we happen to check (updates=true just lets us check before signalEnd), never what the answer is when we do. A 4096-sample record wouldn't give a different T0 than the same samples split up — same array, same scan, same result. What do you think ? @gempa-jabe |
Checked the code for this too. emitAmplitude() (the call that sends the message) only fires when computeAmplitude() returns true and Mwpd returns false on every call except the one where T0 actually resolves. So regardless of record size, Mwpd sends at most one Amplitude message per station, ever. No growing sequence of values to throttle, so I don't think the min-interval concern applies here that seems more relevant for amplitude types that do stream intermediate values. (Side note: the base's Finished status flag lags it's only set once the full 1200s window streams, even though our value went out earlier. Internal only, doesn't affect the message scmag sees.) I will be checking out in the morning. |
|
Small correction to my last comment. computeAmplitude does not return false after T0 resolves; it keeps getting called and keeps returning true with the same value. What actually blocks the repeat messages is the base class's own dedup in process(), it only calls emitAmplitude when the new amplitude is strictly greater than the last one, and ours is never larger on the repeat calls since it is a deterministic recompute over unchanged history. So still one message per station, just via the base's dedup, not our own gating. |
|
You can try to call To recap: setting the update flag does not change the way amplitudes are computed or if they are computed at all, it is just a matter of when they are computed, right? Given the algorithm and nature of the amplitude, I am finde with enabling updates in that particular case. |
|
One more data point on this. I also went back to the actual Early-est source we ported from (timedomain_processing.c, https://github.com/comoglu/early-est/blob/e69c669d1e7a98a3f5a7aa4b77ef40327bec3f39/timedomain_processing/timedomain_processing.c#L1541-L1691. Turns out Early-est does the opposite of what we do: it is incremental, it keeps a persistent per-station T0 state (amp_peak, amp_90/80/50/20 and their indices) in a struct, and each call only processes the newly arrived samples, updating that running state. It never rescans history. My port went the other way. No state between calls, full rescan of the whole buffer every time. That works because SeisComP hands us an append-only buffer that never gets truncated, so for a given amount of data we always see the same array no matter how it arrived. So both designs land on the same guarantee; chunking cannot change the result, just by different means. Early-est does it with careful incremental bookkeeping, and I do it by not keeping state at all and always looking at everything. Let me know if that matches your understanding of the framework or if we misread something in the SeisComP base class, you know that code better than I do. Your input is highly appreciated. |
Right, thanks heaps, updates only change when computeAmplitude gets called, not whether or what it computes. on setStatus(Finished, 100). Checked the codebase, MLc and MLh already do exactly this once their result is final. I will add it to Mwpd's computeAmplitude once the amplitude is resolved, that stops store() from calling process() again at all instead of relying on the base's dedup to absorb the repeats. Will push shortly. |
|
Correction, sorry for the back and forth. It turns out computeAmplitude already calls setStatus(Finished, 100.0) the moment it resolves; that has been there since an early commit (de4a13e). So it does not get called again after that, store() rejects every later record before process() runs at all. My last two comments describing the base's dedup as the mechanism were wrong, I mis-checked for this. Your suggestion describes exactly what the code already does, nothing to add. |
Summary
Adds
Mwpd— a per-station duration–amplitude moment magnitude for large earthquakes from teleseismic P waveforms, after Lomax & Michelini (2009) (a port of the Mwpd implementation in Early-est). UnlikeMwp, which uses the peak of the displacement integral,Mwpdintegrates the P-displacement over the apparent source durationT0, so it does not saturate for great events.The change is purely additive: a new plugin directory
plugins/magnitudes/mwpd/plus oneSUBDIRS(mwpd)line inplugins/magnitudes/CMakeLists.txt. No existing behaviour changes. The plugin registers an amplitude processor and a magnitude processor, both of typeMwpd(vertical broad-band, amplitude unitnm*s).Method
T0from the high-frequency (1–5 Hz) envelope via its 90/80/50/20 %-of-peak last-crossing times.min(T0, S−P, MAX_MWPD_DUR)— theS−Pcap (travel-time table) keeps the integral free of S/surface-wave energy.M0 = C_M · ∫u dt · Δ(Tsuboi constant),Mwpd = ⅔(log10 M0 − 9.1)minus the INGV/Early-est distance correction, plus the PREM step depth correction and the duration-gated moment-scaling ramp (T0 > 90 s) for great/slow events (eq. 5a).setUpdateEnabled): the value updates as data stream in and finalises onceT0resolves.Validation
Calibrated against GCMT moment magnitudes for 972 events (Mw 6.0–8.8, 2015–2026):
MwpdtracksMw(GCMT)along the 1:1 line with slope ≈ 1.02, mean difference +0.05, σ ≈ 0.15, no saturation and no significant depth dependence — consistent with operational Early-est and with the paper (±0.2). Per-magnitude and per-depth tables and the scatter figure are indescriptions/global_mwpd.rst.Documentation
descriptions/global_mwpd.rst— method, processing, calibration (figure + tables), configuration, references.descriptions/global_mwpd.xml— per-parameter binding descriptions for scconfig.References