use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum GrowthStage {
Seed,
Germination,
Seedling,
Vegetative,
Flowering,
Fruiting,
Senescence,
Dormant,
}
#[must_use]
pub fn growth_stage(current_height: f32, max_height: f32) -> GrowthStage {
if max_height <= 0.0 || current_height <= 0.0 {
return GrowthStage::Seed;
}
let fraction = (current_height / max_height).clamp(0.0, 1.0);
let stage = match fraction {
f if f < 0.01 => GrowthStage::Seed,
f if f < 0.05 => GrowthStage::Germination,
f if f < 0.15 => GrowthStage::Seedling,
f if f < 0.60 => GrowthStage::Vegetative,
f if f < 0.80 => GrowthStage::Flowering,
f if f < 0.95 => GrowthStage::Fruiting,
_ => GrowthStage::Senescence,
};
tracing::trace!(current_height, max_height, fraction, ?stage, "growth_stage");
stage
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GrowthModel {
pub max_height: f32, pub growth_rate: f32, pub initial_height: f32, }
impl GrowthModel {
#[must_use]
pub fn height_at_day(&self, day: f32) -> f32 {
if self.initial_height <= 0.0 {
return 0.0;
}
let k = self.max_height;
let h0 = self.initial_height;
let r = self.growth_rate;
let height = k / (1.0 + ((k - h0) / h0) * (-r * day).exp());
tracing::trace!(day, height, max_height = k, "height_at_day");
height
}
#[must_use]
pub fn daily_growth(&self, current_height: f32) -> f32 {
let k = self.max_height;
if k <= 0.0 {
return 0.0;
}
let rate = self.growth_rate * current_height * (1.0 - current_height / k);
tracing::trace!(current_height, rate, "daily_growth");
rate
}
#[must_use]
pub fn oak() -> Self {
Self {
max_height: 25.0,
growth_rate: 0.005,
initial_height: 0.1,
}
}
#[must_use]
pub fn bamboo() -> Self {
Self {
max_height: 20.0,
growth_rate: 0.05,
initial_height: 0.1,
}
}
#[must_use]
pub fn grass() -> Self {
Self {
max_height: 0.5,
growth_rate: 0.1,
initial_height: 0.01,
}
}
}
#[must_use]
#[inline]
pub fn water_stress_growth_factor(relative_water_content: f32) -> f32 {
if relative_water_content <= 0.0 {
return 0.0;
}
let factor = (relative_water_content / 0.6).clamp(0.0, 1.0);
tracing::trace!(relative_water_content, factor, "water_stress_growth_factor");
factor
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logistic_starts_at_initial() {
let m = GrowthModel::oak();
assert!((m.height_at_day(0.0) - 0.1).abs() < 0.01);
}
#[test]
fn logistic_approaches_max() {
let m = GrowthModel::grass();
let h = m.height_at_day(365.0);
assert!(
(h - m.max_height).abs() < 0.01,
"should approach max height, got {h}"
);
}
#[test]
fn bamboo_grows_faster_than_oak() {
let oak = GrowthModel::oak().height_at_day(100.0);
let bamboo = GrowthModel::bamboo().height_at_day(100.0);
assert!(bamboo > oak, "bamboo should be taller at 100 days");
}
#[test]
fn daily_growth_zero_at_max() {
let m = GrowthModel::oak();
let g = m.daily_growth(m.max_height);
assert!(g.abs() < 0.001, "growth should be ~0 at max height");
}
#[test]
fn daily_growth_positive_mid() {
let m = GrowthModel::oak();
let g = m.daily_growth(5.0);
assert!(g > 0.0);
}
#[test]
fn zero_initial_height_returns_zero() {
let m = GrowthModel {
max_height: 10.0,
growth_rate: 0.01,
initial_height: 0.0,
};
assert_eq!(m.height_at_day(100.0), 0.0);
}
#[test]
fn zero_max_height_no_daily_growth() {
let m = GrowthModel {
max_height: 0.0,
growth_rate: 0.01,
initial_height: 0.1,
};
assert_eq!(m.daily_growth(5.0), 0.0);
}
#[test]
fn grass_reaches_max_fast() {
let g = GrowthModel::grass();
let h = g.height_at_day(100.0);
assert!(
(h - g.max_height).abs() < 0.01,
"grass should reach max well before a year"
);
}
#[test]
fn growth_monotonically_increases() {
let oak = GrowthModel::oak();
let mut prev = 0.0_f32;
for day in (0..=365).step_by(10) {
let h = oak.height_at_day(day as f32);
assert!(h >= prev, "height should never decrease: day {day}");
prev = h;
}
}
#[test]
fn stage_seed_at_zero() {
assert_eq!(growth_stage(0.0, 25.0), GrowthStage::Seed);
}
#[test]
fn stage_seed_negative_height() {
assert_eq!(growth_stage(-1.0, 25.0), GrowthStage::Seed);
}
#[test]
fn stage_seed_zero_max() {
assert_eq!(growth_stage(5.0, 0.0), GrowthStage::Seed);
}
#[test]
fn stage_progression() {
let max = 100.0;
assert_eq!(growth_stage(0.5, max), GrowthStage::Seed); assert_eq!(growth_stage(2.0, max), GrowthStage::Germination); assert_eq!(growth_stage(10.0, max), GrowthStage::Seedling); assert_eq!(growth_stage(40.0, max), GrowthStage::Vegetative); assert_eq!(growth_stage(70.0, max), GrowthStage::Flowering); assert_eq!(growth_stage(90.0, max), GrowthStage::Fruiting); assert_eq!(growth_stage(98.0, max), GrowthStage::Senescence); }
#[test]
fn stage_clamped_above_max() {
assert_eq!(growth_stage(30.0, 25.0), GrowthStage::Senescence);
}
#[test]
fn growth_water_stress_full() {
assert_eq!(water_stress_growth_factor(1.0), 1.0);
}
#[test]
fn growth_water_stress_above_threshold() {
assert_eq!(water_stress_growth_factor(0.8), 1.0);
}
#[test]
fn growth_water_stress_at_threshold() {
assert!((water_stress_growth_factor(0.6) - 1.0).abs() < 0.01);
}
#[test]
fn growth_water_stress_half_threshold() {
assert!((water_stress_growth_factor(0.3) - 0.5).abs() < 0.01);
}
#[test]
fn growth_water_stress_wilted() {
assert_eq!(water_stress_growth_factor(0.0), 0.0);
}
#[test]
fn growth_water_stress_negative() {
assert_eq!(water_stress_growth_factor(-0.1), 0.0);
}
#[test]
fn growth_more_sensitive_than_photosynthesis() {
let growth_f = water_stress_growth_factor(0.5);
assert!(growth_f < 1.0, "growth should be stressed at RWC=0.5");
let photo_f = crate::photosynthesis::water_stress_factor(0.5);
assert!(
growth_f < photo_f,
"growth should be more sensitive: growth={growth_f}, photo={photo_f}"
);
}
#[test]
fn stage_integrates_with_growth_model() {
let oak = GrowthModel::oak();
let h0 = oak.height_at_day(0.0);
let h_mid = oak.height_at_day(500.0);
let h_late = oak.height_at_day(5000.0);
assert_eq!(growth_stage(h0, oak.max_height), GrowthStage::Seed);
assert_ne!(growth_stage(h_mid, oak.max_height), GrowthStage::Seed);
assert!(matches!(
growth_stage(h_late, oak.max_height),
GrowthStage::Fruiting | GrowthStage::Senescence
));
}
}