//! 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, /// Basin depth Z2.5 (km). pub z2_5: Option, /// Predominant period (seconds). pub predominant_period: Option, } 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 } }