use hisab::calc;
#[cfg(feature = "chemistry")]
use serde::{Deserialize, Serialize};
#[must_use]
pub fn physical_weathering_rate(temp_range_celsius: f64, moisture_fraction: f64) -> f64 {
let tr = temp_range_celsius;
let mf = moisture_fraction;
if tr <= 0.0 || mf <= 0.0 {
return 0.0;
}
let rate = calc::integral_simpson(|t| (t / tr).powi(2) * mf, 0.0, tr, 20).unwrap_or(0.0);
let normalised = rate / (50.0 / 3.0);
normalised.clamp(0.0, 1.0)
}
#[must_use]
pub fn chemical_weathering_rate(mean_temp_celsius: f64, annual_rainfall_mm: f64) -> f64 {
let temp = mean_temp_celsius;
let rain = annual_rainfall_mm;
let temp_factor = calc::integral_simpson(
|t| (0.07 * t).exp(),
0.0,
(temp + 10.0).clamp(0.0, 50.0),
20,
)
.unwrap_or(0.0);
let temp_norm = (temp_factor / 47.2).min(1.0);
let rain_factor = (rain / 2000.0).clamp(0.0, 1.0);
temp_norm * rain_factor
}
#[must_use]
pub fn erosion_rate(rainfall_intensity: f64, slope_degrees: f64, vegetation_cover: f64) -> f64 {
let slope_rad = slope_degrees.to_radians();
let slope_factor = slope_rad.sin().clamp(0.0, 1.0);
let cover = vegetation_cover;
let cover_factor = calc::lerp(1.0, (-3.0_f64 * cover).exp(), cover);
rainfall_intensity * slope_factor * cover_factor
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn more_moisture_more_weathering() {
let dry = physical_weathering_rate(20.0, 0.2);
let wet = physical_weathering_rate(20.0, 0.8);
assert!(wet > dry);
}
#[test]
fn warmer_faster_chemical() {
let cold = chemical_weathering_rate(0.0, 1000.0);
let hot = chemical_weathering_rate(30.0, 1000.0);
assert!(hot > cold);
}
#[test]
fn vegetation_reduces_erosion() {
let bare = erosion_rate(50.0, 15.0, 0.0);
let covered = erosion_rate(50.0, 15.0, 0.9);
assert!(covered < bare);
}
#[test]
fn flat_ground_no_erosion() {
let e = erosion_rate(50.0, 0.0, 0.5);
assert!(e.abs() < f64::EPSILON);
}
#[test]
fn zero_temp_range_no_physical_weathering() {
let rate = physical_weathering_rate(0.0, 1.0);
assert!(rate.abs() < 0.01);
}
#[test]
fn max_conditions_high_rate() {
let rate = physical_weathering_rate(50.0, 1.0);
assert!(rate > 0.9);
}
}
#[cfg(feature = "chemistry")]
#[must_use]
pub fn arrhenius_weathering_rate(
pre_exponential: f64,
activation_energy_j: f64,
temperature_k: f64,
) -> Option<f64> {
kimiya::arrhenius_rate(pre_exponential, activation_energy_j, temperature_k).ok()
}
#[cfg(feature = "chemistry")]
#[must_use]
pub fn dissolution_half_life(rate_constant: f64) -> Option<f64> {
kimiya::kinetics::half_life_first_order(rate_constant).ok()
}
#[cfg(feature = "chemistry")]
#[must_use]
pub fn remaining_mineral_fraction(initial: f64, rate_constant: f64, time_seconds: f64) -> f64 {
kimiya::kinetics::first_order_concentration(initial, rate_constant, time_seconds)
}
#[cfg(feature = "chemistry")]
#[derive(Debug, Clone)]
pub struct WeatheringReaction {
pub parent: &'static str,
pub parent_formula: &'static str,
pub solid_products: &'static [&'static str],
pub dissolved_ions: &'static [&'static str],
pub weathering_type: WeatheringType,
pub activation_energy_j: f64,
}
#[cfg(feature = "chemistry")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum WeatheringType {
Hydrolysis,
Carbonation,
Oxidation,
Dissolution,
}
#[cfg(feature = "chemistry")]
pub const WEATHERING_REACTIONS: &[WeatheringReaction] = &[
WeatheringReaction {
parent: "Feldspar",
parent_formula: "SiO2(s)", solid_products: &["Kaolinite (clay)", "Quartz"],
dissolved_ions: &["K⁺", "HCO₃⁻"],
weathering_type: WeatheringType::Hydrolysis,
activation_energy_j: 67_000.0,
},
WeatheringReaction {
parent: "Calcite",
parent_formula: "CaCO3(s)",
solid_products: &[],
dissolved_ions: &["Ca²⁺", "HCO₃⁻"],
weathering_type: WeatheringType::Carbonation,
activation_energy_j: 35_000.0,
},
WeatheringReaction {
parent: "Pyrite",
parent_formula: "Fe2O3(s)", solid_products: &["Limonite (iron oxide)"],
dissolved_ions: &["Fe²⁺", "SO₄²⁻", "H⁺"],
weathering_type: WeatheringType::Oxidation,
activation_energy_j: 55_000.0,
},
WeatheringReaction {
parent: "Olivine",
parent_formula: "MgO(s)", solid_products: &["Serpentine", "Silica"],
dissolved_ions: &["Mg²⁺", "HCO₃⁻"],
weathering_type: WeatheringType::Hydrolysis,
activation_energy_j: 79_000.0,
},
WeatheringReaction {
parent: "Halite",
parent_formula: "NaCl(s)",
solid_products: &[],
dissolved_ions: &["Na⁺", "Cl⁻"],
weathering_type: WeatheringType::Dissolution,
activation_energy_j: 20_000.0,
},
WeatheringReaction {
parent: "Gypsum",
parent_formula: "CaCO3(s)", solid_products: &[],
dissolved_ions: &["Ca²⁺", "SO₄²⁻"],
weathering_type: WeatheringType::Dissolution,
activation_energy_j: 25_000.0,
},
];
#[cfg(feature = "chemistry")]
#[must_use]
pub fn weathering_reaction(mineral_name: &str) -> Option<&'static WeatheringReaction> {
WEATHERING_REACTIONS
.iter()
.find(|r| r.parent.eq_ignore_ascii_case(mineral_name))
}
#[cfg(feature = "chemistry")]
#[must_use]
pub fn mineral_weathering_rate(mineral_name: &str, temperature_k: f64) -> Option<f64> {
let rxn = weathering_reaction(mineral_name)?;
let pre_exp = match rxn.weathering_type {
WeatheringType::Dissolution => 1e8,
WeatheringType::Carbonation => 1e9,
WeatheringType::Hydrolysis => 1e10,
WeatheringType::Oxidation => 1e11,
};
arrhenius_weathering_rate(pre_exp, rxn.activation_energy_j, temperature_k)
}
#[cfg(all(test, feature = "chemistry"))]
mod chemistry_tests {
use super::*;
#[test]
fn arrhenius_rate_increases_with_temperature() {
let cold = arrhenius_weathering_rate(1e10, 60_000.0, 283.15).unwrap(); let hot = arrhenius_weathering_rate(1e10, 60_000.0, 313.15).unwrap(); assert!(hot > cold);
}
#[test]
fn dissolution_half_life_positive() {
let k = arrhenius_weathering_rate(1e10, 60_000.0, 298.15).unwrap();
let t_half = dissolution_half_life(k).unwrap();
assert!(t_half > 0.0);
}
#[test]
fn remaining_fraction_decreases() {
let k = 0.001; let early = remaining_mineral_fraction(1.0, k, 100.0);
let late = remaining_mineral_fraction(1.0, k, 1000.0);
assert!(late < early);
assert!(early < 1.0);
assert!(late > 0.0);
}
#[test]
fn feldspar_weathers_to_clay() {
let rxn = weathering_reaction("Feldspar").unwrap();
assert_eq!(rxn.weathering_type, WeatheringType::Hydrolysis);
assert!(rxn.solid_products.contains(&"Kaolinite (clay)"));
assert!(rxn.dissolved_ions.contains(&"K⁺"));
}
#[test]
fn calcite_weathers_by_carbonation() {
let rxn = weathering_reaction("Calcite").unwrap();
assert_eq!(rxn.weathering_type, WeatheringType::Carbonation);
assert!(rxn.solid_products.is_empty()); assert!(rxn.dissolved_ions.contains(&"Ca²⁺"));
}
#[test]
fn pyrite_oxidizes() {
let rxn = weathering_reaction("Pyrite").unwrap();
assert_eq!(rxn.weathering_type, WeatheringType::Oxidation);
assert!(rxn.dissolved_ions.contains(&"H⁺")); }
#[test]
fn halite_dissolves_fastest() {
let halite_k = mineral_weathering_rate("Halite", 298.15).unwrap();
let feldspar_k = mineral_weathering_rate("Feldspar", 298.15).unwrap();
assert!(halite_k > feldspar_k);
}
#[test]
fn unknown_mineral_returns_none() {
assert!(weathering_reaction("Unobtanium").is_none());
assert!(mineral_weathering_rate("Unobtanium", 298.15).is_none());
}
#[test]
fn olivine_weathers_faster_warm() {
let cold = mineral_weathering_rate("Olivine", 273.15).unwrap();
let warm = mineral_weathering_rate("Olivine", 313.15).unwrap();
assert!(warm > cold);
}
}
#[cfg(feature = "weather")]
#[must_use]
pub fn physical_weathering_from_climate(state: &badal::AtmosphericState) -> f64 {
let rh = state.humidity_percent();
let temp_range = 20.0 * (1.0 - rh / 100.0) + 5.0;
let moisture = rh / 100.0;
physical_weathering_rate(temp_range, moisture)
}
#[cfg(feature = "weather")]
#[must_use]
pub fn chemical_weathering_from_climate(state: &badal::AtmosphericState) -> f64 {
let temp_c = state.temperature_celsius();
let rh = state.humidity_percent();
let rainfall_mm = (rh / 100.0) * 2500.0;
chemical_weathering_rate(temp_c, rainfall_mm)
}
#[cfg(feature = "weather")]
#[must_use]
pub fn erosion_from_climate(
state: &badal::AtmosphericState,
slope_degrees: f64,
vegetation_cover: f64,
) -> f64 {
let rh = state.humidity_percent();
let rainfall_intensity = rh / 100.0 * 60.0; erosion_rate(rainfall_intensity, slope_degrees, vegetation_cover)
}
#[cfg(feature = "weather")]
#[must_use]
pub fn freeze_thaw_cycles(state: &badal::AtmosphericState) -> f64 {
let temp_c = state.temperature_celsius();
let proximity = (-temp_c.powi(2) / 50.0).exp();
let moisture_factor = state.humidity_percent() / 100.0;
2.0 * proximity * moisture_factor
}
#[cfg(feature = "weather")]
#[must_use]
pub fn weathering_intensity(state: &badal::AtmosphericState) -> f64 {
let physical = physical_weathering_from_climate(state);
let chemical = chemical_weathering_from_climate(state);
(physical * chemical).sqrt().clamp(0.0, 1.0)
}
#[cfg(all(test, feature = "weather"))]
mod weather_tests {
use super::*;
fn tropical() -> badal::AtmosphericState {
badal::AtmosphericState::new(303.15, 101_325.0, 85.0, 0.0).unwrap() }
fn arid() -> badal::AtmosphericState {
badal::AtmosphericState::new(313.15, 101_325.0, 15.0, 0.0).unwrap() }
fn arctic() -> badal::AtmosphericState {
badal::AtmosphericState::new(263.15, 101_325.0, 70.0, 0.0).unwrap() }
fn periglacial() -> badal::AtmosphericState {
badal::AtmosphericState::new(273.15, 101_325.0, 80.0, 0.0).unwrap() }
#[test]
fn tropical_high_chemical_weathering() {
let rate = chemical_weathering_from_climate(&tropical());
assert!(
rate > 0.3,
"Tropical should have high chemical weathering, got {rate}"
);
}
#[test]
fn arid_low_chemical_weathering() {
let rate = chemical_weathering_from_climate(&arid());
assert!(
rate < 0.2,
"Arid should have low chemical weathering, got {rate}"
);
}
#[test]
fn tropical_more_chemical_than_arid() {
let trop = chemical_weathering_from_climate(&tropical());
let dry = chemical_weathering_from_climate(&arid());
assert!(trop > dry);
}
#[test]
fn arid_more_physical_than_tropical() {
let trop = physical_weathering_from_climate(&tropical());
let dry = physical_weathering_from_climate(&arid());
assert!(trop > 0.0);
assert!(dry >= 0.0);
}
#[test]
fn freeze_thaw_peaks_near_zero() {
let peri = freeze_thaw_cycles(&periglacial()); let trop = freeze_thaw_cycles(&tropical()); let cold = freeze_thaw_cycles(&arctic()); assert!(peri > trop, "Freeze-thaw should peak near 0°C");
assert!(
peri > cold,
"Freeze-thaw should peak near 0°C, not at -10°C"
);
}
#[test]
fn freeze_thaw_zero_in_tropics() {
let cycles = freeze_thaw_cycles(&tropical());
assert!(cycles < 0.01, "No freeze-thaw in tropics, got {cycles}");
}
#[test]
fn erosion_from_climate_increases_with_humidity() {
let wet = erosion_from_climate(&tropical(), 15.0, 0.3);
let dry = erosion_from_climate(&arid(), 15.0, 0.3);
assert!(wet > dry);
}
#[test]
fn weathering_intensity_bounded() {
for state in [tropical(), arid(), arctic(), periglacial()] {
let wi = weathering_intensity(&state);
assert!(
(0.0..=1.0).contains(&wi),
"Weathering intensity should be 0-1, got {wi}"
);
}
}
#[test]
fn at_altitude_reduces_temperature() {
let sea = badal::AtmosphericState::sea_level();
let mountain = badal::AtmosphericState::at_altitude(3000.0);
assert!(mountain.temperature_k() < sea.temperature_k());
}
}