malcolmSQ commited on
Commit
0cefa06
·
1 Parent(s): 0dba6f7

Refactor declining_increment_growth to accumulate increments, fix negative height/DBH; enable robust multi-model support

Browse files
README.md CHANGED
@@ -1,124 +1,92 @@
1
- # ER Model - Nigeria Mangroves
2
-
3
- A Python implementation of the Emissions Reduction (ER) model for Nigeria mangrove projects. This repository provides tools to estimate carbon sequestration over a 30-year period based on mangrove growth and survival rates.
4
-
5
- ## Recent Updates and Improvements
6
-
7
- ### Growth Model Enhancements (August 2024)
8
- - **Updated Growth Parameters**: Back-solved from August 2024 field data (t=1.58 years):
9
- - DBH growth rate (b): Changed from 0.1237 to 0.4736 yr⁻¹
10
- - Height growth rate (b): Changed from 0.1237 to 0.1335 yr⁻¹
11
- - Height shape parameter (c): Changed from 2.1 to 1.5
12
- - Rhizophora height asymptote: Changed from 12.0m to 11.58m
13
-
14
- - **Allometric Equations**: Using Zanvo et al. (2023) species-specific equations:
15
- - Rhizophora: Total = 1.938 × (DBH² H)^0.67628
16
- - Avicennia: Total = 1.486 × (DBH² H)^0.55864
17
-
18
- ### Dashboard Improvements
19
- - **Enhanced Parameter Controls**:
20
- - Direct input of Chapman-Richards parameters
21
- - Separate growth parameters for DBH and height
22
- - Real-time model updates
23
- - Improved validation and error handling
24
-
25
- - **Improved Visualizations**:
26
- - Carbon sequestration over time
27
- - Annual emission reductions
28
- - Per-tree biomass growth curves
29
- - Enhanced data tables and summaries
30
-
31
- - **Detailed Metrics**:
32
- - Project overview with total area and buffer pool
33
- - Comprehensive carbon sequestration metrics
34
- - Milestone year comparisons (10, 20, 30 years)
35
- - Per hectare metrics with annual rates
36
- - Species-specific results
37
-
38
- ## Model Parameters
39
-
40
- ### Species Parameters
41
- - **Rhizophora spp.**
42
- - DBH: a = 11.07 cm, b = 0.4736 yr⁻¹, c = 2.1
43
- - Height: a = 11.58 m, b = 0.1335 yr⁻¹, c = 1.5
44
- - Initial values: DBH = 1.0 cm, Height = 0.5 m
45
- - Configurable planting density and mortality rates
46
-
47
- - **Avicennia germinans**
48
- - DBH: a = 11.07 cm, b = 0.4736 yr⁻¹, c = 2.1
49
- - Height: a = 11.58 m, b = 0.1335 yr⁻¹, c = 1.5
50
- - Initial values: DBH = 1.0 cm, Height = 0.5 m
51
- - Configurable planting density and mortality rates
52
-
53
- ### Project Configuration
54
- - Total area: 5000 ha (2500 ha each in years 1-2)
55
- - Project duration: 30 years
56
- - Buffer pool: 20%
57
- - Species mix: Rhizophora spp. and Avicennia germinans
58
-
59
- ## Installation
60
-
61
- 1. Install [Miniconda](https://docs.conda.io/en/latest/miniconda.html) or [Miniforge](https://github.com/conda-forge/miniforge)
62
-
63
- 2. Create and activate the environment:
64
  ```bash
65
  conda env create -f environment.yml
66
  conda activate er-model
67
- ```
68
-
69
- 3. Install pre-commit hooks:
70
- ```bash
71
  pre-commit install
72
  ```
73
 
74
- ## Usage
75
-
76
- 1. Configure parameters in `configs/params.yaml`
77
-
78
- 2. Launch the dashboard:
79
  ```bash
80
  python -m dashboard.app
81
  ```
82
- This will start a Gradio server and open the dashboard in your default web browser.
83
- The dashboard will be accessible via a public URL for sharing.
84
-
85
- 3. Using the Dashboard:
86
- - Adjust species parameters (density, mortality, growth)
87
- - Set planting schedule (up to 5 years)
88
- - Configure buffer pool percentage
89
- - View real-time updates to:
90
- * Carbon sequestration curves
91
- * Annual emission reductions
92
- * Biomass per tree growth
93
- * Detailed metrics and summaries
94
-
95
- ## Project Structure
96
-
97
- - `src/` - Core model implementation
98
- - `er_model.py` - Main ER calculation logic
99
- - `allometry.py` - Species-specific allometric equations
100
- - `metrics.py` - Carbon conversion and statistics
101
- - `dashboard/` - Gradio web application
102
- - `configs/` - Parameter configuration files
103
- - `tests/` - Test suite
104
-
105
- ## Dependencies
106
-
107
- Key dependencies (specified in `environment.yml`):
108
- - numpy
109
- - pandas
110
- - gradio
111
- - matplotlib
112
- - pyyaml
113
- - pytest (for development)
114
-
115
- ## Development
116
-
117
- - Code style is enforced using `black` and `isort`
118
- - Type hints are required for all public functions
119
- - Tests are written using `pytest`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  - Pre-commit hooks ensure code quality
121
 
122
- ## License
 
 
 
 
 
 
 
 
123
 
 
124
  [License details to be added]
 
1
+ # Mangrove Emissions Reduction (ER) Modeling Suite
2
+
3
+ A Python toolkit and interactive dashboard for modeling carbon sequestration and tree survival in mangrove restoration projects. Supports multiple growth and mortality models, including Chapman-Richards and Jimenez & Lugo parameterizations.
4
+
5
+ ---
6
+
7
+ ## 🚀 Quickstart
8
+
9
+ 1. **Install dependencies**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ```bash
11
  conda env create -f environment.yml
12
  conda activate er-model
 
 
 
 
13
  pre-commit install
14
  ```
15
 
16
+ 2. **Launch the dashboard**
 
 
 
 
17
  ```bash
18
  python -m dashboard.app
19
  ```
20
+ - The dashboard opens in your browser.
21
+ - Each tab represents a different model/configuration.
22
+
23
+ ---
24
+
25
+ ## 🖥️ Dashboard Usage
26
+ - **Multi-model:** Switch between models (e.g., "Original Model", "Jimenez & Lugo") using tabs.
27
+ - **Interactive plots:** All plots are interactive (hover, zoom, export).
28
+ - **Parameter editing:** Adjust planting schedule and see real-time results (where enabled).
29
+ - **Tables:** View carbon, species, and survival tables for each model.
30
+
31
+ ---
32
+
33
+ ## 📚 Model Documentation
34
+
35
+ - [Original Model](models/original/README.md): Chapman-Richards growth, DBH-dependent mortality, field-calibrated.
36
+ - [Jimenez & Lugo Model](models/jimenez_lugo/README.md): Literature-based, fixed annual mortality rates.
37
+
38
+ ---
39
+
40
+ ## 🗂️ Directory Structure
41
+ ```
42
+ er-chapman-richards/
43
+ ├─�� README.md # This file
44
+ ├── environment.yml # Conda environment
45
+ ├── dashboard/ # Gradio dashboard app
46
+ ├── src/ # Core model code
47
+ ├── configs/ # Model parameter YAMLs
48
+ ├── models/ # Model-specific docs
49
+ │ ├── original/README.md
50
+ │ └── jimenez_lugo/README.md
51
+ ├── tests/ # Pytest suite
52
+ └── ...
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Adding a New Model
58
+ 1. Create a new config YAML in `configs/` (e.g., `configs/my_model.yaml`).
59
+ 2. Add a new entry to `MODEL_CONFIGS` in `dashboard/app.py`:
60
+ ```python
61
+ MODEL_CONFIGS = {
62
+ "Original Model": "configs/params.yaml",
63
+ "Jimenez & Lugo": "configs/jimenez_lugo.yaml",
64
+ "My Model": "configs/my_model.yaml"
65
+ }
66
+ ```
67
+ 3. (Optional) Add a new subdirectory and README in `models/` for documentation.
68
+ 4. Restart the dashboard. Your model appears as a new tab.
69
+
70
+ ---
71
+
72
+ ## 🧑‍💻 Development & Testing
73
+ - Code style: [black](https://black.readthedocs.io/), [isort](https://pycqa.github.io/isort/)
74
+ - Type hints required for all public functions
75
+ - Run tests with:
76
+ ```bash
77
+ pytest
78
+ ```
79
  - Pre-commit hooks ensure code quality
80
 
81
+ ---
82
+
83
+ ## 📖 References
84
+ - Chapman-Richards growth model literature
85
+ - Jimenez, J.A. & Lugo, A.E. (1985, 1987)
86
+ - Zanvo et al. (2023) for allometric equations
87
+ - Field data from Nigeria mangrove sites (2024)
88
+
89
+ ---
90
 
91
+ ## 📝 License
92
  [License details to be added]
configs/declining_increment.yaml ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ growth_model: declining_increment
2
+
3
+ species:
4
+ - name: "species_A" # Rhizophora spp.
5
+ planting_density: 4000 # trees/ha
6
+ mortality_rates:
7
+ year_1: 15 # %
8
+ year_2: 12 # %
9
+ year_3: 6 # %
10
+ year_4: 5 # %
11
+ year_5: 5 # %
12
+ year_6: 5 # %
13
+ year_7: 5 # %
14
+ year_8: 5 # %
15
+ year_9: 5 # %
16
+ year_10: 5 # %
17
+ subsequent: 1.5 # % (years 11-30)
18
+ declining_increment:
19
+ dbh:
20
+ r0: 1.83 # cm/yr
21
+ T_m: 30 # years
22
+ height:
23
+ r0: 0.61 # m/yr
24
+ T_m: 12 # years
25
+ allometry:
26
+ equation: "Zanvo et al. 2023: Total = 1.938 × (DBH² H)^0.67628"
27
+ initial_values:
28
+ dbh: 1.0 # cm
29
+ height: 0.5 # m
30
+
31
+ - name: "species_B" # Avicennia germinans
32
+ planting_density: 444 # trees/ha
33
+ mortality_rates:
34
+ year_1: 15 # %
35
+ year_2: 12 # %
36
+ year_3: 6 # %
37
+ year_4: 5 # %
38
+ year_5: 5 # %
39
+ year_6: 5 # %
40
+ year_7: 5 # %
41
+ year_8: 5 # %
42
+ year_9: 5 # %
43
+ year_10: 5 # %
44
+ subsequent: 1.5 # % (years 11-30)
45
+ declining_increment:
46
+ dbh:
47
+ r0: 1.2 # cm/yr (example, adjust as needed)
48
+ T_m: 30
49
+ height:
50
+ r0: 0.4 # m/yr (example, adjust as needed)
51
+ T_m: 12
52
+ allometry:
53
+ equation: "Zanvo et al. 2023: Total = 1.486 × (DBH² H)^0.55864"
54
+ initial_values:
55
+ dbh: 1.0 # cm
56
+ height: 0.5 # m
57
+
58
+ project:
59
+ duration_years: 30 # matches project_years
60
+ planting_schedule:
61
+ year_1: 2500 # ha
62
+ year_2: 2500 # ha
63
+ year_3: 0 # ha
64
+ year_4: 0 # ha
65
+ year_5: 0 # ha
66
+
67
+ carbon:
68
+ biomass_to_carbon: 0.47 # IPCC default
69
+ carbon_to_co2: 3.67 # molecular weight ratio
70
+ buffer_percentage: 20 # risk buffer
71
+ leakage_percentage: 0 # no leakage assumed
72
+ baseline_emissions: 0 # tCO2/ha/year
73
+
74
+ scenarios:
75
+ area: 1000.0 # ha
76
+ dbh_range: [1.0, 25.0] # cm
77
+ height_range: [0.5, 15.0] # m
78
+ growth_rate_factor: 1.0
configs/linear.yaml ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ growth_model: linear
2
+
3
+ species:
4
+ - name: "species_A" # Rhizophora spp.
5
+ planting_density: 4000 # trees/ha
6
+ mortality_rates:
7
+ year_1: 15 # %
8
+ year_2: 12 # %
9
+ year_3: 6 # %
10
+ year_4: 5 # %
11
+ year_5: 5 # %
12
+ year_6: 5 # %
13
+ year_7: 5 # %
14
+ year_8: 5 # %
15
+ year_9: 5 # %
16
+ year_10: 5 # %
17
+ subsequent: 1.5 # % (years 11-30)
18
+ linear:
19
+ dbh:
20
+ r: 1.83 # cm/yr
21
+ height:
22
+ r: 0.61 # m/yr
23
+ allometry:
24
+ equation: "Zanvo et al. 2023: Total = 1.938 × (DBH² H)^0.67628"
25
+ initial_values:
26
+ dbh: 1.0 # cm
27
+ height: 0.5 # m
28
+
29
+ - name: "species_B" # Avicennia germinans
30
+ planting_density: 444 # trees/ha
31
+ mortality_rates:
32
+ year_1: 15 # %
33
+ year_2: 12 # %
34
+ year_3: 6 # %
35
+ year_4: 5 # %
36
+ year_5: 5 # %
37
+ year_6: 5 # %
38
+ year_7: 5 # %
39
+ year_8: 5 # %
40
+ year_9: 5 # %
41
+ year_10: 5 # %
42
+ subsequent: 1.5 # % (years 11-30)
43
+ linear:
44
+ dbh:
45
+ r: 1.2 # cm/yr (example, adjust as needed)
46
+ height:
47
+ r: 0.4 # m/yr (example, adjust as needed)
48
+ allometry:
49
+ equation: "Zanvo et al. 2023: Total = 1.486 × (DBH² H)^0.55864"
50
+ initial_values:
51
+ dbh: 1.0 # cm
52
+ height: 0.5 # m
53
+
54
+ project:
55
+ duration_years: 30 # matches project_years
56
+ planting_schedule:
57
+ year_1: 2500 # ha
58
+ year_2: 2500 # ha
59
+ year_3: 0 # ha
60
+ year_4: 0 # ha
61
+ year_5: 0 # ha
62
+
63
+ carbon:
64
+ biomass_to_carbon: 0.47 # IPCC default
65
+ carbon_to_co2: 3.67 # molecular weight ratio
66
+ buffer_percentage: 20 # risk buffer
67
+ leakage_percentage: 0 # no leakage assumed
68
+ baseline_emissions: 0 # tCO2/ha/year
69
+
70
+ scenarios:
71
+ area: 1000.0 # ha
72
+ dbh_range: [1.0, 25.0] # cm
73
+ height_range: [0.5, 15.0] # m
74
+ growth_rate_factor: 1.0
configs/linear_plateau.yaml ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ growth_model: linear_plateau
2
+
3
+ species:
4
+ - name: "species_A" # Rhizophora spp.
5
+ planting_density: 4000 # trees/ha
6
+ mortality_rates:
7
+ year_1: 15 # %
8
+ year_2: 12 # %
9
+ year_3: 6 # %
10
+ year_4: 5 # %
11
+ year_5: 5 # %
12
+ year_6: 5 # %
13
+ year_7: 5 # %
14
+ year_8: 5 # %
15
+ year_9: 5 # %
16
+ year_10: 5 # %
17
+ subsequent: 1.5 # % (years 11-30)
18
+ linear_plateau:
19
+ dbh:
20
+ r: 1.83 # cm/yr
21
+ T_p: 30 # plateau at 30 years
22
+ a: 11.07 # asymptote from params.yaml
23
+ height:
24
+ r: 0.61 # m/yr
25
+ T_p: 12 # plateau at 12 years
26
+ a: 11.58 # asymptote from params.yaml
27
+ allometry:
28
+ equation: "Zanvo et al. 2023: Total = 1.938 × (DBH² H)^0.67628"
29
+ initial_values:
30
+ dbh: 1.0 # cm
31
+ height: 0.5 # m
32
+
33
+ - name: "species_B" # Avicennia germinans
34
+ planting_density: 444 # trees/ha
35
+ mortality_rates:
36
+ year_1: 15 # %
37
+ year_2: 12 # %
38
+ year_3: 6 # %
39
+ year_4: 5 # %
40
+ year_5: 5 # %
41
+ year_6: 5 # %
42
+ year_7: 5 # %
43
+ year_8: 5 # %
44
+ year_9: 5 # %
45
+ year_10: 5 # %
46
+ subsequent: 1.5 # % (years 11-30)
47
+ linear_plateau:
48
+ dbh:
49
+ r: 1.2 # cm/yr (example, adjust as needed)
50
+ T_p: 30
51
+ a: 17.0
52
+ height:
53
+ r: 0.4 # m/yr (example, adjust as needed)
54
+ T_p: 12
55
+ a: 8.8
56
+ allometry:
57
+ equation: "Zanvo et al. 2023: Total = 1.486 × (DBH² H)^0.55864"
58
+ initial_values:
59
+ dbh: 1.0 # cm
60
+ height: 0.5 # m
61
+
62
+ project:
63
+ duration_years: 30 # matches project_years
64
+ planting_schedule:
65
+ year_1: 2500 # ha
66
+ year_2: 2500 # ha
67
+ year_3: 0 # ha
68
+ year_4: 0 # ha
69
+ year_5: 0 # ha
70
+
71
+ carbon:
72
+ biomass_to_carbon: 0.47 # IPCC default
73
+ carbon_to_co2: 3.67 # molecular weight ratio
74
+ buffer_percentage: 20 # risk buffer
75
+ leakage_percentage: 0 # no leakage assumed
76
+ baseline_emissions: 0 # tCO2/ha/year
77
+
78
+ scenarios:
79
+ area: 1000.0 # ha
80
+ dbh_range: [1.0, 25.0] # cm
81
+ height_range: [0.5, 15.0] # m
82
+ growth_rate_factor: 1.0
configs/params.yaml CHANGED
@@ -16,12 +16,12 @@ species:
16
  chapman_richards:
17
  dbh:
18
  a: 11.07 # asymptotic maximum DBH (cm)
19
- b: 0.1237 # growth rate parameter (yr^-1)
20
  c: 2.1 # shape parameter
21
  height:
22
- a: 12.0 # asymptotic maximum height (m)
23
- b: 0.1237 # growth rate parameter (yr^-1)
24
- c: 2.1 # shape parameter
25
  allometry:
26
  equation: "Zanvo et al. 2023: Total = 1.938 × (DBH² H)^0.67628"
27
  initial_values:
@@ -45,12 +45,12 @@ species:
45
  chapman_richards:
46
  dbh:
47
  a: 17.0 # asymptotic maximum DBH (cm)
48
- b: 0.1237 # growth rate parameter (yr^-1)
49
  c: 2.1 # shape parameter
50
  height:
51
  a: 8.8 # asymptotic maximum height (m)
52
- b: 0.1237 # growth rate parameter (yr^-1)
53
- c: 2.1 # shape parameter
54
  allometry:
55
  equation: "Zanvo et al. 2023: Total = 1.486 × (DBH² H)^0.55864"
56
  initial_values:
 
16
  chapman_richards:
17
  dbh:
18
  a: 11.07 # asymptotic maximum DBH (cm)
19
+ b: 0.4736 # growth rate parameter (yr^-1), back-solved from Aug 2024 data
20
  c: 2.1 # shape parameter
21
  height:
22
+ a: 11.58 # asymptotic maximum height (m)
23
+ b: 0.1335 # growth rate parameter (yr^-1), back-solved from Aug 2024 data
24
+ c: 1.5 # shape parameter
25
  allometry:
26
  equation: "Zanvo et al. 2023: Total = 1.938 × (DBH² H)^0.67628"
27
  initial_values:
 
45
  chapman_richards:
46
  dbh:
47
  a: 17.0 # asymptotic maximum DBH (cm)
48
+ b: 0.4736 # growth rate parameter (yr^-1), back-solved from Aug 2024 data
49
  c: 2.1 # shape parameter
50
  height:
51
  a: 8.8 # asymptotic maximum height (m)
52
+ b: 0.1335 # growth rate parameter (yr^-1), back-solved from Aug 2024 data
53
+ c: 1.5 # shape parameter
54
  allometry:
55
  equation: "Zanvo et al. 2023: Total = 1.486 × (DBH² H)^0.55864"
56
  initial_values:
dashboard/app.py CHANGED
@@ -1,459 +1,408 @@
1
  """
2
- Gradio dashboard for exploring ER model results and parameters.
3
- """
4
- from pathlib import Path
5
- from typing import Dict, List, Tuple
6
 
 
 
 
 
7
  import gradio as gr
 
 
 
 
8
  import matplotlib.pyplot as plt
9
- import numpy as np
10
  import pandas as pd
11
- import yaml
12
- import uuid
13
-
14
- from src.er_model import ERModel
 
15
 
 
 
 
 
 
 
16
 
17
- def load_config(config_path: Path = Path("configs/params.yaml")) -> dict:
18
- """Load and parse YAML configuration."""
19
  with open(config_path) as f:
20
- return yaml.safe_load(f)
 
 
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- def run_model(config: dict) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
24
- """Run model with current parameters."""
25
- # Create a temporary config file with a unique name
26
- temp_config = Path(f"configs/temp_config_{uuid.uuid4()}.yaml")
27
-
28
- try:
29
- # Save temporary config
30
- with open(temp_config, "w") as f:
31
- yaml.dump(config, f)
32
-
33
- # Run model
34
- model = ERModel(temp_config)
35
- results, species_results, scenario_results = model.run()
36
-
37
- return results, species_results, scenario_results
38
- finally:
39
- # Clean up temp file
40
- if temp_config.exists():
41
- temp_config.unlink()
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
- def format_number(x):
45
- """Format number with comma separators, handling NaN and floating points."""
46
- if pd.isna(x):
47
- return "0"
48
- if isinstance(x, (int, float)):
49
- if isinstance(x, int) or float(x).is_integer():
50
- return format(int(x), ',')
51
- return format(round(float(x), 2), ',')
52
- return str(x)
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
- def create_plots_and_tables(
56
- results: pd.DataFrame,
57
- species_results: pd.DataFrame,
58
- scenario_results: pd.DataFrame,
59
- config: dict,
60
- year_1_area: float,
61
- year_2_area: float,
62
- year_3_area: float,
63
- year_4_area: float,
64
- year_5_area: float
65
- ) -> Tuple[plt.Figure, plt.Figure, plt.Figure, str, pd.DataFrame, pd.DataFrame]:
66
- """Create carbon sequestration plots, summary, and tables."""
67
- # Clear any existing plots
68
- plt.close('all')
69
-
70
- # Time series plot
71
- fig1 = plt.figure(figsize=(10, 6))
72
- ax1 = fig1.add_subplot(111)
73
- results.plot(
74
- x="year",
75
- y=["gross_carbon", "net_carbon"],
76
- ax=ax1,
77
- title="Carbon Sequestration Over Time"
78
- )
79
- ax1.set_xlabel("Year")
80
- ax1.set_ylabel("Carbon (tCO2)")
81
- ax1.legend(["Gross Carbon", "Net Carbon"])
82
- # Format y-axis with comma separator
83
- ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: format_number(x)))
84
-
85
- # Annual ERs plot (replacing milestone years plot)
86
- fig2 = plt.figure(figsize=(10, 6))
87
- ax2 = fig2.add_subplot(111)
88
-
89
- # Calculate annual ERs (year-over-year change in net carbon)
90
- annual_ers = results["net_carbon"].diff()
91
- annual_ers.iloc[0] = results["net_carbon"].iloc[0] # First year is total, not difference
92
-
93
- # Plot annual ERs as bars
94
- ax2.bar(results["year"], annual_ers, color='#2ecc71', alpha=0.7)
95
- ax2.set_xlabel("Year")
96
- ax2.set_ylabel("Annual ERs (tCO2)")
97
- ax2.set_title("Annual Emission Reductions")
98
-
99
- # Format y-axis with comma separator
100
- ax2.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: format_number(x)))
101
-
102
- # Add grid for better readability
103
- ax2.grid(True, axis='y', linestyle='--', alpha=0.7)
104
-
105
- # Ensure integer ticks for years
106
- ax2.xaxis.set_major_locator(plt.MaxNLocator(integer=True))
107
 
108
- # New biomass per tree plot
109
- fig3 = plt.figure(figsize=(10, 6))
110
- ax3 = fig3.add_subplot(111)
111
-
112
- # Get initial values
113
- rhiz_init = config["species"][0]["initial_values"]
114
- avic_init = config["species"][1]["initial_values"]
115
-
116
- # Create time series
117
- years = np.arange(len(species_results))
118
-
119
- # Calculate DBH and height for each species over time
120
- def chapman_richards(t, params, initial):
121
- a, b, c = params["a"], params["b"], params["c"]
122
- return initial + (a - initial) * (1 - np.exp(-b * t)) ** c
123
-
124
- # Calculate growth for Rhizophora
125
- rhiz_dbh = chapman_richards(years, config["species"][0]["chapman_richards"]["dbh"], rhiz_init["dbh"])
126
- rhiz_height = chapman_richards(years, config["species"][0]["chapman_richards"]["height"], rhiz_init["height"])
127
-
128
- # Calculate growth for Avicennia
129
- avic_dbh = chapman_richards(years, config["species"][1]["chapman_richards"]["dbh"], avic_init["dbh"])
130
- avic_height = chapman_richards(years, config["species"][1]["chapman_richards"]["height"], avic_init["height"])
131
-
132
- # Calculate biomass using Zanvo equations (convert kg to tonnes)
133
- rhiz_biomass = 1.938 * (rhiz_dbh**2 * rhiz_height)**0.67628 / 1000 # Convert kg to tonnes
134
- avic_biomass = 1.486 * (avic_dbh**2 * avic_height)**0.55864 / 1000 # Convert kg to tonnes
135
-
136
- # Plot with proper formatting
137
- ax3.plot(years, rhiz_biomass, 'o-', color='#1f77b4', label='Rhizophora spp. - Total Biomass')
138
- ax3.plot(years, avic_biomass, 'o-', color='#ff7f0e', label='Avicennia germinans - Total Biomass')
139
-
140
- ax3.set_xlabel("Year since planting")
141
- ax3.set_ylabel("Biomass (tonnes per tree)")
142
- ax3.set_title("Total Biomass per Tree")
143
- ax3.grid(True)
144
- ax3.legend()
145
-
146
- # Set proper axis limits
147
- ax3.set_xlim(0, len(years)-1)
148
- ax3.set_ylim(bottom=0) # Start y-axis at 0
149
-
150
- # Format y-axis with comma separator
151
- ax3.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: format_number(x)))
152
-
153
- # Ensure the plot is drawn
154
- fig3.tight_layout()
155
- plt.draw()
156
 
157
- # Calculate total area from planting schedule
158
- total_area = sum([
159
- year_1_area, year_2_area, year_3_area, year_4_area, year_5_area
160
- ])
161
-
162
- # Get final year carbon values
163
  final_gross = results["gross_carbon"].iloc[-1]
164
  final_net = results["net_carbon"].iloc[-1]
165
-
166
- # Calculate per hectare metrics
167
- if total_area > 0:
168
- gross_carbon_per_ha = final_gross / total_area
169
- net_carbon_per_ha = final_net / total_area
170
- annual_net_per_ha = net_carbon_per_ha / len(results) # Divide by actual years
171
- else:
172
- gross_carbon_per_ha = 0
173
- net_carbon_per_ha = 0
174
- annual_net_per_ha = 0
175
-
176
- # Get year 10 and year 20 values for intermediate metrics
177
- year_10_gross = results["gross_carbon"].iloc[9] if len(results) > 9 else 0
178
- year_10_net = results["net_carbon"].iloc[9] if len(results) > 9 else 0
179
- year_20_gross = results["gross_carbon"].iloc[19] if len(results) > 19 else 0
180
- year_20_net = results["net_carbon"].iloc[19] if len(results) > 19 else 0
181
-
182
- # Calculate annual averages
183
- years_with_carbon = len(results[results["gross_carbon"] > 0])
184
- if years_with_carbon > 0:
185
- avg_annual_gross = final_gross / years_with_carbon
186
- avg_annual_net = final_net / years_with_carbon
187
- else:
188
- avg_annual_gross = 0
189
- avg_annual_net = 0
190
-
191
- # Create summary text with formatted numbers
192
- summary = f"""
193
- Project Overview:
194
- ----------------
195
- Duration: {len(results)} years
196
- Total Area Planted: {format_number(total_area)} ha
197
- Buffer Pool: {config["carbon"]["buffer_percentage"]}%
198
-
199
- Carbon Sequestration:
200
- -------------------
201
- Total Gross Carbon (Year 30): {format_number(final_gross)} tCO2
202
- Total Net Carbon (Year 30): {format_number(final_net)} tCO2
203
- Average Annual Gross: {format_number(avg_annual_gross)} tCO2/yr
204
- Average Annual Net: {format_number(avg_annual_net)} tCO2/yr
205
-
206
- Milestone Years:
207
- --------------
208
- Year 10 Gross: {format_number(year_10_gross)} tCO2
209
- Year 10 Net: {format_number(year_10_net)} tCO2
210
- Year 20 Gross: {format_number(year_20_gross)} tCO2
211
- Year 20 Net: {format_number(year_20_net)} tCO2
212
-
213
- Per Hectare Metrics (Year 30):
214
- -----------------------------
215
- Gross Carbon per ha: {format_number(gross_carbon_per_ha)} tCO2/ha
216
- Net Carbon per ha: {format_number(net_carbon_per_ha)} tCO2/ha
217
- Average Annual Net per ha: {format_number(annual_net_per_ha)} tCO2/ha/yr
218
- """
219
-
220
- # Format species results table with comma separators
221
- species_table = species_results.copy()
222
- species_table.index.name = "Year"
223
- for col in species_table.select_dtypes(include=[np.number]).columns:
224
- species_table[col] = species_table[col].apply(format_number)
225
-
226
- # Create annual ERs table
227
- annual_results = pd.DataFrame({
228
- 'Year': results['year'],
229
- 'Annual ERs (tCO2)': annual_ers,
230
- 'Cumulative ERs (tCO2)': results['net_carbon'],
231
- 'Area Planted (ha)': results[[col for col in results.columns if col.startswith('area_year_')]].sum(axis=1),
232
- 'Cumulative Area (ha)': results[[col for col in results.columns if col.startswith('area_year_')]].cumsum().sum(axis=1)
233
- })
234
-
235
- # Format annual results table with comma separators
236
- for col in annual_results.select_dtypes(include=[np.number]).columns:
237
- annual_results[col] = annual_results[col].apply(format_number)
238
-
239
- # Format scenario comparison table with comma separators
240
- scenario_table = scenario_results.copy()
241
- scenario_table.index.name = "Scenario"
242
- for col in scenario_table.select_dtypes(include=[np.number]).columns:
243
- scenario_table[col] = scenario_table[col].apply(format_number)
244
-
245
- return fig1, fig2, fig3, summary, annual_results, scenario_table
246
-
247
-
248
- def update_model(
249
- rhiz_density: float,
250
- rhiz_mort_1: float,
251
- rhiz_mort_2: float,
252
- rhiz_mort_3: float,
253
- rhiz_mort_4: float,
254
- rhiz_mort_5: float,
255
- avic_density: float,
256
- avic_mort_1: float,
257
- avic_mort_2: float,
258
- avic_mort_3: float,
259
- avic_mort_4: float,
260
- avic_mort_5: float,
261
- year_1_area: float,
262
- year_2_area: float,
263
- year_3_area: float,
264
- year_4_area: float,
265
- year_5_area: float,
266
- buffer_percentage: float,
267
- # Chapman-Richards parameters for Rhizophora
268
- rhiz_dbh_a: float,
269
- rhiz_dbh_b: float,
270
- rhiz_dbh_c: float,
271
- rhiz_height_a: float,
272
- rhiz_height_b: float,
273
- rhiz_height_c: float,
274
- rhiz_initial_dbh: float,
275
- rhiz_initial_height: float,
276
- # Chapman-Richards parameters for Avicennia
277
- avic_dbh_a: float,
278
- avic_dbh_b: float,
279
- avic_dbh_c: float,
280
- avic_height_a: float,
281
- avic_height_b: float,
282
- avic_height_c: float,
283
- avic_initial_dbh: float,
284
- avic_initial_height: float,
285
- ) -> Tuple[plt.Figure, plt.Figure, plt.Figure, str, pd.DataFrame, pd.DataFrame]:
286
- """Update model with new parameters and return plots and tables."""
287
- # Load base config
288
- config = load_config()
289
-
290
- # Update Rhizophora parameters
291
- config["species"][0]["planting_density"] = rhiz_density
292
- config["species"][0]["mortality_rates"].update({
293
- "year_1": rhiz_mort_1,
294
- "year_2": rhiz_mort_2,
295
- "year_3": rhiz_mort_3,
296
- "year_4": rhiz_mort_4,
297
- "year_5": rhiz_mort_5
298
- })
299
-
300
- # Update Rhizophora Chapman-Richards parameters
301
- config["species"][0]["chapman_richards"]["dbh"].update({
302
- "a": rhiz_dbh_a,
303
- "b": rhiz_dbh_b,
304
- "c": rhiz_dbh_c
305
- })
306
- config["species"][0]["chapman_richards"]["height"].update({
307
- "a": rhiz_height_a,
308
- "b": rhiz_height_b,
309
- "c": rhiz_height_c
310
- })
311
- config["species"][0]["initial_values"].update({
312
- "dbh": rhiz_initial_dbh,
313
- "height": rhiz_initial_height
314
- })
315
-
316
- # Update Avicennia parameters
317
- config["species"][1]["planting_density"] = avic_density
318
- config["species"][1]["mortality_rates"].update({
319
- "year_1": avic_mort_1,
320
- "year_2": avic_mort_2,
321
- "year_3": avic_mort_3,
322
- "year_4": avic_mort_4,
323
- "year_5": avic_mort_5
324
- })
325
-
326
- # Update Avicennia Chapman-Richards parameters
327
- config["species"][1]["chapman_richards"]["dbh"].update({
328
- "a": avic_dbh_a,
329
- "b": avic_dbh_b,
330
- "c": avic_dbh_c
331
- })
332
- config["species"][1]["chapman_richards"]["height"].update({
333
- "a": avic_height_a,
334
- "b": avic_height_b,
335
- "c": avic_height_c
336
- })
337
- config["species"][1]["initial_values"].update({
338
- "dbh": avic_initial_dbh,
339
- "height": avic_initial_height
340
- })
341
-
342
- # Update planting schedule
343
- config["project"]["planting_schedule"].update({
344
- "year_1": year_1_area,
345
- "year_2": year_2_area,
346
- "year_3": year_3_area,
347
- "year_4": year_4_area,
348
- "year_5": year_5_area
349
- })
350
-
351
- # Update carbon parameters
352
- config["carbon"]["buffer_percentage"] = buffer_percentage
353
-
354
- # Run model and create plots/tables
355
- results, species_results, scenario_results = run_model(config)
356
- return create_plots_and_tables(results, species_results, scenario_results, config, year_1_area, year_2_area, year_3_area, year_4_area, year_5_area)
357
-
358
 
359
- def main():
360
- """Run the Gradio interface."""
361
- with gr.Blocks(theme=gr.themes.Soft()) as app:
362
- gr.Markdown("# ER Model Dashboard")
363
- gr.Markdown("Explore carbon sequestration results with different growth parameters")
364
-
365
- with gr.Tab("Model Parameters"):
 
 
 
 
 
366
  with gr.Row():
367
- with gr.Column():
368
- gr.Markdown("### Rhizophora Parameters")
369
- rhiz_density = gr.Number(value=2500, label="Planting Density (trees/ha)")
370
- rhiz_mort_1 = gr.Number(value=20, label="Year 1 Mortality (%)")
371
- rhiz_mort_2 = gr.Number(value=10, label="Year 2 Mortality (%)")
372
- rhiz_mort_3 = gr.Number(value=5, label="Year 3 Mortality (%)")
373
- rhiz_mort_4 = gr.Number(value=2, label="Year 4 Mortality (%)")
374
- rhiz_mort_5 = gr.Number(value=1, label="Year 5 Mortality (%)")
375
-
376
- gr.Markdown("### Rhizophora Growth Parameters")
377
- rhiz_dbh_a = gr.Number(value=11.07, label="DBH Asymptote (cm)")
378
- rhiz_dbh_b = gr.Number(value=0.4736, label="DBH Growth Rate (yr⁻¹)")
379
- rhiz_dbh_c = gr.Number(value=2.1, label="DBH Shape Parameter")
380
- rhiz_height_a = gr.Number(value=11.58, label="Height Asymptote (m)")
381
- rhiz_height_b = gr.Number(value=0.1335, label="Height Growth Rate (yr⁻¹)")
382
- rhiz_height_c = gr.Number(value=1.5, label="Height Shape Parameter")
383
- rhiz_initial_dbh = gr.Number(value=1.0, label="Initial DBH (cm)")
384
- rhiz_initial_height = gr.Number(value=0.5, label="Initial Height (m)")
385
-
386
- with gr.Column():
387
- gr.Markdown("### Avicennia Parameters")
388
- avic_density = gr.Number(value=2500, label="Planting Density (trees/ha)")
389
- avic_mort_1 = gr.Number(value=20, label="Year 1 Mortality (%)")
390
- avic_mort_2 = gr.Number(value=10, label="Year 2 Mortality (%)")
391
- avic_mort_3 = gr.Number(value=5, label="Year 3 Mortality (%)")
392
- avic_mort_4 = gr.Number(value=2, label="Year 4 Mortality (%)")
393
- avic_mort_5 = gr.Number(value=1, label="Year 5 Mortality (%)")
394
-
395
- gr.Markdown("### Avicennia Growth Parameters")
396
- avic_dbh_a = gr.Number(value=11.07, label="DBH Asymptote (cm)")
397
- avic_dbh_b = gr.Number(value=0.4736, label="DBH Growth Rate (yr⁻¹)")
398
- avic_dbh_c = gr.Number(value=2.1, label="DBH Shape Parameter")
399
- avic_height_a = gr.Number(value=11.58, label="Height Asymptote (m)")
400
- avic_height_b = gr.Number(value=0.1335, label="Height Growth Rate (yr⁻¹)")
401
- avic_height_c = gr.Number(value=1.5, label="Height Shape Parameter")
402
- avic_initial_dbh = gr.Number(value=1.0, label="Initial DBH (cm)")
403
- avic_initial_height = gr.Number(value=0.5, label="Initial Height (m)")
404
-
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  with gr.Row():
406
- with gr.Column():
407
- gr.Markdown("### Project Parameters")
408
- year_1_area = gr.Number(value=2500, label="Year 1 Planting Area (ha)")
409
- year_2_area = gr.Number(value=2500, label="Year 2 Planting Area (ha)")
410
- year_3_area = gr.Number(value=0, label="Year 3 Planting Area (ha)")
411
- year_4_area = gr.Number(value=0, label="Year 4 Planting Area (ha)")
412
- year_5_area = gr.Number(value=0, label="Year 5 Planting Area (ha)")
413
- buffer_percentage = gr.Number(value=20, label="Buffer Pool (%)")
414
-
415
- with gr.Row():
416
- update_btn = gr.Button("Update Model")
417
-
418
- with gr.Tab("Results"):
 
 
 
419
  with gr.Row():
420
- carbon_plot = gr.Plot(label="Carbon Sequestration")
421
- annual_plot = gr.Plot(label="Annual ERs")
422
- biomass_plot = gr.Plot(label="Biomass per Tree")
423
-
424
- summary_text = gr.Textbox(label="Summary", lines=10)
425
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  with gr.Row():
427
- annual_table = gr.DataFrame(label="Annual Results")
428
- species_table = gr.DataFrame(label="Species Results")
429
-
430
- # Connect the update button to the model update function
431
- inputs = [
432
- rhiz_density, rhiz_mort_1, rhiz_mort_2, rhiz_mort_3, rhiz_mort_4, rhiz_mort_5,
433
- avic_density, avic_mort_1, avic_mort_2, avic_mort_3, avic_mort_4, avic_mort_5,
434
- year_1_area, year_2_area, year_3_area, year_4_area, year_5_area,
435
- buffer_percentage,
436
- # Chapman-Richards parameters for Rhizophora
437
- rhiz_dbh_a, rhiz_dbh_b, rhiz_dbh_c,
438
- rhiz_height_a, rhiz_height_b, rhiz_height_c,
439
- rhiz_initial_dbh, rhiz_initial_height,
440
- # Chapman-Richards parameters for Avicennia
441
- avic_dbh_a, avic_dbh_b, avic_dbh_c,
442
- avic_height_a, avic_height_b, avic_height_c,
443
- avic_initial_dbh, avic_initial_height
444
- ]
445
-
446
- outputs = [carbon_plot, annual_plot, biomass_plot, summary_text, annual_table, species_table]
447
-
448
- # Connect button click event
449
- update_btn.click(fn=update_model, inputs=inputs, outputs=outputs)
450
-
451
- # Initialize the UI with default values
452
- app.load(fn=update_model, inputs=inputs, outputs=outputs)
453
-
454
- return app
455
-
456
 
457
  if __name__ == "__main__":
458
- app = main()
459
- app.launch(share=True)
 
1
  """
2
+ Mangrove ER Model Dashboard
 
 
 
3
 
4
+ - Each tab corresponds to a different model/config.
5
+ - To add a new model, add its config to configs/ and add an entry to MODEL_CONFIGS below.
6
+ - Each tab shows the results for the base config (no parameter editing).
7
+ """
8
  import gradio as gr
9
+ from pathlib import Path
10
+ from src.er_model import ERModel
11
+ import yaml
12
+ import tempfile
13
  import matplotlib.pyplot as plt
 
14
  import pandas as pd
15
+ import numpy as np
16
+ import plotly.graph_objs as go
17
+ from plotly.subplots import make_subplots
18
+ import warnings
19
+ import traceback
20
 
21
+ MODEL_CONFIGS = {
22
+ "Original Model": "configs/params.yaml",
23
+ "Simple Linear": "configs/linear.yaml",
24
+ "Linear Plateau": "configs/linear_plateau.yaml",
25
+ "Declining Increment": "configs/declining_increment.yaml"
26
+ }
27
 
28
+ # Helper to update planting schedule in a config file
29
+ def update_planting_schedule(config_path, year_areas):
30
  with open(config_path) as f:
31
+ config = yaml.safe_load(f)
32
+ for i, area in enumerate(year_areas, 1):
33
+ config["project"]["planting_schedule"][f"year_{i}"] = area
34
+ return config
35
 
36
+ def create_survival_table(model):
37
+ """
38
+ Returns a DataFrame with years as rows and columns for each species and total surviving trees.
39
+ """
40
+ years = range(1, model.project.duration_years + 1)
41
+ data = {"Year": []}
42
+ species_names = [s.name for s in model.species]
43
+ for name in species_names:
44
+ data[name] = []
45
+ data["Total Surviving Trees"] = []
46
+ for year in years:
47
+ data["Year"].append(year)
48
+ totals = model.calculate_total_surviving_trees(year)
49
+ total = 0
50
+ for name in species_names:
51
+ val = totals.get(name, 0)
52
+ data[name].append(val)
53
+ total += val
54
+ data["Total Surviving Trees"].append(total)
55
+ df = pd.DataFrame(data)
56
+ # Format numbers: no decimals, thousands separator
57
+ for name in species_names + ["Total Surviving Trees"]:
58
+ df[name] = df[name].apply(lambda x: f"{x:,.0f}")
59
+ return df
60
 
61
+ def run_model_from_config(config):
62
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
63
+ yaml.dump(config, tmp)
64
+ tmp_path = tmp.name
65
+ model = ERModel(Path(tmp_path))
66
+ results, species_results = model.run()
67
+ plots = create_all_plots(results, species_results, config)
68
+ summary = create_summary(results, species_results, config, model)
69
+ survival_table = create_survival_table(model)
70
+ # Format tables
71
+ results_fmt = results.copy()
72
+ species_results_fmt = species_results.copy()
73
+ for col in results_fmt.columns:
74
+ if results_fmt[col].dtype in [float, int]:
75
+ results_fmt[col] = results_fmt[col].apply(lambda x: f"{x:,.2f}" if isinstance(x, float) else f"{x:,}")
76
+ for col in species_results_fmt.columns:
77
+ if species_results_fmt[col].dtype in [float, int]:
78
+ species_results_fmt[col] = species_results_fmt[col].apply(lambda x: f"{x:,.2f}" if isinstance(x, float) else f"{x:,}")
79
+ return (*plots, summary, results_fmt.head(30), species_results_fmt.head(30), survival_table.head(30))
80
 
81
+ def run_model(config_path):
82
+ with open(config_path) as f:
83
+ config = yaml.safe_load(f)
84
+ model = ERModel(Path(config_path))
85
+ results, species_results = model.run()
86
+ plots = create_all_plots(results, species_results, config)
87
+ summary = create_summary(results, species_results, config, model)
88
+ survival_table = create_survival_table(model)
89
+ # Format tables
90
+ results_fmt = results.copy()
91
+ species_results_fmt = species_results.copy()
92
+ for col in results_fmt.columns:
93
+ if results_fmt[col].dtype in [float, int]:
94
+ results_fmt[col] = results_fmt[col].apply(lambda x: f"{x:,.2f}" if isinstance(x, float) else f"{x:,}")
95
+ for col in species_results_fmt.columns:
96
+ if species_results_fmt[col].dtype in [float, int]:
97
+ species_results_fmt[col] = species_results_fmt[col].apply(lambda x: f"{x:,.2f}" if isinstance(x, float) else f"{x:,}")
98
+ return (*plots, summary, results_fmt.head(30), species_results_fmt.head(30), survival_table.head(30))
99
 
100
+ def check_complex(arr, label):
101
+ if np.iscomplexobj(arr):
102
+ complex_indices = np.where(np.iscomplex(arr))[0]
103
+ warnings.warn(f"[ERROR] Complex values in {label}: indices={complex_indices}, values={arr[complex_indices]}")
104
+ traceback.print_stack()
 
 
 
 
105
 
106
+ def create_growth_increment_plots(config, model_type=None):
107
+ """
108
+ Create a 2x2 grid of growth and increment plots for DBH and Height using Plotly.
109
+ model_type: 'chapman_richards', 'linear', 'linear_plateau', or 'declining_increment'
110
+ """
111
+ if model_type is None:
112
+ model_type = config.get('growth_model', 'chapman_richards')
113
+ years = np.arange(1, config["project"]["duration_years"] + 1)
114
+ fig = make_subplots(rows=2, cols=2, subplot_titles=("DBH Growth", "HEIGHT Growth", "DBH Annual Increment", "HEIGHT Annual Increment"))
115
+ from src.er_model import ERModel
116
+ for sp in config["species"]:
117
+ name = sp["name"]
118
+ initial_dbh = sp["initial_values"]["dbh"]
119
+ initial_height = sp["initial_values"]["height"]
120
+ if model_type == "linear":
121
+ dbh_params = sp["linear"]["dbh"]
122
+ height_params = sp["linear"]["height"]
123
+ dbh = [ERModel.linear_growth(t, dbh_params, initial_dbh) for t in years]
124
+ height = [ERModel.linear_growth(t, height_params, initial_height) for t in years]
125
+ elif model_type == "linear_plateau":
126
+ dbh_params = sp["linear_plateau"]["dbh"]
127
+ height_params = sp["linear_plateau"]["height"]
128
+ dbh = [ERModel.linear_plateau_growth(t, dbh_params, initial_dbh) for t in years]
129
+ height = [ERModel.linear_plateau_growth(t, height_params, initial_height) for t in years]
130
+ elif model_type == "declining_increment":
131
+ dbh_params = sp["declining_increment"]["dbh"]
132
+ height_params = sp["declining_increment"]["height"]
133
+ dbh = [ERModel.declining_increment_growth(t, dbh_params, initial_dbh) for t in years]
134
+ height = [ERModel.declining_increment_growth(t, height_params, initial_height) for t in years]
135
+ else: # Default to Chapman-Richards
136
+ dbh_params = sp["chapman_richards"]["dbh"]
137
+ height_params = sp["chapman_richards"]["height"]
138
+ def chapman_richards(t, params, initial):
139
+ a, b, c = params["a"], params["b"], params["c"]
140
+ return initial + (a - initial) * (1 - np.exp(-b * t)) ** c
141
+ dbh = [chapman_richards(t, dbh_params, initial_dbh) for t in years]
142
+ height = [chapman_richards(t, height_params, initial_height) for t in years]
143
+ dbh = np.array(dbh)
144
+ height = np.array(height)
145
+ check_complex(dbh, f"{name} DBH (growth)")
146
+ check_complex(height, f"{name} Height (growth)")
147
+ dbh_inc = np.diff(np.insert(dbh, 0, dbh[0]))
148
+ height_inc = np.diff(np.insert(height, 0, height[0]))
149
+ check_complex(dbh_inc, f"{name} DBH Δ (increment)")
150
+ check_complex(height_inc, f"{name} Height Δ (increment)")
151
+ fig.add_trace(go.Scatter(x=years, y=dbh, mode='lines', name=f"{name} DBH", legendgroup=name, line=dict(width=2)), row=1, col=1)
152
+ fig.add_trace(go.Scatter(x=years, y=height, mode='lines', name=f"{name} Height", legendgroup=name, line=dict(width=2)), row=1, col=2)
153
+ fig.add_trace(go.Scatter(x=years, y=dbh_inc, mode='lines', name=f"{name} DBH Δ", legendgroup=name, showlegend=False, line=dict(width=2)), row=2, col=1)
154
+ fig.add_trace(go.Scatter(x=years, y=height_inc, mode='lines', name=f"{name} Height Δ", legendgroup=name, showlegend=False, line=dict(width=2)), row=2, col=2)
155
+ fig.update_layout(height=700, width=900, title_text="Growth and Increment Curves", hovermode="x unified")
156
+ fig.update_xaxes(title_text="Age (years)", row=1, col=1)
157
+ fig.update_xaxes(title_text="Age (years)", row=1, col=2)
158
+ fig.update_xaxes(title_text="Age (years)", row=2, col=1)
159
+ fig.update_xaxes(title_text="Age (years)", row=2, col=2)
160
+ fig.update_yaxes(title_text="Size (cm)", row=1, col=1)
161
+ fig.update_yaxes(title_text="Size (m)", row=1, col=2)
162
+ fig.update_yaxes(title_text="Annual increment (cm/year)", row=2, col=1)
163
+ fig.update_yaxes(title_text="Annual increment (m/year)", row=2, col=2)
164
+ return fig
165
 
166
+ def create_all_plots(results, species_results, config):
167
+ # Diagnostic: Check for complex numbers in results DataFrame columns used for plotting
168
+ for col in ["gross_carbon", "net_carbon"]:
169
+ arr = np.array(results[col])
170
+ check_complex(arr, f"results['{col}']")
171
+ # 1. Carbon curve (gross and net)
172
+ fig1 = go.Figure()
173
+ fig1.add_trace(go.Scatter(x=results["year"], y=results["gross_carbon"], mode="lines+markers", name="Gross Carbon", line=dict(width=2)))
174
+ fig1.add_trace(go.Scatter(x=results["year"], y=results["net_carbon"], mode="lines+markers", name="Net Carbon", line=dict(width=2)))
175
+ fig1.update_layout(title="Carbon Sequestration Over Time", xaxis_title="Year", yaxis_title="Carbon (tCO2)", hovermode="x unified", template="plotly_white")
176
+
177
+ # 2. Annual carbon (ERs)
178
+ annual_ers = results["net_carbon"].diff().fillna(results["net_carbon"].iloc[0])
179
+ arr = np.array(annual_ers)
180
+ check_complex(arr, "annual_ers")
181
+ fig2 = go.Figure()
182
+ fig2.add_trace(go.Bar(x=results["year"], y=annual_ers, name="Annual ERs", marker_color="#2ecc71", opacity=0.7))
183
+ fig2.update_layout(title="Annual Emission Reductions", xaxis_title="Year", yaxis_title="Annual ERs (tCO2)", hovermode="x unified", template="plotly_white")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
+ # 3. Biomass per tree for all species
186
+ years = results["year"]
187
+ fig3 = go.Figure()
188
+ from src.er_model import ERModel
189
+ growth_model = config.get('growth_model', 'chapman_richards')
190
+ for sp in config["species"]:
191
+ name = sp["name"]
192
+ initial_dbh = sp["initial_values"]["dbh"]
193
+ initial_height = sp["initial_values"]["height"]
194
+ if growth_model == "linear":
195
+ dbh_params = sp["linear"]["dbh"]
196
+ height_params = sp["linear"]["height"]
197
+ dbh = [ERModel.linear_growth(t, dbh_params, initial_dbh) for t in years]
198
+ height = [ERModel.linear_growth(t, height_params, initial_height) for t in years]
199
+ elif growth_model == "linear_plateau":
200
+ dbh_params = sp["linear_plateau"]["dbh"]
201
+ height_params = sp["linear_plateau"]["height"]
202
+ dbh = [ERModel.linear_plateau_growth(t, dbh_params, initial_dbh) for t in years]
203
+ height = [ERModel.linear_plateau_growth(t, height_params, initial_height) for t in years]
204
+ elif growth_model == "declining_increment":
205
+ dbh_params = sp["declining_increment"]["dbh"]
206
+ height_params = sp["declining_increment"]["height"]
207
+ dbh = [ERModel.declining_increment_growth(t, dbh_params, initial_dbh) for t in years]
208
+ height = [ERModel.declining_increment_growth(t, height_params, initial_height) for t in years]
209
+ else: # Default to Chapman-Richards
210
+ dbh_params = sp["chapman_richards"]["dbh"]
211
+ height_params = sp["chapman_richards"]["height"]
212
+ def chapman_richards(t, params, initial):
213
+ a, b, c = params["a"], params["b"], params["c"]
214
+ return initial + (a - initial) * (1 - np.exp(-b * t)) ** c
215
+ dbh = [chapman_richards(t, dbh_params, initial_dbh) for t in years]
216
+ height = [chapman_richards(t, height_params, initial_height) for t in years]
217
+ if "Zanvo" in sp["allometry"]["equation"]:
218
+ if "1.938" in sp["allometry"]["equation"]:
219
+ biomass = 1.938 * (np.array(dbh) ** 2 * np.array(height)) ** 0.67628 / 1000
220
+ else:
221
+ biomass = 1.486 * (np.array(dbh) ** 2 * np.array(height)) ** 0.55864 / 1000
222
+ else:
223
+ biomass = dbh
224
+ check_complex(biomass, f"{name} biomass (allometry)")
225
+ fig3.add_trace(go.Scatter(x=years, y=biomass, mode="lines+markers", name=name, line=dict(width=2)))
226
+ fig3.update_layout(title="Total Biomass per Tree", xaxis_title="Year since planting", yaxis_title="Biomass (tonnes per tree)", hovermode="x unified", template="plotly_white")
227
+ return (fig1, fig2, fig3)
 
 
 
 
 
228
 
229
+ def create_summary(results, species_results, config, model):
230
+ total_area = sum(config["project"]["planting_schedule"].values())
 
 
 
 
231
  final_gross = results["gross_carbon"].iloc[-1]
232
  final_net = results["net_carbon"].iloc[-1]
233
+ years = len(results)
234
+ gross_carbon_per_ha = final_gross / total_area if total_area > 0 else 0
235
+ net_carbon_per_ha = final_net / total_area if total_area > 0 else 0
236
+ annual_net_per_ha = net_carbon_per_ha / years if years > 0 else 0
237
+ # Milestone years
238
+ def get_val(col, idx):
239
+ return results[col].iloc[idx] if len(results) > idx else 0
240
+ year_5_gross = get_val("gross_carbon", 4)
241
+ year_5_net = get_val("net_carbon", 4)
242
+ year_10_gross = get_val("gross_carbon", 9)
243
+ year_10_net = get_val("net_carbon", 9)
244
+ year_20_gross = get_val("gross_carbon", 19)
245
+ year_20_net = get_val("net_carbon", 19)
246
+ avg_annual_gross = final_gross / years if years > 0 else 0
247
+ avg_annual_net = final_net / years if years > 0 else 0
248
+ # Surviving trees at milestones
249
+ milestones = [5, 10, 20, 30]
250
+ survival_lines = []
251
+ for m in milestones:
252
+ if m <= years:
253
+ surv = model.calculate_total_surviving_trees(m)
254
+ surv_str = ", ".join([f"{k}: {v:,.0f}" for k, v in surv.items()])
255
+ survival_lines.append(f"Year {m} surviving trees: {surv_str}")
256
+ # Per hectare (final year)
257
+ surv_30 = model.calculate_total_surviving_trees(years)
258
+ per_ha_lines = []
259
+ for k, v in surv_30.items():
260
+ per_ha = v / total_area if total_area > 0 else 0
261
+ per_ha_lines.append(f"{k}: {per_ha:,.0f} trees/ha")
262
+ summary = (
263
+ f"Project Overview:\n----------------\nDuration: {years} years\nTotal Area Planted: {total_area:,.0f} ha\nBuffer Pool: {config['carbon']['buffer_percentage']}%\n\n"
264
+ f"Carbon Sequestration:\n-------------------\nTotal Gross Carbon (Year {years}): {final_gross:,.0f} tCO2\nTotal Net Carbon (Year {years}): {final_net:,.0f} tCO2\nAverage Annual Gross: {avg_annual_gross:,.0f} tCO2/yr\nAverage Annual Net: {avg_annual_net:,.0f} tCO2/yr\n\n"
265
+ f"Milestone Years:\n--------------\nYear 5 Gross: {year_5_gross:,.0f} tCO2\nYear 5 Net: {year_5_net:,.0f} tCO2\nYear 10 Gross: {year_10_gross:,.0f} tCO2\nYear 10 Net: {year_10_net:,.0f} tCO2\nYear 20 Gross: {year_20_gross:,.0f} tCO2\nYear 20 Net: {year_20_net:,.0f} tCO2\n\n"
266
+ "Surviving Trees (Milestones):\n----------------------------\n"
267
+ + "\n".join(survival_lines)
268
+ + "\n\nPer Hectare Surviving Trees (Final Year):\n----------------------------------------\n"
269
+ + "\n".join(per_ha_lines)
270
+ + f"\n\nPer Hectare Metrics (Year {years}):\n-----------------------------\nGross Carbon per ha: {gross_carbon_per_ha:,.0f} tCO2/ha\nNet Carbon per ha: {net_carbon_per_ha:,.0f} tCO2/ha\nAverage Annual Net per ha: {annual_net_per_ha:,.2f} tCO2/ha/yr\n"
271
+ )
272
+ return summary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
+ with gr.Blocks() as demo:
275
+ gr.Markdown("# Mangrove ER Model Dashboard\nSelect a model tab to view results for that model's base configuration.")
276
+ with gr.Tabs():
277
+ # Original Model tab with planting schedule editing
278
+ with gr.Tab("Original Model"):
279
+ gr.Markdown("## Planting Schedule (ha per year)")
280
+ year_1 = gr.Number(value=2500, label="Year 1 Area (ha)")
281
+ year_2 = gr.Number(value=2500, label="Year 2 Area (ha)")
282
+ year_3 = gr.Number(value=0, label="Year 3 Area (ha)")
283
+ year_4 = gr.Number(value=0, label="Year 4 Area (ha)")
284
+ year_5 = gr.Number(value=0, label="Year 5 Area (ha)")
285
+ update_btn = gr.Button("Update Results", variant="primary")
286
  with gr.Row():
287
+ carbon_plot = gr.Plot()
288
+ annual_plot = gr.Plot()
289
+ biomass_plot = gr.Plot()
290
+ # Now re-enable carbon_plot as well
291
+ # growth_plot = gr.Plot(label="Growth & Increment Curves")
292
+ summary_box = gr.Textbox(label="Summary", lines=12)
293
+ results_box = gr.Dataframe()
294
+ species_box = gr.Dataframe()
295
+ survival_box = gr.Dataframe(label="Surviving Trees Table")
296
+ def update_original_model(y1, y2, y3, y4, y5):
297
+ config = update_planting_schedule(MODEL_CONFIGS["Original Model"], [y1, y2, y3, y4, y5])
298
+ import tempfile
299
+ import yaml
300
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
301
+ yaml.dump(config, tmp)
302
+ tmp_path = tmp.name
303
+ model = ERModel(Path(tmp_path))
304
+ results, species_results = model.run()
305
+ plots = create_all_plots(results, species_results, config)
306
+ summary = create_summary(results, species_results, config, model)
307
+ survival_table = create_survival_table(model)
308
+ results_fmt = results.copy()
309
+ species_results_fmt = species_results.copy()
310
+ for col in results_fmt.columns:
311
+ if results_fmt[col].dtype in [float, int]:
312
+ results_fmt[col] = results_fmt[col].apply(lambda x: f"{x:,.2f}" if isinstance(x, float) else f"{x:,}")
313
+ for col in species_results_fmt.columns:
314
+ if species_results_fmt[col].dtype in [float, int]:
315
+ species_results_fmt[col] = species_results_fmt[col].apply(lambda x: f"{x:,.2f}" if isinstance(x, float) else f"{x:,}")
316
+ # Return the carbon plot (plots[0]), biomass plot (plots[2]), and annual plot (plots[1])
317
+ return plots[0], plots[2], plots[1], summary, results_fmt.head(30), species_results_fmt.head(30), survival_table.head(30)
318
+ update_btn.click(
319
+ update_original_model,
320
+ inputs=[year_1, year_2, year_3, year_4, year_5],
321
+ outputs=[carbon_plot, biomass_plot, annual_plot, summary_box, results_box, species_box, survival_box]
322
+ )
323
+ # Show initial results
324
+ c, a, b, summary, r, s, surv = run_model(MODEL_CONFIGS["Original Model"])
325
+ # Assign the carbon plot (c), biomass plot (b), and annual plot (a)
326
+ carbon_plot.value = c
327
+ biomass_plot.value = b
328
+ annual_plot.value = a
329
+ summary_box.value = summary
330
+ results_box.value = r
331
+ species_box.value = s
332
+ survival_box.value = surv
333
+ # Simple Linear tab
334
+ with gr.Tab("Simple Linear"):
335
+ gr.Markdown("## Simple Linear Model Results (base config)")
336
+ carbon_plot, annual_plot, biomass_plot, summary, results, species, survival = run_model(MODEL_CONFIGS["Simple Linear"])
337
+ growth_fig = create_growth_increment_plots(yaml.safe_load(open(MODEL_CONFIGS["Simple Linear"])), model_type="linear")
338
  with gr.Row():
339
+ carbon_plot_comp = gr.Plot(value=carbon_plot)
340
+ biomass_plot_comp = gr.Plot(value=biomass_plot)
341
+ annual_plot_comp = gr.Plot(value=annual_plot)
342
+ # gr.Plot(value=growth_fig, label="Growth & Increment Curves")
343
+ gr.Textbox(value=summary, label="Summary", lines=12)
344
+ gr.Markdown("### Results Table")
345
+ gr.Dataframe(value=results)
346
+ gr.Markdown("### Species Table")
347
+ gr.Dataframe(value=species)
348
+ gr.Markdown("### Surviving Trees Table")
349
+ gr.Dataframe(value=survival)
350
+ # Linear Plateau tab
351
+ with gr.Tab("Linear Plateau"):
352
+ gr.Markdown("## Linear Plateau Model Results (base config)")
353
+ carbon_plot, annual_plot, biomass_plot, summary, results, species, survival = run_model(MODEL_CONFIGS["Linear Plateau"])
354
+ growth_fig = create_growth_increment_plots(yaml.safe_load(open(MODEL_CONFIGS["Linear Plateau"])), model_type="linear_plateau")
355
  with gr.Row():
356
+ carbon_plot_comp = gr.Plot(value=carbon_plot)
357
+ biomass_plot_comp = gr.Plot(value=biomass_plot)
358
+ annual_plot_comp = gr.Plot(value=annual_plot)
359
+ # gr.Plot(value=growth_fig, label="Growth & Increment Curves")
360
+ gr.Textbox(value=summary, label="Summary", lines=12)
361
+ gr.Markdown("### Results Table")
362
+ gr.Dataframe(value=results)
363
+ gr.Markdown("### Species Table")
364
+ gr.Dataframe(value=species)
365
+ gr.Markdown("### Surviving Trees Table")
366
+ gr.Dataframe(value=survival)
367
+ # Declining Increment tab
368
+ with gr.Tab("Declining Increment"):
369
+ gr.Markdown("## Declining Increment Model Results (base config)")
370
+ carbon_plot, annual_plot, biomass_plot, summary, results, species, survival = run_model(MODEL_CONFIGS["Declining Increment"])
371
+ growth_fig = create_growth_increment_plots(yaml.safe_load(open(MODEL_CONFIGS["Declining Increment"])), model_type="declining_increment")
372
+ # Diagnostics for DBH, height, and biomass arrays before plotting (biomass plot)
373
+ config = yaml.safe_load(open(MODEL_CONFIGS["Declining Increment"]))
374
+ years = results["year"]
375
+ from src.er_model import ERModel
376
+ for sp in config["species"]:
377
+ name = sp["name"]
378
+ initial_dbh = sp["initial_values"]["dbh"]
379
+ initial_height = sp["initial_values"]["height"]
380
+ dbh_params = sp["declining_increment"]["dbh"]
381
+ height_params = sp["declining_increment"]["height"]
382
+ dbh = np.array([ERModel.declining_increment_growth(float(t), dbh_params, initial_dbh) for t in years])
383
+ height = np.array([ERModel.declining_increment_growth(float(t), height_params, initial_height) for t in years])
384
+ if "Zanvo" in sp["allometry"]["equation"]:
385
+ if "1.938" in sp["allometry"]["equation"]:
386
+ biomass = 1.938 * (dbh ** 2 * height) ** 0.67628 / 1000
387
+ else:
388
+ biomass = 1.486 * (dbh ** 2 * height) ** 0.55864 / 1000
389
+ else:
390
+ biomass = dbh
391
+ print(f"[DEBUG] {name} DBH: min={dbh.min()}, max={dbh.max()}, any_negative={np.any(dbh < 0)}, any_complex={np.iscomplexobj(dbh)}")
392
+ print(f"[DEBUG] {name} Height: min={height.min()}, max={height.max()}, any_negative={np.any(height < 0)}, any_complex={np.iscomplexobj(height)}")
393
+ print(f"[DEBUG] {name} Biomass: min={biomass.min()}, max={biomass.max()}, any_negative={np.any(biomass < 0)}, any_complex={np.iscomplexobj(biomass)}")
394
  with gr.Row():
395
+ carbon_plot_comp = gr.Plot(value=carbon_plot)
396
+ biomass_plot_comp = gr.Plot(value=biomass_plot)
397
+ annual_plot_comp = gr.Plot(value=annual_plot)
398
+ # gr.Plot(value=growth_fig, label="Growth & Increment Curves")
399
+ gr.Textbox(value=summary, label="Summary", lines=12)
400
+ gr.Markdown("### Results Table")
401
+ gr.Dataframe(value=results)
402
+ gr.Markdown("### Species Table")
403
+ gr.Dataframe(value=species)
404
+ gr.Markdown("### Surviving Trees Table")
405
+ gr.Dataframe(value=survival)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
  if __name__ == "__main__":
408
+ demo.launch()
 
models/original/README.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Original Model
2
+
3
+ ## Model Overview
4
+ This is the default Chapman-Richards-based mangrove growth and carbon model for the project. It uses species-specific growth curves and DBH-dependent mortality to estimate carbon sequestration and tree survival over time.
5
+
6
+ ## Growth & Mortality Logic
7
+ - **Growth:**
8
+ - Uses Chapman-Richards equations for both DBH and height for each species.
9
+ - Parameters are derived from field data and literature.
10
+ - **Mortality:**
11
+ - Applies a DBH-dependent mortality rate, where annual mortality is a function of tree size (DBH) and its increment.
12
+ - This dynamic approach reflects higher mortality when trees are small, leveling off as they mature.
13
+
14
+ ## Parameters
15
+ - See `configs/params.yaml` for all parameter values, including:
16
+ - Chapman-Richards parameters for DBH and height
17
+ - Initial values for DBH and height
18
+ - Planting density
19
+ - Mortality parameters (m_ref, DBH_ref, p)
20
+ - Allometric equations for biomass
21
+
22
+ ## References
23
+ - Chapman-Richards growth model literature
24
+ - Zanvo et al. (2023) for allometric equations
25
+ - Field data from Nigeria mangrove sites (2024)
26
+
27
+ ## Usage
28
+ - This model is available as the "Original Model" tab in the dashboard.
29
+ - To modify parameters, edit `configs/params.yaml`.
30
+ - For more details on running the dashboard, see the top-level README.
src/allometry.py CHANGED
@@ -17,6 +17,9 @@ def calculate_biomass(dbh: float, height: float, species_name: str, params: Dict
17
  Returns:
18
  Total tree biomass (above + below ground) in kg
19
  """
 
 
 
20
  # Use Zanvo et al. 2023 equations that include both DBH and height
21
  if species_name == "species_A": # Rhizophora
22
  # Total = 1.938 × (DBH² H)^0.67628
 
17
  Returns:
18
  Total tree biomass (above + below ground) in kg
19
  """
20
+ base = dbh**2 * height
21
+ if base <= 0:
22
+ print(f"[ERROR] Non-positive base in calculate_biomass: dbh={dbh}, height={height}, base={base}, species={species_name}")
23
  # Use Zanvo et al. 2023 equations that include both DBH and height
24
  if species_name == "species_A": # Rhizophora
25
  # Total = 1.938 × (DBH² H)^0.67628
src/current_er_model (1).ipynb ADDED
The diff for this file is too large to render. See raw diff
 
src/er_model.py CHANGED
@@ -8,6 +8,7 @@ from typing import Dict, List, Optional, Tuple
8
  import numpy as np
9
  import pandas as pd
10
  import yaml
 
11
 
12
  from .allometry import calculate_biomass
13
  from .metrics import calculate_carbon
@@ -18,10 +19,18 @@ class Species:
18
  """Species-specific parameters for growth and carbon calculations."""
19
  name: str
20
  planting_density: float
21
- mortality_rates: Dict[str, float]
22
- chapman_richards: Dict[str, Dict[str, float]] # Nested dict for dbh and height parameters
23
- allometry: Dict[str, float]
24
- initial_values: Dict[str, float]
 
 
 
 
 
 
 
 
25
 
26
 
27
  @dataclass
@@ -76,32 +85,73 @@ class ERModel:
76
  self.species_results: Optional[pd.DataFrame] = None
77
  self.scenario_results: Optional[pd.DataFrame] = None
78
 
79
- def calculate_cohort_surviving_trees(self, planting_year: int, current_year: int, initial_trees: float, mortality_rates: Dict[str, float]) -> float:
 
 
80
  """
81
  Calculate surviving trees for a cohort planted in planting_year, in current_year.
82
- Applies cumulative mortality for the cohort's age.
 
83
  """
 
 
84
  age = current_year - planting_year + 1
85
  if age < 1:
86
  return 0
87
- surviving = initial_trees
88
- for y in range(1, age + 1):
89
- rate = mortality_rates.get(f"year_{y}", mortality_rates["subsequent"]) / 100.0
90
- surviving *= (1 - rate)
91
- return surviving
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
  def calculate_total_surviving_trees(self, year: int) -> Dict[str, float]:
94
  """
95
  Calculate total surviving trees for each species in a given year, summing across all cohorts.
96
  Returns a dict: {species_name: total_surviving_trees}
97
  """
 
98
  totals = {}
99
  for species in self.species:
100
  total = 0
101
  for planting_year, area in self.project.planting_schedule.items():
102
  py = int(planting_year.split("_")[1])
103
  initial_trees = species.planting_density * area
104
- total += self.calculate_cohort_surviving_trees(py, year, initial_trees, species.mortality_rates)
 
 
105
  totals[species.name] = total
106
  return totals
107
 
@@ -116,21 +166,43 @@ class ERModel:
116
  total += area
117
  return total
118
 
119
- def chapman_richards_growth(self, age: float, params: Dict[str, float], initial_value: float) -> float:
120
- """
121
- Calculate growth using Chapman-Richards growth equation.
122
-
123
- Args:
124
- age: Tree age in years
125
- params: Dictionary with a, b, c parameters
126
- initial_value: Initial value (DBH or height)
127
-
128
- Returns:
129
- Current size (DBH in cm or height in m)
130
- """
131
  a, b, c = params["a"], params["b"], params["c"]
132
  return initial_value + (a - initial_value) * (1 - np.exp(-b * age)) ** c
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  def calculate_carbon_for_species(self, species: Species, age: int, area: float) -> float:
135
  """
136
  Calculate carbon sequestration for a single species and age.
@@ -141,23 +213,15 @@ class ERModel:
141
  Returns:
142
  Carbon sequestration in tCO2
143
  """
144
- # Calculate surviving trees using cumulative mortality for the given age
145
  initial_trees = species.planting_density * area
146
- surviving = initial_trees
147
- for y in range(1, age + 1):
148
- rate = species.mortality_rates.get(f"year_{y}", species.mortality_rates["subsequent"]) / 100.0
149
- surviving *= (1 - rate)
150
- # Calculate DBH and height using Chapman-Richards
151
- dbh = self.chapman_richards_growth(
152
- age,
153
- species.chapman_richards["dbh"],
154
- species.initial_values["dbh"]
155
- )
156
- height = self.chapman_richards_growth(
157
- age,
158
- species.chapman_richards["height"],
159
- species.initial_values["height"]
160
- )
161
  # Calculate biomass using both DBH and height
162
  biomass = calculate_biomass(dbh, height, species.name, species.allometry)
163
  # Convert to carbon
@@ -168,66 +232,11 @@ class ERModel:
168
  )
169
  return carbon
170
 
171
- def run_scenario(self, area: float, dbh_range: List[float], height_range: List[float],
172
- growth_rate_factor: float) -> pd.Series:
173
- """
174
- Run a scenario with modified parameters.
175
-
176
- Args:
177
- area: Area to plant in hectares
178
- dbh_range: [min_dbh, max_dbh] in cm
179
- height_range: [min_height, max_height] in m
180
- growth_rate_factor: Factor to multiply growth rate by
181
-
182
- Returns:
183
- Series with carbon sequestration at milestone years
184
- """
185
- # Create modified species for scenario
186
- scenario_species = []
187
- for sp in self.species:
188
- mod_sp = Species(
189
- name=sp.name,
190
- planting_density=sp.planting_density,
191
- mortality_rates=sp.mortality_rates.copy(),
192
- chapman_richards={
193
- "dbh": {
194
- "a": np.mean(dbh_range),
195
- "b": sp.chapman_richards["dbh"]["b"] * growth_rate_factor,
196
- "c": sp.chapman_richards["dbh"]["c"]
197
- },
198
- "height": {
199
- "a": np.mean(height_range),
200
- "b": sp.chapman_richards["height"]["b"] * growth_rate_factor,
201
- "c": sp.chapman_richards["height"]["c"]
202
- }
203
- },
204
- allometry=sp.allometry.copy(),
205
- initial_values=sp.initial_values.copy()
206
- )
207
- scenario_species.append(mod_sp)
208
-
209
- # Calculate carbon at milestone years
210
- milestones = [5, 10, 15, 30]
211
- carbon_values = []
212
-
213
- for year in milestones:
214
- total_carbon = sum(
215
- self.calculate_carbon_for_species(sp, year, area)
216
- for sp in scenario_species
217
- )
218
- carbon_values.append(total_carbon)
219
-
220
- return pd.Series(
221
- carbon_values,
222
- index=[f"Year {y} tCO2" for y in milestones]
223
- )
224
-
225
- def run(self) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
226
  """
227
  Execute the full ER calculation pipeline.
228
-
229
  Returns:
230
- Tuple of (yearly results DataFrame, species results DataFrame, scenario results DataFrame)
231
  """
232
  years = range(1, self.project.duration_years + 1)
233
  results = []
@@ -245,22 +254,14 @@ class ERModel:
245
  for planting_year, area in self.project.planting_schedule.items():
246
  py = int(planting_year.split("_")[1])
247
  initial_trees = species.planting_density * area
248
- age = year - py + 1
249
- if age < 1:
250
- continue
251
- # Calculate surviving trees for this cohort
252
- surviving = self.calculate_cohort_surviving_trees(py, year, initial_trees, species.mortality_rates)
253
- # Calculate DBH and height using Chapman-Richards
254
- dbh = self.chapman_richards_growth(
255
- age,
256
- species.chapman_richards["dbh"],
257
- species.initial_values["dbh"]
258
- )
259
- height = self.chapman_richards_growth(
260
- age,
261
- species.chapman_richards["height"],
262
- species.initial_values["height"]
263
- )
264
  # Calculate biomass using both DBH and height
265
  biomass = calculate_biomass(dbh, height, species.name, species.allometry)
266
  # Convert to carbon
@@ -298,58 +299,7 @@ class ERModel:
298
  self.results = pd.DataFrame(results)
299
  self.species_results = pd.DataFrame(species_results)
300
 
301
- # Run scenarios
302
- scenarios = []
303
- dbh_min, dbh_max = self.scenarios["dbh_range"]
304
- height_min, height_max = self.scenarios["height_range"]
305
- area = self.scenarios["area"]
306
- growth_factor = self.scenarios["growth_rate_factor"]
307
-
308
- # Base scenario
309
- base_scenario = self.run_scenario(
310
- area, [dbh_min, dbh_max], [height_min, height_max], growth_factor
311
- )
312
- scenarios.append({
313
- "Scenario": "Base",
314
- "DBH Range": f"{dbh_min:.1f}-{dbh_max:.1f}",
315
- "Height Range": f"{height_min:.1f}-{height_max:.1f}",
316
- "Growth Rate": f"{growth_factor:.1f}x",
317
- **base_scenario
318
- })
319
-
320
- # Low growth scenario
321
- low_scenario = self.run_scenario(
322
- area,
323
- [dbh_min * 0.5, dbh_max * 0.5],
324
- [height_min * 0.5, height_max * 0.5],
325
- growth_factor
326
- )
327
- scenarios.append({
328
- "Scenario": "Low Growth",
329
- "DBH Range": f"{dbh_min*0.5:.1f}-{dbh_max*0.5:.1f}",
330
- "Height Range": f"{height_min*0.5:.1f}-{height_max*0.5:.1f}",
331
- "Growth Rate": f"{growth_factor:.1f}x",
332
- **low_scenario
333
- })
334
-
335
- # High growth scenario
336
- high_scenario = self.run_scenario(
337
- area,
338
- [dbh_min * 1.5, dbh_max * 1.5],
339
- [height_min * 1.5, height_max * 1.5],
340
- growth_factor
341
- )
342
- scenarios.append({
343
- "Scenario": "High Growth",
344
- "DBH Range": f"{dbh_min*1.5:.1f}-{dbh_max*1.5:.1f}",
345
- "Height Range": f"{height_min*1.5:.1f}-{height_max*1.5:.1f}",
346
- "Growth Rate": f"{growth_factor:.1f}x",
347
- **high_scenario
348
- })
349
-
350
- self.scenario_results = pd.DataFrame(scenarios)
351
-
352
- return self.results, self.species_results, self.scenario_results
353
 
354
  def save_results(self, output_path: Path) -> None:
355
  """
@@ -360,4 +310,56 @@ class ERModel:
360
  """
361
  if self.results is None:
362
  raise ValueError("No results available. Run the model first.")
363
- self.results.to_csv(output_path, index=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  import numpy as np
9
  import pandas as pd
10
  import yaml
11
+ import warnings
12
 
13
  from .allometry import calculate_biomass
14
  from .metrics import calculate_carbon
 
19
  """Species-specific parameters for growth and carbon calculations."""
20
  name: str
21
  planting_density: float
22
+ # Old style
23
+ mortality_rates: Optional[Dict[str, float]] = None
24
+ # New style
25
+ m_ref: Optional[float] = None
26
+ DBH_ref: Optional[float] = None
27
+ p: Optional[float] = None
28
+ chapman_richards: Dict[str, Dict[str, float]] = None
29
+ allometry: Dict[str, float] = None
30
+ initial_values: Dict[str, float] = None
31
+ linear: Optional[Dict[str, Dict[str, float]]] = None
32
+ linear_plateau: Optional[Dict[str, Dict[str, float]]] = None
33
+ declining_increment: Optional[Dict[str, Dict[str, float]]] = None
34
 
35
 
36
  @dataclass
 
85
  self.species_results: Optional[pd.DataFrame] = None
86
  self.scenario_results: Optional[pd.DataFrame] = None
87
 
88
+ self.growth_model = config.get('growth_model', 'chapman_richards')
89
+
90
+ def calculate_cohort_surviving_trees(self, planting_year: int, current_year: int, initial_trees: float, species: Species, plateau_density: Optional[float] = None, growth_model: str = None) -> float:
91
  """
92
  Calculate surviving trees for a cohort planted in planting_year, in current_year.
93
+ Uses either per-year mortality (from config) or DBH-dependent mortality.
94
+ growth_model: which growth model to use for DBH (e.g., 'linear', 'linear_plateau', etc.)
95
  """
96
+ if growth_model is None:
97
+ growth_model = getattr(self, 'growth_model', 'chapman_richards')
98
  age = current_year - planting_year + 1
99
  if age < 1:
100
  return 0
101
+ if initial_trees == 0:
102
+ # Suppress debug output for zero-initial-trees cohorts
103
+ return 0
104
+ N_live = initial_trees
105
+ for year in range(1, age + 1):
106
+ debug_info = {
107
+ 'planting_year': planting_year,
108
+ 'current_year': current_year,
109
+ 'cohort_age': year,
110
+ 'initial_trees': initial_trees,
111
+ 'N_live_before': N_live
112
+ }
113
+ if species.mortality_rates is not None:
114
+ year_key = f"year_{year}"
115
+ if year_key in species.mortality_rates:
116
+ mort_rate = species.mortality_rates[year_key]
117
+ else:
118
+ mort_rate = species.mortality_rates.get("subsequent", 0)
119
+ print(f"[DEBUG] Year key '{year_key}' not found in mortality_rates for {species.name}. Using 'subsequent' or 0. Available keys: {list(species.mortality_rates.keys())}")
120
+ m = mort_rate / 100.0
121
+ debug_info['mortality_logic'] = 'annual'
122
+ debug_info['mortality_rate_percent'] = mort_rate
123
+ else:
124
+ growth_func, dbh_params = self.get_growth_function_and_params(species, growth_model, 'dbh')
125
+ dbh = growth_func(year, dbh_params, species.initial_values["dbh"])
126
+ dbh = max(dbh, 1.0)
127
+ m_ref = species.m_ref if species.m_ref is not None else 0.16
128
+ DBH_ref = species.DBH_ref if species.DBH_ref is not None else 9.0
129
+ p = species.p if species.p is not None else 1.493
130
+ m = m_ref * (DBH_ref / dbh) ** p
131
+ m = min(max(m, 0), 0.99)
132
+ debug_info['mortality_logic'] = 'dbh-dependent'
133
+ debug_info['mortality_rate_percent'] = m * 100
134
+ debug_info['dbh'] = dbh
135
+ N_live = N_live * (1 - m)
136
+ debug_info['N_live_after'] = N_live
137
+ print(f"[DEBUG] {species.name} | PlantingYear: {planting_year} | Year: {current_year} | CohortAge: {year} | MortalityLogic: {debug_info['mortality_logic']} | MortalityRate(%): {debug_info['mortality_rate_percent']} | N_live_before: {debug_info['N_live_before']} | N_live_after: {debug_info['N_live_after']}")
138
+ return N_live
139
 
140
  def calculate_total_surviving_trees(self, year: int) -> Dict[str, float]:
141
  """
142
  Calculate total surviving trees for each species in a given year, summing across all cohorts.
143
  Returns a dict: {species_name: total_surviving_trees}
144
  """
145
+ growth_model = getattr(self, 'growth_model', 'chapman_richards')
146
  totals = {}
147
  for species in self.species:
148
  total = 0
149
  for planting_year, area in self.project.planting_schedule.items():
150
  py = int(planting_year.split("_")[1])
151
  initial_trees = species.planting_density * area
152
+ # Use plateau_density as the year-5 value for this cohort
153
+ plateau_density = species.planting_density * area if 5 <= (year - py + 1) else None
154
+ total += self.calculate_cohort_surviving_trees(py, year, initial_trees, species, plateau_density, growth_model)
155
  totals[species.name] = total
156
  return totals
157
 
 
166
  total += area
167
  return total
168
 
169
+ @staticmethod
170
+ def chapman_richards_growth(age: float, params: Dict[str, float], initial_value: float) -> float:
 
 
 
 
 
 
 
 
 
 
171
  a, b, c = params["a"], params["b"], params["c"]
172
  return initial_value + (a - initial_value) * (1 - np.exp(-b * age)) ** c
173
 
174
+ @staticmethod
175
+ def linear_growth(age: float, params: Dict[str, float], initial_value: float) -> float:
176
+ r = params["r"]
177
+ return initial_value + r * age
178
+
179
+ @staticmethod
180
+ def linear_plateau_growth(age: float, params: Dict[str, float], initial_value: float) -> float:
181
+ r = params["r"]
182
+ T_p = params["T_p"]
183
+ a = params["a"]
184
+ if age <= T_p:
185
+ return initial_value + r * age
186
+ else:
187
+ return initial_value + a
188
+
189
+ @staticmethod
190
+ def declining_increment_growth(age: float, params: Dict[str, float], initial_value: float) -> float:
191
+ r0 = params["r0"]
192
+ T_m = params["T_m"]
193
+ # Accumulate annual increments, never negative
194
+ total = initial_value
195
+ for i in range(1, int(np.floor(age)) + 1):
196
+ increment = r0 * max(0, 1 - (i - 1) / T_m)
197
+ total += increment
198
+ # If age is fractional, add partial increment for the last year
199
+ frac = age - int(np.floor(age))
200
+ if frac > 0:
201
+ i = int(np.floor(age)) + 1
202
+ increment = r0 * max(0, 1 - (i - 1) / T_m)
203
+ total += frac * increment
204
+ return total
205
+
206
  def calculate_carbon_for_species(self, species: Species, age: int, area: float) -> float:
207
  """
208
  Calculate carbon sequestration for a single species and age.
 
213
  Returns:
214
  Carbon sequestration in tCO2
215
  """
216
+ # Calculate surviving trees using DBH-dependent mortality
217
  initial_trees = species.planting_density * area
218
+ plateau_density = species.planting_density * area if age >= 5 else None
219
+ surviving = self.calculate_cohort_surviving_trees(1, age, initial_trees, species, plateau_density, self.growth_model)
220
+ # Calculate DBH and height using the selected growth model
221
+ dbh_func, dbh_params = self.get_growth_function_and_params(species, self.growth_model, 'dbh')
222
+ height_func, height_params = self.get_growth_function_and_params(species, self.growth_model, 'height')
223
+ dbh = dbh_func(age, dbh_params, species.initial_values["dbh"])
224
+ height = height_func(age, height_params, species.initial_values["height"])
 
 
 
 
 
 
 
 
225
  # Calculate biomass using both DBH and height
226
  biomass = calculate_biomass(dbh, height, species.name, species.allometry)
227
  # Convert to carbon
 
232
  )
233
  return carbon
234
 
235
+ def run(self) -> Tuple[pd.DataFrame, pd.DataFrame]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  """
237
  Execute the full ER calculation pipeline.
 
238
  Returns:
239
+ Tuple of (yearly results DataFrame, species results DataFrame)
240
  """
241
  years = range(1, self.project.duration_years + 1)
242
  results = []
 
254
  for planting_year, area in self.project.planting_schedule.items():
255
  py = int(planting_year.split("_")[1])
256
  initial_trees = species.planting_density * area
257
+ # Use plateau_density as the year-5 value for this cohort
258
+ plateau_density = species.planting_density * area if 5 <= (year - py + 1) else None
259
+ surviving = self.calculate_cohort_surviving_trees(py, year, initial_trees, species, plateau_density, self.growth_model)
260
+ # Calculate DBH and height using the selected growth model
261
+ dbh_func, dbh_params = self.get_growth_function_and_params(species, self.growth_model, 'dbh')
262
+ height_func, height_params = self.get_growth_function_and_params(species, self.growth_model, 'height')
263
+ dbh = dbh_func(year, dbh_params, species.initial_values["dbh"])
264
+ height = height_func(year, height_params, species.initial_values["height"])
 
 
 
 
 
 
 
 
265
  # Calculate biomass using both DBH and height
266
  biomass = calculate_biomass(dbh, height, species.name, species.allometry)
267
  # Convert to carbon
 
299
  self.results = pd.DataFrame(results)
300
  self.species_results = pd.DataFrame(species_results)
301
 
302
+ return self.results, self.species_results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
 
304
  def save_results(self, output_path: Path) -> None:
305
  """
 
310
  """
311
  if self.results is None:
312
  raise ValueError("No results available. Run the model first.")
313
+ self.results.to_csv(output_path, index=False)
314
+
315
+ def get_growth_function_and_params(self, species, growth_model, dim):
316
+ """
317
+ Returns the correct growth function and parameter dict for the given species and dimension (dbh or height).
318
+ """
319
+ if growth_model == "linear":
320
+ func = ERModel.linear_growth
321
+ params = species.linear[dim]
322
+ elif growth_model == "linear_plateau":
323
+ func = ERModel.linear_plateau_growth
324
+ params = species.linear_plateau[dim]
325
+ elif growth_model == "declining_increment":
326
+ func = ERModel.declining_increment_growth
327
+ params = species.declining_increment[dim]
328
+ else:
329
+ func = ERModel.chapman_richards_growth
330
+ params = species.chapman_richards[dim]
331
+ return func, params
332
+
333
+ # --- Parameter sweep/test for plausible survival curves ---
334
+ def test_dbh_mortality_sweep():
335
+ import matplotlib.pyplot as plt
336
+ import numpy as np
337
+ m_refs = [0.01, 0.05, 0.1, 0.16]
338
+ ps = [1.0, 1.5, 2.0]
339
+ DBH_ref = 9.0
340
+ years = np.arange(1, 31)
341
+ initial_trees = 1000
342
+ results = {}
343
+ for m_ref in m_refs:
344
+ for p in ps:
345
+ N_live = initial_trees
346
+ N_lives = []
347
+ for year in years:
348
+ dbh = 1.0 + (year - 1) * 0.5 # simple linear DBH growth for test
349
+ dbh = max(dbh, 1.0)
350
+ m = m_ref * (DBH_ref / dbh) ** p
351
+ m = min(max(m, 0), 0.99)
352
+ N_live = N_live * (1 - m)
353
+ N_lives.append(N_live)
354
+ results[(m_ref, p)] = N_lives
355
+ plt.figure(figsize=(10,6))
356
+ for (m_ref, p), N_lives in results.items():
357
+ plt.plot(years, N_lives, label=f"m_ref={m_ref}, p={p}")
358
+ plt.xlabel("Year")
359
+ plt.ylabel("Surviving Trees")
360
+ plt.title("DBH-dependent Mortality Parameter Sweep")
361
+ plt.legend()
362
+ plt.grid(True)
363
+ plt.show()
364
+
365
+ # To run the test, call test_dbh_mortality_sweep() from __main__ or a notebook.
tests/test_er_model.py CHANGED
@@ -20,20 +20,18 @@ def sample_config():
20
  {
21
  "name": "Test Species",
22
  "planting_density": 1000,
23
- "mortality_rates": {
24
- "year_1": 10,
25
- "year_2": 5,
26
- "subsequent": 2
27
- },
28
  "chapman_richards": {
29
- "a": 30,
30
- "b": 0.15,
31
- "c": 1.5
32
  },
33
  "allometry": {
34
  "biomass_equation": "0.2 * (dbh ** 2.4)",
35
  "root_shoot_ratio": 0.4
36
- }
 
37
  }
38
  ],
39
  "project": {
@@ -71,18 +69,16 @@ def test_model_initialization(config_file):
71
 
72
 
73
  def test_surviving_trees_calculation(config_file):
74
- """Test tree survival calculations."""
75
  model = ERModel(config_file)
76
  species = model.species[0]
77
-
78
- # Test first year mortality
79
- surviving = model.calculate_surviving_trees(1000, 1, species.mortality_rates)
80
- assert surviving == 900 # 1000 * (1 - 0.10)
81
-
82
- # Test cumulative mortality
83
- surviving = model.calculate_surviving_trees(1000, 2, species.mortality_rates)
84
- expected = 1000 * (1 - 0.10) * (1 - 0.05)
85
- assert np.isclose(surviving, expected)
86
 
87
 
88
  def test_chapman_richards_growth(config_file):
@@ -132,4 +128,42 @@ def test_full_pipeline(config_file):
132
  assert "year" in results.columns
133
  assert "gross_carbon" in results.columns
134
  assert "net_carbon" in results.columns
135
- assert all(results["net_carbon"] <= results["gross_carbon"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  {
21
  "name": "Test Species",
22
  "planting_density": 1000,
23
+ "m_ref": 0.16,
24
+ "DBH_ref": 9.0,
25
+ "p": 1.493,
 
 
26
  "chapman_richards": {
27
+ "dbh": {"a": 30, "b": 0.15, "c": 1.5},
28
+ "height": {"a": 10, "b": 0.05, "c": 1.2}
 
29
  },
30
  "allometry": {
31
  "biomass_equation": "0.2 * (dbh ** 2.4)",
32
  "root_shoot_ratio": 0.4
33
+ },
34
+ "initial_values": {"dbh": 1.0, "height": 0.5}
35
  }
36
  ],
37
  "project": {
 
69
 
70
 
71
  def test_surviving_trees_calculation(config_file):
72
+ """Test tree survival calculations with DBH-dependent mortality."""
73
  model = ERModel(config_file)
74
  species = model.species[0]
75
+ # Test first year mortality (should use DBH-dependent formula)
76
+ dbh = 9.0
77
+ m = species.m_ref * (species.DBH_ref / dbh) ** species.p
78
+ initial_trees = 1000
79
+ N_live = initial_trees * (1 - m)
80
+ expected = initial_trees * (1 - 0.16)
81
+ assert np.isclose(N_live, expected, atol=1e-3)
 
 
82
 
83
 
84
  def test_chapman_richards_growth(config_file):
 
128
  assert "year" in results.columns
129
  assert "gross_carbon" in results.columns
130
  assert "net_carbon" in results.columns
131
+ assert all(results["net_carbon"] <= results["gross_carbon"])
132
+
133
+
134
+ def test_dbh_dependent_mortality():
135
+ """Test that DBH-dependent mortality matches expected at DBH=9 and year-5 plateau."""
136
+ from src.er_model import ERModel, Species
137
+ # Minimal species config
138
+ species = Species(
139
+ name="Test Species",
140
+ planting_density=1000,
141
+ m_ref=0.16,
142
+ DBH_ref=9.0,
143
+ p=1.493,
144
+ chapman_richards={
145
+ "dbh": {"a": 9.0, "b": 0.5, "c": 1.0},
146
+ "height": {"a": 5.0, "b": 0.2, "c": 1.0}
147
+ },
148
+ allometry={"equation": "0.2 * (dbh ** 2.4)", "root_shoot_ratio": 0.4},
149
+ initial_values={"dbh": 9.0, "height": 5.0}
150
+ )
151
+ # At DBH = 9, mortality = 0.16
152
+ dbh = 9.0
153
+ m = species.m_ref * (species.DBH_ref / dbh) ** species.p
154
+ assert np.isclose(m, 0.16, atol=1e-3)
155
+
156
+ # Simulate 5 years, enforce plateau at year 5
157
+ initial_trees = 1000
158
+ plateau_density = 2000
159
+ class DummyModel:
160
+ def chapman_richards_growth(self, age, params, initial):
161
+ return dbh # Always 9 for this test
162
+ model = DummyModel()
163
+ N_live = initial_trees
164
+ for y in range(1, 6):
165
+ m = species.m_ref * (species.DBH_ref / dbh) ** species.p
166
+ N_live = N_live * (1 - m)
167
+ if y == 5:
168
+ N_live = plateau_density
169
+ assert N_live == plateau_density