use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SoilTexture {
Sand,
LoamySand,
SandyLoam,
Loam,
SiltLoam,
Silt,
SandyClayLoam,
ClayLoam,
SiltyClayLoam,
SandyClay,
SiltyClay,
Clay,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct SoilComposition {
pub sand: f64, pub silt: f64, pub clay: f64, }
impl SoilComposition {
#[must_use]
pub fn new(sand: f64, silt: f64, clay: f64) -> Option<Self> {
let sum = sand + silt + clay;
if (sum - 1.0).abs() > 0.01 || sand < 0.0 || silt < 0.0 || clay < 0.0 {
return None;
}
Some(Self { sand, silt, clay })
}
#[must_use]
pub fn texture(&self) -> SoilTexture {
let sand = self.sand;
let silt = self.silt;
let clay = self.clay;
if clay >= 0.40 {
if sand >= 0.45 {
SoilTexture::SandyClay
} else if silt >= 0.40 {
SoilTexture::SiltyClay
} else {
SoilTexture::Clay
}
}
else if (0.20..0.35).contains(&clay) && sand >= 0.45 {
SoilTexture::SandyClayLoam
}
else if (0.27..0.40).contains(&clay) {
if silt >= 0.40 {
SoilTexture::SiltyClayLoam
} else {
SoilTexture::ClayLoam
}
}
else if clay >= 0.35 && sand >= 0.45 {
SoilTexture::SandyClayLoam
}
else if sand >= 0.85 && clay < 0.10 {
SoilTexture::Sand
}
else if (0.70..0.90).contains(&sand) && clay < 0.15 {
SoilTexture::LoamySand
}
else if sand >= 0.43 && clay < 0.20 && silt < 0.50 {
SoilTexture::SandyLoam
}
else if silt >= 0.80 && clay < 0.12 {
SoilTexture::Silt
}
else if silt >= 0.50 && clay < 0.27 {
SoilTexture::SiltLoam
}
else {
SoilTexture::Loam
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SoilOrder {
Alfisol,
Andisol,
Aridisol,
Entisol,
Gelisol,
Histosol,
Inceptisol,
Mollisol,
Oxisol,
Spodosol,
Ultisol,
Vertisol,
}
impl SoilOrder {
pub const ALL: &'static [SoilOrder] = &[
Self::Alfisol,
Self::Andisol,
Self::Aridisol,
Self::Entisol,
Self::Gelisol,
Self::Histosol,
Self::Inceptisol,
Self::Mollisol,
Self::Oxisol,
Self::Spodosol,
Self::Ultisol,
Self::Vertisol,
];
#[must_use]
pub fn typical_environment(&self) -> &'static str {
match self {
Self::Alfisol => "temperate deciduous forest",
Self::Andisol => "volcanic regions",
Self::Aridisol => "arid/semi-arid deserts",
Self::Entisol => "recent deposits (floodplains, dunes)",
Self::Gelisol => "permafrost regions (tundra)",
Self::Histosol => "wetlands (bogs, marshes)",
Self::Inceptisol => "young landscapes (mountains, river terraces)",
Self::Mollisol => "grasslands/prairies",
Self::Oxisol => "tropical rainforest",
Self::Spodosol => "coniferous forest (boreal/cool humid)",
Self::Ultisol => "subtropical humid forest",
Self::Vertisol => "seasonal wet-dry climates",
}
}
#[must_use]
pub fn fertility(&self) -> SoilFertility {
match self {
Self::Mollisol | Self::Alfisol | Self::Andisol => SoilFertility::High,
Self::Vertisol | Self::Inceptisol | Self::Entisol => SoilFertility::Moderate,
Self::Histosol | Self::Aridisol | Self::Gelisol => SoilFertility::Low,
Self::Ultisol | Self::Spodosol | Self::Oxisol => SoilFertility::VeryLow,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum SoilFertility {
VeryLow,
Low,
Moderate,
High,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum HorizonType {
O,
A,
E,
B,
C,
R,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SoilHorizon {
pub horizon_type: HorizonType,
pub depth_top_cm: f64,
pub depth_bottom_cm: f64,
pub organic_matter: f64,
pub ph: f64,
pub texture: SoilTexture,
pub color: String,
}
impl SoilHorizon {
#[must_use]
pub fn thickness_cm(&self) -> f64 {
self.depth_bottom_cm - self.depth_top_cm
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SoilProfile {
pub horizons: Vec<SoilHorizon>,
pub location: String,
}
impl SoilProfile {
#[must_use]
pub fn total_depth_cm(&self) -> f64 {
self.horizons
.iter()
.map(|h| h.depth_bottom_cm)
.fold(0.0_f64, f64::max)
}
#[must_use]
pub fn has_horizon(&self, ht: HorizonType) -> bool {
self.horizons.iter().any(|h| h.horizon_type == ht)
}
#[must_use]
pub fn a_horizon(&self) -> Option<&SoilHorizon> {
self.horizons
.iter()
.find(|h| h.horizon_type == HorizonType::A)
}
#[must_use]
pub fn b_horizon(&self) -> Option<&SoilHorizon> {
self.horizons
.iter()
.find(|h| h.horizon_type == HorizonType::B)
}
#[must_use]
pub fn topsoil_organic_matter(&self) -> Option<f64> {
self.a_horizon().map(|h| h.organic_matter)
}
#[must_use]
pub fn classify_order(&self) -> SoilOrder {
let a = self.a_horizon();
let b = self.b_horizon();
if a.is_some_and(|h| h.organic_matter > 0.20) {
return SoilOrder::Histosol;
}
if a.is_some_and(|h| h.texture == SoilTexture::Clay)
&& b.is_some_and(|h| h.texture == SoilTexture::Clay)
{
return SoilOrder::Vertisol;
}
if a.is_some_and(|h| h.organic_matter > 0.03 && h.thickness_cm() > 25.0 && h.ph > 5.5) {
return SoilOrder::Mollisol;
}
if self.has_horizon(HorizonType::E) && a.is_some_and(|h| h.ph < 5.0) {
return SoilOrder::Spodosol;
}
if b.is_some_and(|h| h.thickness_cm() > 100.0 && h.ph < 5.5) {
return SoilOrder::Oxisol;
}
if a.is_some_and(|h| h.organic_matter < 0.01 && h.ph > 7.5) {
return SoilOrder::Aridisol;
}
if b.is_some_and(|h| {
matches!(
h.texture,
SoilTexture::Clay | SoilTexture::SandyClay | SoilTexture::SiltyClay
) && h.ph < 5.5
}) {
return SoilOrder::Ultisol;
}
if b.is_some_and(|h| {
matches!(
h.texture,
SoilTexture::Clay
| SoilTexture::ClayLoam
| SoilTexture::SandyClayLoam
| SoilTexture::SiltyClayLoam
) && h.ph >= 5.5
}) {
return SoilOrder::Alfisol;
}
if !self.has_horizon(HorizonType::B) {
return SoilOrder::Entisol;
}
SoilOrder::Inceptisol
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SoilPhClass {
UltraAcid,
ExtremelyAcid,
VeryStronglyAcid,
StronglyAcid,
ModeratelyAcid,
SlightlyAcid,
Neutral,
SlightlyAlkaline,
ModeratelyAlkaline,
StronglyAlkaline,
VeryStronglyAlkaline,
}
#[must_use]
pub fn classify_ph(ph: f64) -> SoilPhClass {
if ph < 3.5 {
SoilPhClass::UltraAcid
} else if ph < 4.5 {
SoilPhClass::ExtremelyAcid
} else if ph < 5.0 {
SoilPhClass::VeryStronglyAcid
} else if ph < 5.5 {
SoilPhClass::StronglyAcid
} else if ph < 6.0 {
SoilPhClass::ModeratelyAcid
} else if ph < 6.5 {
SoilPhClass::SlightlyAcid
} else if ph < 7.3 {
SoilPhClass::Neutral
} else if ph < 7.8 {
SoilPhClass::SlightlyAlkaline
} else if ph < 8.4 {
SoilPhClass::ModeratelyAlkaline
} else if ph < 9.0 {
SoilPhClass::StronglyAlkaline
} else {
SoilPhClass::VeryStronglyAlkaline
}
}
#[must_use]
pub fn cation_exchange_capacity(clay_fraction: f64, organic_matter_fraction: f64) -> f64 {
0.5 * (clay_fraction * 100.0) + 2.0 * (organic_matter_fraction * 100.0)
}
#[must_use]
pub fn available_water_capacity(texture: SoilTexture) -> f64 {
match texture {
SoilTexture::Sand => 60.0,
SoilTexture::LoamySand => 80.0,
SoilTexture::SandyLoam => 120.0,
SoilTexture::Loam => 170.0,
SoilTexture::SiltLoam => 200.0,
SoilTexture::Silt => 180.0,
SoilTexture::SandyClayLoam => 140.0,
SoilTexture::ClayLoam => 170.0,
SoilTexture::SiltyClayLoam => 190.0,
SoilTexture::SandyClay => 130.0,
SoilTexture::SiltyClay => 160.0,
SoilTexture::Clay => 150.0,
}
}
#[must_use]
pub fn hydraulic_conductivity_mm_hr(texture: SoilTexture) -> f64 {
match texture {
SoilTexture::Sand => 210.0,
SoilTexture::LoamySand => 61.0,
SoilTexture::SandyLoam => 26.0,
SoilTexture::Loam => 13.0,
SoilTexture::SiltLoam => 7.0,
SoilTexture::Silt => 7.0,
SoilTexture::SandyClayLoam => 4.0,
SoilTexture::ClayLoam => 2.0,
SoilTexture::SiltyClayLoam => 1.5,
SoilTexture::SandyClay => 1.2,
SoilTexture::SiltyClay => 0.9,
SoilTexture::Clay => 0.6,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_composition() {
assert!(SoilComposition::new(0.4, 0.4, 0.2).is_some());
}
#[test]
fn invalid_composition_sum() {
assert!(SoilComposition::new(0.5, 0.5, 0.5).is_none());
}
#[test]
fn negative_fraction_rejected() {
assert!(SoilComposition::new(-0.1, 0.6, 0.5).is_none());
}
#[test]
fn texture_sand() {
let s = SoilComposition::new(0.90, 0.05, 0.05).unwrap();
assert_eq!(s.texture(), SoilTexture::Sand);
}
#[test]
fn texture_loamy_sand() {
let s = SoilComposition::new(0.80, 0.10, 0.10).unwrap();
assert_eq!(s.texture(), SoilTexture::LoamySand);
}
#[test]
fn texture_sandy_loam() {
let s = SoilComposition::new(0.60, 0.25, 0.15).unwrap();
assert_eq!(s.texture(), SoilTexture::SandyLoam);
}
#[test]
fn texture_loam() {
let s = SoilComposition::new(0.40, 0.40, 0.20).unwrap();
assert_eq!(s.texture(), SoilTexture::Loam);
}
#[test]
fn texture_silt_loam() {
let s = SoilComposition::new(0.20, 0.65, 0.15).unwrap();
assert_eq!(s.texture(), SoilTexture::SiltLoam);
}
#[test]
fn texture_silt() {
let s = SoilComposition::new(0.05, 0.88, 0.07).unwrap();
assert_eq!(s.texture(), SoilTexture::Silt);
}
#[test]
fn texture_sandy_clay_loam() {
let s = SoilComposition::new(0.55, 0.15, 0.30).unwrap();
assert_eq!(s.texture(), SoilTexture::SandyClayLoam);
}
#[test]
fn texture_clay_loam() {
let s = SoilComposition::new(0.30, 0.35, 0.35).unwrap();
assert_eq!(s.texture(), SoilTexture::ClayLoam);
}
#[test]
fn texture_silty_clay_loam() {
let s = SoilComposition::new(0.10, 0.55, 0.35).unwrap();
assert_eq!(s.texture(), SoilTexture::SiltyClayLoam);
}
#[test]
fn texture_sandy_clay() {
let s = SoilComposition::new(0.50, 0.05, 0.45).unwrap();
assert_eq!(s.texture(), SoilTexture::SandyClay);
}
#[test]
fn texture_silty_clay() {
let s = SoilComposition::new(0.05, 0.50, 0.45).unwrap();
assert_eq!(s.texture(), SoilTexture::SiltyClay);
}
#[test]
fn texture_clay() {
let s = SoilComposition::new(0.20, 0.30, 0.50).unwrap();
assert_eq!(s.texture(), SoilTexture::Clay);
}
#[test]
fn twelve_soil_orders() {
assert_eq!(SoilOrder::ALL.len(), 12);
}
#[test]
fn mollisol_high_fertility() {
assert_eq!(SoilOrder::Mollisol.fertility(), SoilFertility::High);
}
#[test]
fn oxisol_very_low_fertility() {
assert_eq!(SoilOrder::Oxisol.fertility(), SoilFertility::VeryLow);
}
#[test]
fn fertility_ordering() {
assert!(SoilFertility::High > SoilFertility::VeryLow);
assert!(SoilFertility::Moderate > SoilFertility::Low);
}
fn make_mollisol_profile() -> SoilProfile {
SoilProfile {
horizons: vec![
SoilHorizon {
horizon_type: HorizonType::A,
depth_top_cm: 0.0,
depth_bottom_cm: 40.0,
organic_matter: 0.05,
ph: 6.5,
texture: SoilTexture::Loam,
color: "10YR 2/1".into(),
},
SoilHorizon {
horizon_type: HorizonType::B,
depth_top_cm: 40.0,
depth_bottom_cm: 100.0,
organic_matter: 0.01,
ph: 6.8,
texture: SoilTexture::ClayLoam,
color: "10YR 4/3".into(),
},
SoilHorizon {
horizon_type: HorizonType::C,
depth_top_cm: 100.0,
depth_bottom_cm: 150.0,
organic_matter: 0.002,
ph: 7.0,
texture: SoilTexture::SiltLoam,
color: "10YR 5/4".into(),
},
],
location: "Kansas prairie".into(),
}
}
#[test]
fn classify_mollisol() {
let profile = make_mollisol_profile();
assert_eq!(profile.classify_order(), SoilOrder::Mollisol);
}
#[test]
fn classify_entisol_no_b_horizon() {
let profile = SoilProfile {
horizons: vec![SoilHorizon {
horizon_type: HorizonType::A,
depth_top_cm: 0.0,
depth_bottom_cm: 20.0,
organic_matter: 0.02,
ph: 6.0,
texture: SoilTexture::Sand,
color: "10YR 5/3".into(),
}],
location: "River floodplain".into(),
};
assert_eq!(profile.classify_order(), SoilOrder::Entisol);
}
#[test]
fn classify_histosol_high_om() {
let profile = SoilProfile {
horizons: vec![
SoilHorizon {
horizon_type: HorizonType::O,
depth_top_cm: 0.0,
depth_bottom_cm: 10.0,
organic_matter: 0.50,
ph: 4.5,
texture: SoilTexture::Loam,
color: "5YR 2/1".into(),
},
SoilHorizon {
horizon_type: HorizonType::A,
depth_top_cm: 10.0,
depth_bottom_cm: 50.0,
organic_matter: 0.30,
ph: 4.5,
texture: SoilTexture::SiltLoam,
color: "10YR 2/1".into(),
},
],
location: "Bog".into(),
};
assert_eq!(profile.classify_order(), SoilOrder::Histosol);
}
#[test]
fn profile_total_depth() {
let profile = make_mollisol_profile();
assert!((profile.total_depth_cm() - 150.0).abs() < 0.01);
}
#[test]
fn horizon_thickness() {
let h = SoilHorizon {
horizon_type: HorizonType::A,
depth_top_cm: 0.0,
depth_bottom_cm: 30.0,
organic_matter: 0.04,
ph: 6.0,
texture: SoilTexture::Loam,
color: "dark".into(),
};
assert!((h.thickness_cm() - 30.0).abs() < 0.01);
}
#[test]
fn ph_classification() {
assert_eq!(classify_ph(4.0), SoilPhClass::ExtremelyAcid);
assert_eq!(classify_ph(5.2), SoilPhClass::StronglyAcid);
assert_eq!(classify_ph(6.8), SoilPhClass::Neutral);
assert_eq!(classify_ph(7.5), SoilPhClass::SlightlyAlkaline);
assert_eq!(classify_ph(8.5), SoilPhClass::StronglyAlkaline);
}
#[test]
fn cec_increases_with_clay_and_om() {
let low = cation_exchange_capacity(0.10, 0.01);
let high = cation_exchange_capacity(0.50, 0.05);
assert!(high > low);
}
#[test]
fn awc_sand_lowest() {
let sand_awc = available_water_capacity(SoilTexture::Sand);
let loam_awc = available_water_capacity(SoilTexture::Loam);
assert!(sand_awc < loam_awc);
}
#[test]
fn ksat_sand_highest() {
let sand_k = hydraulic_conductivity_mm_hr(SoilTexture::Sand);
let clay_k = hydraulic_conductivity_mm_hr(SoilTexture::Clay);
assert!(sand_k > clay_k);
}
#[test]
fn topsoil_organic_matter_from_profile() {
let profile = make_mollisol_profile();
let om = profile.topsoil_organic_matter().unwrap();
assert!((om - 0.05).abs() < 0.001);
}
}