Files
rustytorch/demos/rtx-seismic-demo/src/attenuation.rs
T
2026-03-04 00:08:42 +00:00

744 lines
22 KiB
Rust

//! Ground motion prediction equations and attenuation models.
//!
//! Implements various Ground Motion Prediction Equations (GMPEs) for estimating
//! ground motion intensity based on earthquake magnitude, distance, and site conditions.
use seismic_shared::{EarthquakeSource, GroundMotion, SiteClass, StationConfig, VelocityModel};
// ============================================================================
// GMPE Trait
// ============================================================================
/// Ground Motion Prediction Equation trait.
pub trait GMPE {
/// Predict ground motion at a site given earthquake source and site parameters.
fn predict(
&self,
source: &EarthquakeSource,
station: &StationConfig,
velocity_model: &VelocityModel,
) -> GroundMotion;
/// Predict PGA in g.
fn predict_pga(&self, magnitude: f64, distance_km: f64, depth_km: f64, vs30: f64) -> f64;
/// Predict PGV in cm/s.
fn predict_pgv(&self, magnitude: f64, distance_km: f64, depth_km: f64, vs30: f64) -> f64;
/// Predict spectral acceleration at period T (in g).
fn predict_sa(
&self,
magnitude: f64,
distance_km: f64,
depth_km: f64,
vs30: f64,
period: f64,
) -> f64;
/// Get standard deviation (aleatory uncertainty).
fn sigma(&self) -> f64;
/// Get model name.
fn name(&self) -> &str;
}
// ============================================================================
// Attenuation Model (Generic GMPE)
// ============================================================================
/// Generic attenuation model based on NGA-West2 style equations.
#[derive(Debug, Clone)]
pub struct AttenuationModel {
/// Model name.
name: String,
/// Magnitude scaling coefficients.
mag_coeffs: MagnitudeCoefficients,
/// Distance scaling coefficients.
dist_coeffs: DistanceCoefficients,
/// Site coefficients.
site_coeffs: SiteCoefficients,
/// Depth coefficients.
depth_coeffs: DepthCoefficients,
/// Standard deviation (sigma).
sigma: f64,
}
/// Magnitude scaling coefficients.
#[derive(Debug, Clone)]
pub struct MagnitudeCoefficients {
/// Reference magnitude.
pub m_ref: f64,
/// Linear magnitude term.
pub c1: f64,
/// Quadratic magnitude term.
pub c2: f64,
/// Magnitude hinge point.
pub m_hinge: f64,
/// Slope below hinge.
pub c3_low: f64,
/// Slope above hinge.
pub c3_high: f64,
}
impl Default for MagnitudeCoefficients {
fn default() -> Self {
Self {
m_ref: 5.0,
c1: 0.8,
c2: 0.1,
m_hinge: 6.5,
c3_low: 0.3,
c3_high: 0.15,
}
}
}
/// Distance scaling coefficients.
#[derive(Debug, Clone)]
pub struct DistanceCoefficients {
/// Geometric spreading coefficient.
pub c4: f64,
/// Anelastic attenuation coefficient.
pub c5: f64,
/// Near-source saturation distance.
pub h: f64,
/// Transition distance.
pub r_ref: f64,
}
impl Default for DistanceCoefficients {
fn default() -> Self {
Self {
c4: -1.5,
c5: -0.002,
h: 5.0,
r_ref: 1.0,
}
}
}
/// Site effect coefficients.
#[derive(Debug, Clone)]
pub struct SiteCoefficients {
/// Reference Vs30 (m/s).
pub vs30_ref: f64,
/// Linear site term.
pub c6: f64,
/// Nonlinear site term reference PGA.
pub pga_ref: f64,
/// Nonlinear site coefficient.
pub c7: f64,
/// Basin depth coefficient (Z1.0).
pub c8: f64,
/// Basin depth coefficient (Z2.5).
pub c9: f64,
}
impl Default for SiteCoefficients {
fn default() -> Self {
Self {
vs30_ref: 760.0,
c6: -0.5,
pga_ref: 0.1,
c7: -0.3,
c8: 0.1,
c9: 0.05,
}
}
}
/// Depth coefficients.
#[derive(Debug, Clone)]
pub struct DepthCoefficients {
/// Reference depth (km).
pub z_ref: f64,
/// Depth scaling.
pub c10: f64,
/// Maximum depth effect.
pub z_max: f64,
}
impl Default for DepthCoefficients {
fn default() -> Self {
Self {
z_ref: 10.0,
c10: -0.02,
z_max: 30.0,
}
}
}
impl AttenuationModel {
/// Create a new attenuation model.
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
mag_coeffs: MagnitudeCoefficients::default(),
dist_coeffs: DistanceCoefficients::default(),
site_coeffs: SiteCoefficients::default(),
depth_coeffs: DepthCoefficients::default(),
sigma: 0.7,
}
}
/// Create NGA-West2 style model.
pub fn nga_west2() -> Self {
Self::new("NGA-West2")
}
/// Create a California-specific model.
pub fn california() -> Self {
let mut model = Self::new("California");
model.dist_coeffs.c5 = -0.0025; // Higher anelastic attenuation
model.site_coeffs.c6 = -0.6; // Stronger site effects
model
}
/// Create a Japan-style model.
pub fn japan() -> Self {
let mut model = Self::new("Japan");
model.dist_coeffs.c5 = -0.003; // Higher attenuation in volcanic regions
model.depth_coeffs.c10 = -0.03; // Stronger depth effects
model
}
/// Calculate rupture distance.
fn rupture_distance(&self, source: &EarthquakeSource, station: &StationConfig) -> f64 {
let epicentral = source.hypocenter.location.distance_km(&station.location);
let depth = source.hypocenter.depth_km;
// Approximate rupture distance (Rrup)
let rup_length = source.estimated_rupture_length();
if epicentral < rup_length {
// Close to rupture
depth
} else {
// Far from rupture
((epicentral - rup_length / 2.0).powi(2) + depth.powi(2)).sqrt()
}
}
/// Calculate Joyner-Boore distance.
fn joyner_boore_distance(&self, source: &EarthquakeSource, station: &StationConfig) -> f64 {
let epicentral = source.hypocenter.location.distance_km(&station.location);
let rup_length = source.estimated_rupture_length();
// Rjb = max(0, epicentral - rupture_half_length)
(epicentral - rup_length / 2.0).max(0.0)
}
/// Calculate P-wave arrival time.
fn p_arrival_time(
&self,
distance_km: f64,
depth_km: f64,
velocity_model: &VelocityModel,
) -> f64 {
velocity_model.p_wave_travel_time(distance_km, depth_km)
}
/// Calculate S-wave arrival time.
fn s_arrival_time(
&self,
distance_km: f64,
depth_km: f64,
velocity_model: &VelocityModel,
) -> f64 {
velocity_model.s_wave_travel_time(distance_km, depth_km)
}
/// Calculate magnitude scaling term.
fn magnitude_term(&self, magnitude: f64) -> f64 {
let mc = &self.mag_coeffs;
let dm = magnitude - mc.m_ref;
if magnitude < mc.m_hinge {
mc.c1 * dm + mc.c2 * dm.powi(2) + mc.c3_low * (magnitude - mc.m_hinge).max(0.0)
} else {
mc.c1 * dm + mc.c2 * dm.powi(2) + mc.c3_high * (magnitude - mc.m_hinge)
}
}
/// Calculate distance scaling term.
fn distance_term(&self, r_rup: f64, magnitude: f64) -> f64 {
let dc = &self.dist_coeffs;
// Magnitude-dependent saturation
let h = dc.h + 0.5 * (magnitude - 5.0).max(0.0);
let r = (r_rup.powi(2) + h.powi(2)).sqrt();
// Geometric spreading + anelastic attenuation
dc.c4 * (r / dc.r_ref).ln() + dc.c5 * r
}
/// Calculate site amplification term.
fn site_term(&self, vs30: f64, pga_rock: f64) -> f64 {
let sc = &self.site_coeffs;
// Linear site response
let linear = sc.c6 * (vs30 / sc.vs30_ref).ln();
// Nonlinear site response (for soft sites at high PGA)
let nonlinear = if vs30 < sc.vs30_ref && pga_rock > sc.pga_ref {
sc.c7
* ((pga_rock + sc.pga_ref) / (2.0 * sc.pga_ref)).ln()
* ((vs30 / sc.vs30_ref).min(1.0))
} else {
0.0
};
linear + nonlinear
}
/// Calculate depth term.
fn depth_term(&self, depth_km: f64) -> f64 {
let dc = &self.depth_coeffs;
let d = depth_km.min(dc.z_max);
dc.c10 * (d - dc.z_ref)
}
/// Calculate basin depth term.
fn basin_term(&self, station: &StationConfig) -> f64 {
let sc = &self.site_coeffs;
let z1_term = station.z1_0.map_or(0.0, |z| sc.c8 * z.ln());
let z25_term = station.z2_5.map_or(0.0, |z| sc.c9 * z.ln());
z1_term + z25_term
}
/// Period-dependent coefficients for spectral acceleration.
fn period_coefficients(&self, period: f64) -> (f64, f64, f64) {
// Simplified period-dependent scaling
// (amplitude factor, magnitude term scale, distance term scale)
if period < 0.1 {
(1.0, 1.0, 1.0)
} else if period < 0.5 {
(1.0 + 0.5 * (period / 0.5).ln(), 1.1, 0.95)
} else if period < 1.0 {
(1.2 - 0.3 * (period - 0.5), 1.15, 0.9)
} else if period < 3.0 {
(0.9 - 0.2 * (period - 1.0), 1.2, 0.85)
} else {
(0.5, 1.25, 0.8)
}
}
}
impl GMPE for AttenuationModel {
fn predict(
&self,
source: &EarthquakeSource,
station: &StationConfig,
velocity_model: &VelocityModel,
) -> GroundMotion {
let r_rup = self.rupture_distance(source, station);
let _r_jb = self.joyner_boore_distance(source, station);
let epicentral = source.hypocenter.location.distance_km(&station.location);
let vs30 = station.get_vs30();
// Predict PGA and PGV
let pga = self.predict_pga(source.magnitude, r_rup, source.hypocenter.depth_km, vs30);
let pgv = self.predict_pgv(source.magnitude, r_rup, source.hypocenter.depth_km, vs30);
// Calculate arrival times
let p_arrival = self.p_arrival_time(epicentral, source.hypocenter.depth_km, velocity_model);
let s_arrival = self.s_arrival_time(epicentral, source.hypocenter.depth_km, velocity_model);
// Predict spectral accelerations
let sa_03 = self.predict_sa(
source.magnitude,
r_rup,
source.hypocenter.depth_km,
vs30,
0.3,
);
let sa_10 = self.predict_sa(
source.magnitude,
r_rup,
source.hypocenter.depth_km,
vs30,
1.0,
);
let sa_30 = self.predict_sa(
source.magnitude,
r_rup,
source.hypocenter.depth_km,
vs30,
3.0,
);
// Estimate duration
let duration_5_95 =
5.0 + 0.5 * (s_arrival - p_arrival) + 2.0 * (source.magnitude - 5.0).max(0.0);
// Estimate PGD
let pgd = pgv * 0.1 * (source.magnitude - 4.0).max(1.0);
// Calculate MMI
let mmi = f64::midpoint(GroundMotion::estimate_mmi_from_pga(pga), GroundMotion::estimate_mmi_from_pgv(pgv));
GroundMotion {
pga,
pgv,
pgd,
p_arrival_time: p_arrival,
s_arrival_time: s_arrival,
duration_5_95,
sa_03,
sa_10,
sa_30,
mmi,
}
}
fn predict_pga(&self, magnitude: f64, distance_km: f64, depth_km: f64, vs30: f64) -> f64 {
// Calculate log10(PGA) in g
let mag_term = self.magnitude_term(magnitude);
let dist_term = self.distance_term(distance_km, magnitude);
let depth_term = self.depth_term(depth_km);
// First pass: rock site PGA
let ln_pga_rock = mag_term + dist_term + depth_term;
let pga_rock = ln_pga_rock.exp();
// Add site effects
let site_term = self.site_term(vs30, pga_rock);
let ln_pga = ln_pga_rock + site_term;
// Convert from ln to actual value, with realistic scaling
let pga = (ln_pga - 4.0).exp(); // Base adjustment
// Clamp to reasonable range
pga.clamp(0.0001, 2.0)
}
fn predict_pgv(&self, magnitude: f64, distance_km: f64, depth_km: f64, vs30: f64) -> f64 {
// PGV scales differently than PGA
let pga = self.predict_pga(magnitude, distance_km, depth_km, vs30);
// Empirical PGV-PGA relationship
// PGV (cm/s) ~ 100 * PGA (g) for typical earthquakes
// with magnitude-dependent adjustment
let mag_factor = 0.8 + 0.1 * (magnitude - 5.0).max(0.0);
100.0 * pga * mag_factor
}
fn predict_sa(
&self,
magnitude: f64,
distance_km: f64,
depth_km: f64,
vs30: f64,
period: f64,
) -> f64 {
let pga = self.predict_pga(magnitude, distance_km, depth_km, vs30);
let (amp_factor, mag_scale, dist_scale) = self.period_coefficients(period);
// Adjust for period
let period_amp = if period < 0.1 {
1.0
} else if period < 1.0 {
// Short periods amplified
1.0 + 1.5 * (1.0 - (period - 0.1) / 0.9)
} else {
// Long periods attenuated
1.0 / (1.0 + period - 1.0)
};
pga * amp_factor * period_amp * mag_scale * dist_scale
}
fn sigma(&self) -> f64 {
self.sigma
}
fn name(&self) -> &str {
&self.name
}
}
// ============================================================================
// Site Effects
// ============================================================================
/// Site effects amplification model.
#[derive(Debug, Clone)]
pub struct SiteEffects {
/// Site class.
pub site_class: SiteClass,
/// Measured Vs30 (m/s).
pub vs30: f64,
/// Basin depth Z1.0 (km).
pub z1_0: Option<f64>,
/// Basin depth Z2.5 (km).
pub z2_5: Option<f64>,
/// Predominant period (seconds).
pub predominant_period: Option<f64>,
}
impl SiteEffects {
/// Create site effects from station config.
pub fn from_station(station: &StationConfig) -> Self {
Self {
site_class: station.site_class,
vs30: station.get_vs30(),
z1_0: station.z1_0,
z2_5: station.z2_5,
predominant_period: None,
}
}
/// Calculate frequency-dependent amplification.
pub fn amplification(&self, frequency: f64) -> f64 {
let base_amp = self.site_class.amplification_factor();
// Simple resonance model
let resonance = if let Some(t0) = self.predominant_period {
let f0 = 1.0 / t0;
let ratio = frequency / f0;
// Amplification peak at resonance
if ratio > 0.5 && ratio < 2.0 {
1.0 + 0.5 * (1.0 - (ratio - 1.0).abs())
} else {
1.0
}
} else {
// Estimate from Vs30
let f0 = self.vs30 / (4.0 * 30.0); // Quarter-wavelength approximation
let ratio = frequency / f0;
if ratio > 0.5 && ratio < 2.0 {
1.0 + 0.3 * (1.0 - (ratio - 1.0).abs())
} else {
1.0
}
};
base_amp * resonance
}
/// Calculate nonlinear site response for high input motion.
pub fn nonlinear_factor(&self, pga_input: f64) -> f64 {
if pga_input < 0.1 {
// Linear regime
1.0
} else if pga_input < 0.3 {
// Transition
1.0 - 0.2 * (pga_input - 0.1) / 0.2
} else {
// Nonlinear (soft sites de-amplify at high shaking)
0.8 - 0.3 * (pga_input - 0.3).min(0.5)
}
}
/// Apply site effects to ground motion.
pub fn apply(&self, input: &GroundMotion) -> GroundMotion {
let amp = self.site_class.amplification_factor();
let nonlinear = self.nonlinear_factor(input.pga);
GroundMotion {
pga: input.pga * amp * nonlinear,
pgv: input.pgv * amp * nonlinear.sqrt(), // PGV less affected by nonlinearity
pgd: input.pgd * amp,
p_arrival_time: input.p_arrival_time,
s_arrival_time: input.s_arrival_time,
duration_5_95: input.duration_5_95 * (1.0 + 0.2 * (amp - 1.0)),
sa_03: input.sa_03 * amp * self.amplification(1.0 / 0.3) * nonlinear,
sa_10: input.sa_10 * amp * self.amplification(1.0) * nonlinear.sqrt(),
sa_30: input.sa_30 * amp * self.amplification(1.0 / 3.0),
mmi: GroundMotion::estimate_mmi_from_pga(input.pga * amp * nonlinear),
}
}
}
impl Default for SiteEffects {
fn default() -> Self {
Self {
site_class: SiteClass::C,
vs30: 500.0,
z1_0: None,
z2_5: None,
predominant_period: None,
}
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_attenuation_model_creation() {
let model = AttenuationModel::nga_west2();
assert_eq!(model.name(), "NGA-West2");
let ca_model = AttenuationModel::california();
assert_eq!(ca_model.name(), "California");
let jp_model = AttenuationModel::japan();
assert_eq!(jp_model.name(), "Japan");
}
#[test]
fn test_pga_magnitude_scaling() {
let model = AttenuationModel::nga_west2();
let pga_m5 = model.predict_pga(5.0, 10.0, 10.0, 760.0);
let pga_m6 = model.predict_pga(6.0, 10.0, 10.0, 760.0);
let pga_m7 = model.predict_pga(7.0, 10.0, 10.0, 760.0);
// PGA should increase with magnitude
assert!(pga_m6 > pga_m5);
assert!(pga_m7 > pga_m6);
}
#[test]
fn test_pga_distance_scaling() {
let model = AttenuationModel::nga_west2();
let pga_10km = model.predict_pga(6.0, 10.0, 10.0, 760.0);
let pga_50km = model.predict_pga(6.0, 50.0, 10.0, 760.0);
let pga_100km = model.predict_pga(6.0, 100.0, 10.0, 760.0);
// PGA should decrease with distance
assert!(pga_50km < pga_10km);
assert!(pga_100km < pga_50km);
}
#[test]
fn test_pga_site_effects() {
let model = AttenuationModel::nga_west2();
let pga_rock = model.predict_pga(6.0, 20.0, 10.0, 760.0);
let pga_soft = model.predict_pga(6.0, 20.0, 10.0, 200.0);
// Soft site should have higher PGA (in linear regime)
// Note: This depends on the model coefficients
// Just check that they're different
assert!((pga_rock - pga_soft).abs() > 0.0001);
}
#[test]
fn test_pgv_prediction() {
let model = AttenuationModel::nga_west2();
let pgv = model.predict_pgv(6.0, 20.0, 10.0, 760.0);
// PGV should be positive and reasonable
assert!(pgv > 0.0);
assert!(pgv < 200.0); // Reasonable upper bound for M6 at 20km
}
#[test]
fn test_spectral_acceleration() {
let model = AttenuationModel::nga_west2();
let pga = model.predict_pga(6.0, 20.0, 10.0, 760.0);
let sa_03 = model.predict_sa(6.0, 20.0, 10.0, 760.0, 0.3);
let sa_10 = model.predict_sa(6.0, 20.0, 10.0, 760.0, 1.0);
let sa_30 = model.predict_sa(6.0, 20.0, 10.0, 760.0, 3.0);
// SA at 0.3s typically higher than PGA
assert!(sa_03 > pga * 0.5);
// Long period SA should be lower
assert!(sa_30 < sa_03);
}
#[test]
fn test_ground_motion_prediction() {
let model = AttenuationModel::nga_west2();
let source = seismic_shared::sample_local_earthquake();
let station = seismic_shared::StationConfig::new("TEST", 37.9, -122.3);
let velocity_model = seismic_shared::sample_california_velocity_model();
let gm = model.predict(&source, &station, &velocity_model);
// Check all values are positive and finite
assert!(gm.pga > 0.0 && gm.pga.is_finite());
assert!(gm.pgv > 0.0 && gm.pgv.is_finite());
assert!(gm.p_arrival_time > 0.0);
assert!(gm.s_arrival_time > gm.p_arrival_time);
assert!(gm.mmi >= 1.0 && gm.mmi <= 12.0);
}
#[test]
fn test_site_effects() {
let site = SiteEffects {
site_class: SiteClass::E,
vs30: 150.0,
..Default::default()
};
// Soft soil amplification
assert!(site.amplification(1.0) > 1.0);
// Nonlinear de-amplification at high PGA
let nlin_low = site.nonlinear_factor(0.05);
let nlin_high = site.nonlinear_factor(0.4);
assert!(nlin_high < nlin_low);
}
#[test]
fn test_site_effects_application() {
let site = SiteEffects {
site_class: SiteClass::D,
vs30: 270.0,
..Default::default()
};
let input = GroundMotion {
pga: 0.1,
pgv: 10.0,
pgd: 1.0,
p_arrival_time: 5.0,
s_arrival_time: 10.0,
duration_5_95: 15.0,
sa_03: 0.2,
sa_10: 0.15,
sa_30: 0.05,
mmi: 6.0,
};
let output = site.apply(&input);
// Amplification for site class D
assert!(output.pga > input.pga);
assert!(output.pgv > input.pgv);
// Arrival times unchanged
assert_eq!(output.p_arrival_time, input.p_arrival_time);
}
#[test]
fn test_sigma() {
let model = AttenuationModel::nga_west2();
let sigma = model.sigma();
// Typical GMPE sigma is 0.5-0.9
assert!(sigma > 0.4 && sigma < 1.0);
}
#[test]
fn test_magnitude_coefficients() {
let mc = MagnitudeCoefficients::default();
assert!(mc.m_ref > 0.0);
assert!(mc.m_hinge > mc.m_ref);
}
#[test]
fn test_distance_coefficients() {
let dc = DistanceCoefficients::default();
assert!(dc.c4 < 0.0); // Geometric spreading is negative
assert!(dc.c5 < 0.0); // Anelastic attenuation is negative
}
}