use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum LeafHabit {
Deciduous,
Evergreen,
DroughtDeciduous,
}
#[must_use]
#[inline]
pub fn lai_from_biomass(leaf_biomass_kg: f32, sla_m2_per_kg: f32, ground_area_m2: f32) -> f32 {
if leaf_biomass_kg <= 0.0 || sla_m2_per_kg <= 0.0 || ground_area_m2 <= 0.0 {
return 0.0;
}
let lai = leaf_biomass_kg * sla_m2_per_kg / ground_area_m2;
tracing::trace!(
leaf_biomass_kg,
sla_m2_per_kg,
ground_area_m2,
lai,
"lai_from_biomass"
);
lai
}
#[must_use]
pub fn seasonal_lai_multiplier(habit: LeafHabit, day_of_year: u16, latitude_deg: f32) -> f32 {
let day = day_of_year.clamp(1, 365) as f32;
let mult = match habit {
LeafHabit::Deciduous => {
let (leaf_on, leaf_off) = if latitude_deg >= 0.0 {
(100.0, 300.0) } else {
(280.0, 115.0) };
if latitude_deg >= 0.0 {
if day < leaf_on || day > leaf_off {
0.0
} else {
let progress = (day - leaf_on) / (leaf_off - leaf_on);
(std::f32::consts::PI * progress).sin().max(0.0)
}
} else {
let adjusted = if day >= leaf_on {
day - leaf_on
} else if day <= leaf_off {
day + 365.0 - leaf_on
} else {
return 0.0;
};
let season_length = 365.0 - leaf_on + leaf_off;
let progress = adjusted / season_length;
(std::f32::consts::PI * progress).sin().max(0.0)
}
}
LeafHabit::Evergreen => {
let seasonal = (2.0 * std::f32::consts::PI * (day - 172.0) / 365.0).cos();
let adjusted = if latitude_deg < 0.0 {
-seasonal
} else {
seasonal
};
0.85 + 0.15 * adjusted }
LeafHabit::DroughtDeciduous => {
1.0
}
};
tracing::trace!(
?habit,
day_of_year,
latitude_deg,
mult,
"seasonal_lai_multiplier"
);
mult
}
#[must_use]
#[inline]
pub fn drought_leaf_retention(relative_water_content: f32, habit: LeafHabit) -> f32 {
let rwc = relative_water_content.clamp(0.0, 1.0);
let exponent = match habit {
LeafHabit::DroughtDeciduous => 2.0,
LeafHabit::Deciduous => 1.0,
LeafHabit::Evergreen => 0.5,
};
let retention = rwc.powf(exponent);
tracing::trace!(
relative_water_content,
?habit,
exponent,
retention,
"drought_leaf_retention"
);
retention
}
#[must_use]
#[inline]
pub fn frost_leaf_loss(temp_celsius: f32, frost_threshold_celsius: f32) -> f32 {
if temp_celsius >= frost_threshold_celsius {
return 0.0;
}
let damage = ((frost_threshold_celsius - temp_celsius) / 10.0).min(1.0);
let killed = (damage * damage).clamp(0.0, 1.0);
tracing::trace!(
temp_celsius,
frost_threshold_celsius,
killed,
"frost_leaf_loss"
);
killed
}
#[must_use]
#[inline]
pub fn max_lai(is_conifer: bool, is_tropical: bool) -> f32 {
match (is_conifer, is_tropical) {
(true, _) => 10.0,
(false, true) => 8.0,
(false, false) => 7.0,
}
}
#[must_use]
#[inline]
pub fn effective_lai(
lai_biomass: f32,
lai_max: f32,
seasonal: f32,
drought_retention: f32,
frost_loss_fraction: f32,
) -> f32 {
let effective = lai_biomass
* seasonal.clamp(0.0, 1.0)
* drought_retention.clamp(0.0, 1.0)
* (1.0 - frost_loss_fraction.clamp(0.0, 1.0));
let clamped = effective.clamp(0.0, lai_max.max(0.0));
tracing::trace!(
lai_biomass,
lai_max,
seasonal,
drought_retention,
frost_loss_fraction,
effective = clamped,
"effective_lai"
);
clamped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lai_from_biomass_basic() {
let lai = lai_from_biomass(50.0, 25.0, 100.0);
assert!((lai - 12.5).abs() < 0.01, "got {lai}");
}
#[test]
fn lai_from_biomass_zero_leaves() {
assert_eq!(lai_from_biomass(0.0, 25.0, 100.0), 0.0);
}
#[test]
fn lai_from_biomass_zero_area() {
assert_eq!(lai_from_biomass(50.0, 25.0, 0.0), 0.0);
}
#[test]
fn deciduous_peak_in_summer() {
let summer = seasonal_lai_multiplier(LeafHabit::Deciduous, 200, 45.0);
assert!(summer > 0.9, "mid-summer should be near 1.0, got {summer}");
}
#[test]
fn deciduous_zero_in_winter() {
let winter = seasonal_lai_multiplier(LeafHabit::Deciduous, 15, 45.0);
assert_eq!(winter, 0.0, "January should be leafless");
}
#[test]
fn evergreen_always_above_threshold() {
for day in (1..=365).step_by(30) {
let m = seasonal_lai_multiplier(LeafHabit::Evergreen, day, 45.0);
assert!(m >= 0.69, "day {day}: {m}");
}
}
#[test]
fn drought_deciduous_always_one() {
let m = seasonal_lai_multiplier(LeafHabit::DroughtDeciduous, 200, 45.0);
assert_eq!(m, 1.0);
}
#[test]
fn southern_hemisphere_shifted() {
let sh_jan = seasonal_lai_multiplier(LeafHabit::Deciduous, 15, -35.0);
let nh_jan = seasonal_lai_multiplier(LeafHabit::Deciduous, 15, 45.0);
assert!(sh_jan > nh_jan, "SH January should be leafy: sh={sh_jan}");
}
#[test]
fn drought_retention_full_water() {
for habit in [
LeafHabit::Deciduous,
LeafHabit::Evergreen,
LeafHabit::DroughtDeciduous,
] {
assert_eq!(drought_leaf_retention(1.0, habit), 1.0, "{habit:?}");
}
}
#[test]
fn drought_deciduous_sheds_fastest() {
let rwc = 0.5;
let dd = drought_leaf_retention(rwc, LeafHabit::DroughtDeciduous);
let ev = drought_leaf_retention(rwc, LeafHabit::Evergreen);
assert!(
dd < ev,
"drought-deciduous should shed more: dd={dd}, ev={ev}"
);
}
#[test]
fn drought_retention_zero_water() {
assert_eq!(drought_leaf_retention(0.0, LeafHabit::Deciduous), 0.0);
}
#[test]
fn frost_no_damage_above_threshold() {
assert_eq!(frost_leaf_loss(5.0, -2.0), 0.0);
}
#[test]
fn frost_damage_below_threshold() {
let killed = frost_leaf_loss(-5.0, -2.0);
assert!(killed > 0.0, "below threshold should cause damage");
assert!(killed < 0.5, "moderate frost shouldn't kill all leaves");
}
#[test]
fn frost_severe_high_loss() {
let killed = frost_leaf_loss(-15.0, -2.0);
assert!(
killed > 0.8,
"severe frost should kill most leaves, got {killed}"
);
}
#[test]
fn conifer_highest_max_lai() {
assert!(max_lai(true, false) > max_lai(false, false));
}
#[test]
fn effective_lai_full_conditions() {
let eff = effective_lai(6.0, 8.0, 1.0, 1.0, 0.0);
assert!((eff - 6.0).abs() < 0.01);
}
#[test]
fn effective_lai_capped_at_max() {
let eff = effective_lai(15.0, 8.0, 1.0, 1.0, 0.0);
assert!((eff - 8.0).abs() < 0.01);
}
#[test]
fn effective_lai_winter_deciduous() {
let eff = effective_lai(6.0, 8.0, 0.0, 1.0, 0.0);
assert_eq!(eff, 0.0, "winter deciduous = no LAI");
}
#[test]
fn effective_lai_drought_reduces() {
let wet = effective_lai(6.0, 8.0, 1.0, 1.0, 0.0);
let dry = effective_lai(6.0, 8.0, 1.0, 0.5, 0.0);
assert!(dry < wet);
}
#[test]
fn effective_lai_frost_reduces() {
let safe = effective_lai(6.0, 8.0, 1.0, 1.0, 0.0);
let frost = effective_lai(6.0, 8.0, 1.0, 1.0, 0.3);
assert!(frost < safe);
}
}