use crate::mortality::frost_mortality;
use crate::photosynthesis::temperature_factor;
use crate::season::growth_modifier_at;
const SOLAR_PAR_FRACTION: f64 = 0.45;
const PAR_CONVERSION: f64 = 4.57;
const SOLAR_TO_PAR: f64 = SOLAR_PAR_FRACTION * PAR_CONVERSION;
const ROOT_OPTIMUM_CELSIUS: f64 = 20.0;
const CANOPY_EXTINCTION_K: f64 = 0.5;
#[must_use]
#[inline]
pub fn solar_to_par(irradiance_w_m2: f64) -> f32 {
let par = irradiance_w_m2.max(0.0) * SOLAR_TO_PAR;
tracing::trace!(irradiance_w_m2, par, "solar_to_par");
par as f32
}
#[must_use]
pub fn atmosphere_to_photosynthesis_inputs(
temperature_k: f64,
solar_irradiance_w_m2: f64,
) -> (f32, f32) {
let temp_c = (temperature_k - 273.15) as f32;
let par = solar_to_par(solar_irradiance_w_m2);
tracing::trace!(
temperature_k,
temp_c,
par,
"atmosphere_to_photosynthesis_inputs"
);
(temp_c, par)
}
#[must_use]
#[inline]
pub fn rainfall_to_water_supply(rate_mm_hr: f64, duration_hours: f64) -> f32 {
let supply = rate_mm_hr.max(0.0) * duration_hours.max(0.0);
tracing::trace!(
rate_mm_hr,
duration_hours,
supply,
"rainfall_to_water_supply"
);
supply as f32
}
#[must_use]
pub fn frost_risk_to_mortality(
temperature_celsius: f64,
frost_risk: f64,
hardiness_celsius: f64,
) -> f32 {
if frost_risk <= 0.0 {
return 0.0;
}
let risk = frost_risk.clamp(0.0, 1.0) as f32;
let mort = frost_mortality(temperature_celsius as f32, hardiness_celsius as f32);
let result = risk * mort;
tracing::trace!(
temperature_celsius,
frost_risk,
hardiness_celsius,
result,
"frost_risk_to_mortality"
);
result
}
#[must_use]
#[inline]
pub fn frost_to_dormancy(
temperature_celsius: f64,
frost_risk: f64,
dormancy_threshold_celsius: f64,
) -> bool {
let dormant = frost_risk > 0.5 && temperature_celsius < dormancy_threshold_celsius;
tracing::trace!(
temperature_celsius,
frost_risk,
dormancy_threshold_celsius,
dormant,
"frost_to_dormancy"
);
dormant
}
#[must_use]
pub fn wind_to_dispersal_speed(
speed_ref_ms: f64,
z_ref_m: f64,
z_release_m: f64,
z0_m: f64,
) -> f32 {
if z0_m <= 0.0 || z_ref_m <= z0_m || z_release_m <= z0_m || speed_ref_ms <= 0.0 {
return 0.0;
}
let ln_ref = (z_ref_m / z0_m).ln();
let ln_release = (z_release_m / z0_m).ln();
let speed = speed_ref_ms * ln_release / ln_ref;
tracing::trace!(
speed_ref_ms,
z_ref_m,
z_release_m,
z0_m,
speed,
"wind_to_dispersal_speed"
);
speed.max(0.0) as f32
}
#[must_use]
pub fn growing_conditions_to_growth_multiplier(
temperature_celsius: f64,
optimum_celsius: f64,
solar_irradiance_w_m2: f64,
day_of_year: u16,
latitude_deg: f64,
) -> f32 {
let temp_f = temperature_factor(temperature_celsius as f32, optimum_celsius as f32);
let par = solar_to_par(solar_irradiance_w_m2);
let light_f = (par / 800.0).clamp(0.0, 1.0);
let season_f = growth_modifier_at(day_of_year, latitude_deg as f32);
let multiplier = temp_f * light_f * season_f;
tracing::trace!(
temperature_celsius,
optimum_celsius,
solar_irradiance_w_m2,
temp_f,
light_f,
season_f,
multiplier,
"growing_conditions_to_growth_multiplier"
);
multiplier
}
#[must_use]
#[inline]
pub fn soil_water_to_photosynthesis_stress(relative_water_content: f64) -> f32 {
crate::photosynthesis::water_stress_factor(relative_water_content as f32)
}
#[must_use]
#[inline]
pub fn soil_water_to_growth_stress(relative_water_content: f64) -> f32 {
crate::growth::water_stress_growth_factor(relative_water_content as f32)
}
#[must_use]
#[inline]
pub fn nitrogen_to_growth_stress(plant_n_concentration: f64, critical_n_concentration: f64) -> f32 {
crate::nitrogen::nitrogen_stress_factor(
plant_n_concentration as f32,
critical_n_concentration as f32,
)
}
#[must_use]
#[inline]
pub fn soil_temperature_to_root_activity(soil_temperature_k: f64) -> f32 {
let t_c = soil_temperature_k - 273.15;
let diff = t_c - ROOT_OPTIMUM_CELSIUS;
let activity = (-diff * diff / 200.0).exp();
tracing::trace!(
soil_temperature_k,
t_c,
activity,
"soil_temperature_to_root_activity"
);
activity as f32
}
#[must_use]
#[inline]
pub fn soil_temperature_to_growth_factor(soil_temperature_k: f64) -> f32 {
if soil_temperature_k < 273.15 {
tracing::trace!(
soil_temperature_k,
factor = 0.0,
"soil_temperature_to_growth_factor (frozen)"
);
return 0.0;
}
soil_temperature_to_root_activity(soil_temperature_k)
}
#[must_use]
#[inline]
pub fn evapotranspiration_cooling(et_rate_mm_hr: f64) -> f32 {
let cooling = (et_rate_mm_hr.max(0.0) * 2.5).min(15.0);
tracing::trace!(et_rate_mm_hr, cooling, "evapotranspiration_cooling");
cooling as f32
}
#[must_use]
#[inline]
pub fn wet_bulb_to_heat_stress(wet_bulb_k: f64) -> f32 {
let t_c = wet_bulb_k - 273.15;
let stress = ((t_c - 28.0) / 7.0).clamp(0.0, 1.0);
tracing::trace!(wet_bulb_k, t_c, stress, "wet_bulb_to_heat_stress");
stress as f32
}
#[must_use]
#[inline]
pub fn wind_to_boundary_conductance(wind_speed_ms: f64, leaf_width_m: f64) -> f32 {
crate::stomata::boundary_layer_conductance(wind_speed_ms as f32, leaf_width_m as f32)
}
#[must_use]
pub fn humidity_to_vpd(temperature_celsius: f64, relative_humidity_fraction: f64) -> f32 {
let es = crate::stomata::saturation_vapor_pressure(temperature_celsius as f32);
let ea = es * (relative_humidity_fraction as f32).clamp(0.0, 1.0);
let vpd = crate::stomata::vapor_pressure_deficit(es, ea);
tracing::trace!(
temperature_celsius,
relative_humidity_fraction,
es,
ea,
vpd,
"humidity_to_vpd"
);
vpd
}
#[must_use]
pub fn light_to_successional_advantage(understory_light_fraction: f64) -> f32 {
let light = understory_light_fraction as f32;
let pioneer = crate::succession::effective_growth_multiplier(
light,
crate::succession::SuccessionalStage::Pioneer,
);
let climax = crate::succession::effective_growth_multiplier(
light,
crate::succession::SuccessionalStage::Climax,
);
if pioneer <= 0.0 {
return if climax > 0.0 { f32::INFINITY } else { 1.0 };
}
let ratio = climax / pioneer;
tracing::trace!(
understory_light_fraction,
pioneer,
climax,
ratio,
"light_to_successional_advantage"
);
ratio
}
#[must_use]
pub fn fire_weather_risk(
temperature_celsius: f64,
relative_humidity: f64,
wind_speed_ms: f64,
) -> f32 {
let temp = temperature_celsius as f32;
let rh = (relative_humidity as f32).clamp(0.0, 1.0);
let wind = (wind_speed_ms as f32).max(0.0);
let temp_f = ((temp - 20.0) / 20.0).clamp(0.0, 1.0);
let dry_f = 1.0 - rh;
let wind_f = (0.2 + 0.8 * (wind / 15.0).min(1.0)).clamp(0.0, 1.0);
let risk = (temp_f * dry_f * wind_f).clamp(0.0, 1.0);
tracing::trace!(
temperature_celsius,
relative_humidity,
wind_speed_ms,
temp_f,
dry_f,
wind_f,
risk,
"fire_weather_risk"
);
risk
}
#[must_use]
#[inline]
pub fn mycorrhiza_enhanced_uptake(
base_n_uptake: f64,
colonization_fraction: f64,
is_ectomycorrhizal: bool,
) -> f32 {
let myc_type = if is_ectomycorrhizal {
crate::mycorrhiza::MycorrhizalType::Ectomycorrhizal
} else {
crate::mycorrhiza::MycorrhizalType::Arbuscular
};
crate::mycorrhiza::enhanced_n_uptake(
base_n_uptake as f32,
myc_type,
colonization_fraction as f32,
)
}
#[must_use]
#[inline]
pub fn allelopathy_growth_factor(
soil_allelochemical_concentration: f64,
species_sensitivity: f64,
) -> f32 {
let inhibition = crate::allelopathy::growth_inhibition(
soil_allelochemical_concentration as f32,
species_sensitivity as f32,
);
1.0 - inhibition
}
#[must_use]
pub fn herbivore_to_biomass_loss(
leaf_kg: f64,
stem_kg: f64,
root_kg: f64,
reproductive_kg: f64,
feeding_intensity: f64,
is_browser: bool,
) -> f32 {
let htype = if is_browser {
crate::herbivory::HerbivoryType::Browsing
} else {
crate::herbivory::HerbivoryType::Grazing
};
crate::herbivory::total_biomass_removed(
leaf_kg as f32,
stem_kg as f32,
root_kg as f32,
reproductive_kg as f32,
htype,
feeding_intensity as f32,
)
}
#[must_use]
#[inline]
pub fn canopy_to_habitat_score(leaf_area_index: f64) -> f32 {
let score = 1.0 - (-CANOPY_EXTINCTION_K * leaf_area_index.max(0.0)).exp();
tracing::trace!(leaf_area_index, score, "canopy_to_habitat_score");
score as f32
}
#[must_use]
pub fn seed_production_to_food(reproductive_biomass_kg: f64, seed_mass_g: f64) -> f32 {
if reproductive_biomass_kg <= 0.0 || seed_mass_g <= 0.0 {
return 0.0;
}
let seed_count = reproductive_biomass_kg * 1000.0 / seed_mass_g;
let score = (1.0 - (-seed_count / 1000.0).exp()).min(1.0);
tracing::trace!(
reproductive_biomass_kg,
seed_mass_g,
seed_count,
score,
"seed_production_to_food"
);
score as f32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn solar_to_par_full_sun() {
let par = solar_to_par(1000.0);
assert!((par - 2056.5).abs() < 1.0, "got {par}");
}
#[test]
fn solar_to_par_zero() {
assert_eq!(solar_to_par(0.0), 0.0);
}
#[test]
fn solar_to_par_negative_clamped() {
assert_eq!(solar_to_par(-100.0), 0.0);
}
#[test]
fn atmosphere_to_photosynthesis_summer() {
let (temp, par) = atmosphere_to_photosynthesis_inputs(298.15, 800.0);
assert!((temp - 25.0).abs() < 0.1);
assert!(par > 1600.0);
}
#[test]
fn atmosphere_to_photosynthesis_night() {
let (temp, par) = atmosphere_to_photosynthesis_inputs(283.15, 0.0);
assert!((temp - 10.0).abs() < 0.1);
assert_eq!(par, 0.0);
}
#[test]
fn rainfall_basic() {
let supply = rainfall_to_water_supply(5.0, 2.0);
assert!((supply - 10.0).abs() < 0.01);
}
#[test]
fn rainfall_zero() {
assert_eq!(rainfall_to_water_supply(0.0, 5.0), 0.0);
}
#[test]
fn rainfall_negative_clamped() {
assert_eq!(rainfall_to_water_supply(-3.0, 2.0), 0.0);
}
#[test]
fn frost_risk_warm() {
assert_eq!(frost_risk_to_mortality(10.0, 0.0, -10.0), 0.0);
}
#[test]
fn frost_risk_cold() {
let m = frost_risk_to_mortality(-15.0, 0.8, -10.0);
assert!(
m > 0.5,
"cold + high risk should produce high mortality, got {m}"
);
}
#[test]
fn frost_risk_no_risk() {
let m = frost_risk_to_mortality(-15.0, 0.0, -10.0);
assert_eq!(m, 0.0, "zero frost risk → zero mortality");
}
#[test]
fn dormancy_triggers() {
assert!(frost_to_dormancy(-5.0, 0.7, 0.0));
}
#[test]
fn dormancy_warm() {
assert!(!frost_to_dormancy(10.0, 0.1, 0.0));
}
#[test]
fn dormancy_low_risk() {
assert!(!frost_to_dormancy(-5.0, 0.3, 0.0));
}
#[test]
fn wind_same_height() {
let s = wind_to_dispersal_speed(10.0, 10.0, 10.0, 1.0);
assert!((s - 10.0).abs() < 0.01);
}
#[test]
fn wind_higher() {
let s = wind_to_dispersal_speed(10.0, 10.0, 20.0, 1.0);
assert!(s > 10.0, "higher release → faster wind");
}
#[test]
fn wind_lower() {
let s = wind_to_dispersal_speed(10.0, 10.0, 5.0, 1.0);
assert!(s < 10.0, "lower release → slower wind");
}
#[test]
fn wind_invalid_z0() {
assert_eq!(wind_to_dispersal_speed(10.0, 10.0, 20.0, 0.0), 0.0);
}
#[test]
fn wind_zero_speed() {
assert_eq!(wind_to_dispersal_speed(0.0, 10.0, 20.0, 1.0), 0.0);
}
#[test]
fn growing_conditions_summer_optimal() {
let m = growing_conditions_to_growth_multiplier(25.0, 25.0, 800.0, 172, 45.0);
assert!(
m > 0.7,
"optimal summer conditions should give high multiplier, got {m}"
);
}
#[test]
fn growing_conditions_winter() {
let m = growing_conditions_to_growth_multiplier(0.0, 25.0, 100.0, 356, 45.0);
assert!(
m < 0.1,
"winter conditions should give low multiplier, got {m}"
);
}
#[test]
fn growing_conditions_night() {
let m = growing_conditions_to_growth_multiplier(25.0, 25.0, 0.0, 172, 45.0);
assert_eq!(m, 0.0, "no light → zero growth");
}
#[test]
fn soil_temp_optimum() {
let a = soil_temperature_to_root_activity(293.15); assert!((a - 1.0).abs() < 0.01, "optimum should be ~1.0, got {a}");
}
#[test]
fn soil_temp_frozen() {
let a = soil_temperature_to_root_activity(268.15); assert!(a < 0.1, "frozen soil should have low activity, got {a}");
}
#[test]
fn soil_temp_hot() {
let a = soil_temperature_to_root_activity(318.15); assert!(a < 0.3, "hot soil should reduce activity, got {a}");
}
#[test]
fn soil_growth_frozen() {
assert_eq!(soil_temperature_to_growth_factor(270.0), 0.0);
}
#[test]
fn soil_growth_warm() {
let f = soil_temperature_to_growth_factor(293.15);
assert!(f > 0.9);
}
#[test]
fn et_cooling_typical() {
let c = evapotranspiration_cooling(3.0);
assert!((c - 7.5).abs() < 0.01);
}
#[test]
fn et_cooling_zero() {
assert_eq!(evapotranspiration_cooling(0.0), 0.0);
}
#[test]
fn et_cooling_capped() {
let c = evapotranspiration_cooling(100.0);
assert_eq!(c, 15.0);
}
#[test]
fn et_cooling_negative() {
assert_eq!(evapotranspiration_cooling(-5.0), 0.0);
}
#[test]
fn wet_bulb_no_stress() {
let s = wet_bulb_to_heat_stress(295.15); assert_eq!(s, 0.0);
}
#[test]
fn wet_bulb_severe() {
let s = wet_bulb_to_heat_stress(308.15); assert_eq!(s, 1.0);
}
#[test]
fn wet_bulb_onset() {
let s = wet_bulb_to_heat_stress(301.15); assert!(s < 0.01, "at onset, stress should be ~0, got {s}");
}
#[test]
fn wind_boundary_conductance_basic() {
let gb = wind_to_boundary_conductance(2.0, 0.05);
assert!(gb > 0.5 && gb < 1.5, "got {gb}");
}
#[test]
fn wind_boundary_conductance_zero_wind() {
assert_eq!(wind_to_boundary_conductance(0.0, 0.05), 0.0);
}
#[test]
fn humidity_to_vpd_dry() {
let vpd = humidity_to_vpd(25.0, 0.3); assert!(vpd > 1.5, "dry air should produce high VPD, got {vpd}");
}
#[test]
fn humidity_to_vpd_saturated() {
let vpd = humidity_to_vpd(25.0, 1.0); assert!(vpd < 0.01, "saturated air should have ~0 VPD, got {vpd}");
}
#[test]
fn humidity_to_vpd_increases_with_dryness() {
let humid = humidity_to_vpd(25.0, 0.8);
let dry = humidity_to_vpd(25.0, 0.3);
assert!(dry > humid);
}
#[test]
fn canopy_zero_lai() {
let s = canopy_to_habitat_score(0.0);
assert!(s.abs() < 0.01);
}
#[test]
fn canopy_high_lai() {
let s = canopy_to_habitat_score(6.0);
assert!(s > 0.9, "dense canopy should give high cover, got {s}");
}
#[test]
fn canopy_moderate_lai() {
let s = canopy_to_habitat_score(3.0);
assert!(s > 0.7 && s < 0.85, "LAI=3 should give ~0.78, got {s}");
}
#[test]
fn canopy_negative_clamped() {
assert_eq!(canopy_to_habitat_score(-1.0), 0.0);
}
#[test]
fn seed_production_none() {
assert_eq!(seed_production_to_food(0.0, 1.0), 0.0);
}
#[test]
fn seed_production_moderate() {
let s = seed_production_to_food(1.0, 1.0); assert!(s > 0.5 && s < 0.7, "1000 seeds should give ~0.63, got {s}");
}
#[test]
fn seed_production_heavy() {
let s = seed_production_to_food(10.0, 0.1); assert!(s > 0.99, "many seeds should saturate, got {s}");
}
#[test]
fn seed_production_zero_mass() {
assert_eq!(seed_production_to_food(1.0, 0.0), 0.0);
}
#[test]
fn seed_production_negative() {
assert_eq!(seed_production_to_food(-1.0, 1.0), 0.0);
}
#[test]
fn soil_water_photosynthesis_stress_wet() {
assert_eq!(soil_water_to_photosynthesis_stress(1.0), 1.0);
}
#[test]
fn soil_water_photosynthesis_stress_dry() {
let f = soil_water_to_photosynthesis_stress(0.2);
assert!((f - 0.5).abs() < 0.01, "got {f}");
}
#[test]
fn soil_water_photosynthesis_stress_wilted() {
assert_eq!(soil_water_to_photosynthesis_stress(0.0), 0.0);
}
#[test]
fn soil_water_growth_stress_wet() {
assert_eq!(soil_water_to_growth_stress(1.0), 1.0);
}
#[test]
fn soil_water_growth_stress_moderate() {
let f = soil_water_to_growth_stress(0.3);
assert!((f - 0.5).abs() < 0.01, "got {f}");
}
#[test]
fn nitrogen_growth_stress_sufficient() {
assert_eq!(nitrogen_to_growth_stress(0.015, 0.012), 1.0);
}
#[test]
fn nitrogen_growth_stress_deficient() {
let f = nitrogen_to_growth_stress(0.006, 0.012);
assert!((f - 0.5).abs() < 0.01, "got {f}");
}
#[test]
fn nitrogen_growth_stress_zero() {
assert_eq!(nitrogen_to_growth_stress(0.0, 0.012), 0.0);
}
#[test]
fn growth_stress_more_sensitive_than_photosynthesis() {
let rwc = 0.5;
let growth = soil_water_to_growth_stress(rwc);
let photo = soil_water_to_photosynthesis_stress(rwc);
assert!(growth < photo, "growth={growth}, photo={photo}");
}
#[test]
fn fire_weather_hot_dry_windy() {
let risk = fire_weather_risk(40.0, 0.1, 15.0);
assert!(risk > 0.5, "extreme fire weather, got {risk}");
}
#[test]
fn fire_weather_cool_humid() {
let risk = fire_weather_risk(15.0, 0.9, 2.0);
assert_eq!(risk, 0.0, "cool humid = no fire risk");
}
#[test]
fn fire_weather_moderate() {
let risk = fire_weather_risk(30.0, 0.3, 10.0);
assert!(
(0.1..0.7).contains(&risk),
"moderate conditions, got {risk}"
);
}
#[test]
fn mycorrhiza_enhances_uptake() {
let base = 0.001;
let enhanced = mycorrhiza_enhanced_uptake(base, 0.7, true);
assert!(enhanced > base as f32);
}
#[test]
fn mycorrhiza_zero_colonization() {
let base = 0.001;
let enhanced = mycorrhiza_enhanced_uptake(base, 0.0, true);
assert!((enhanced - base as f32).abs() < 0.0001);
}
#[test]
fn allelopathy_no_toxin() {
assert_eq!(allelopathy_growth_factor(0.0, 5.0), 1.0);
}
#[test]
fn allelopathy_high_toxin_sensitive() {
let f = allelopathy_growth_factor(1.0, 10.0);
assert!(f < 0.1, "high toxin should strongly suppress, got {f}");
}
#[test]
fn allelopathy_tolerant_less_affected() {
let tolerant = allelopathy_growth_factor(0.5, 1.0);
let sensitive = allelopathy_growth_factor(0.5, 10.0);
assert!(tolerant > sensitive);
}
#[test]
fn successional_advantage_full_sun_favors_pioneer() {
let ratio = light_to_successional_advantage(1.0);
assert!(ratio < 1.0, "full sun should favor pioneer, got {ratio}");
}
#[test]
fn successional_advantage_shade_favors_climax() {
let ratio = light_to_successional_advantage(0.15);
assert!(ratio > 1.0, "shade should favor climax, got {ratio}");
}
#[test]
fn herbivore_grazing_removes_biomass() {
let loss = herbivore_to_biomass_loss(50.0, 100.0, 30.0, 10.0, 0.5, false);
assert!(loss > 0.0);
}
#[test]
fn herbivore_browsing_removes_more_stem() {
let graze = herbivore_to_biomass_loss(50.0, 100.0, 30.0, 10.0, 0.5, false);
let browse = herbivore_to_biomass_loss(50.0, 100.0, 30.0, 10.0, 0.5, true);
assert!(graze > 0.0);
assert!(browse > 0.0);
}
#[test]
fn herbivore_zero_intensity() {
let loss = herbivore_to_biomass_loss(50.0, 100.0, 30.0, 10.0, 0.0, false);
assert_eq!(loss, 0.0);
}
}