malcolmSQ commited on
Commit ·
2f8199f
1
Parent(s): 316cc25
chore: UI/UX cleanup, grouped accordions, tooltips as labels, compact mortality layout, reset button
Browse files- .cursor/rules/agent.md +5 -0
- dashboard/plots/__init__.py +19 -16
- dashboard/tables/__init__.py +2 -1
- {src → er_model_core}/__init__.py +3 -3
- {src → er_model_core}/allometry.py +0 -0
- er_model_core/config_loader.py +46 -0
- {src → er_model_core}/er_model.py +60 -123
- {src → er_model_core}/growth_models/chapman_richards.py +0 -0
- {src → er_model_core}/growth_models/declining_increment.py +0 -0
- {src → er_model_core}/growth_models/linear.py +0 -0
- {src → er_model_core}/metrics.py +0 -0
- er_model_core/types.py +36 -0
- requirements.txt +6 -3
- scripts/run_pipeline.py +3 -4
- setup.py +2 -1
- tests/test_er_model.py +76 -6
.cursor/rules/agent.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
The agent should only request explicit user permission to complete a suggested code change if the change is large, potentially destructive, or could have significant side effects. For most routine, incremental, or clearly safe changes, the agent should proceed and apply the change without asking for additional user approval.
|
| 2 |
+
|
| 3 |
+
Summary of intent:
|
| 4 |
+
- Small, routine, or obviously safe changes: Apply directly, no need to ask.
|
| 5 |
+
- Large, sweeping, or potentially risky changes: Ask for user confirmation before proceeding.
|
dashboard/plots/__init__.py
CHANGED
|
@@ -2,14 +2,17 @@
|
|
| 2 |
Plotting functions for the Mangrove ER Dashboard.
|
| 3 |
"""
|
| 4 |
# Imports will be updated as needed when moving functions from app.py
|
| 5 |
-
import plotly.
|
| 6 |
from plotly.subplots import make_subplots
|
| 7 |
import numpy as np
|
| 8 |
import warnings
|
| 9 |
import traceback
|
| 10 |
-
from
|
| 11 |
-
from
|
| 12 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
# Helper for checking complex numbers (copied from app.py)
|
| 15 |
def check_complex(arr, label):
|
|
@@ -24,12 +27,12 @@ def create_growth_increment_plots(config, model_type=None):
|
|
| 24 |
model_type: 'chapman_richards', 'linear', 'linear_plateau', or 'declining_increment'
|
| 25 |
"""
|
| 26 |
if model_type is None:
|
| 27 |
-
model_type = config.get('growth_model', '
|
| 28 |
N = config["project"]["duration_years"]
|
| 29 |
ages = np.arange(0, N + 1) # 0 to N
|
| 30 |
ages_inc = np.arange(1, N + 1) # 1 to N
|
| 31 |
fig = make_subplots(rows=2, cols=2, subplot_titles=("DBH Growth", "HEIGHT Growth", "DBH Annual Increment", "HEIGHT Annual Increment"))
|
| 32 |
-
from
|
| 33 |
SPECIES_DISPLAY_NAMES = {
|
| 34 |
'species_A': 'Rhizophora spp.',
|
| 35 |
'species_B': 'Avicennia germinans'
|
|
@@ -40,21 +43,21 @@ def create_growth_increment_plots(config, model_type=None):
|
|
| 40 |
initial_dbh = sp["initial_values"]["dbh"]
|
| 41 |
initial_height = sp["initial_values"]["height"]
|
| 42 |
if model_type == "linear":
|
| 43 |
-
dbh = [
|
| 44 |
-
height = [
|
| 45 |
elif model_type == "linear_plateau":
|
| 46 |
-
dbh = [
|
| 47 |
-
height = [
|
| 48 |
elif model_type == "declining_increment":
|
| 49 |
if use_continuous:
|
| 50 |
-
dbh = [
|
| 51 |
-
height = [
|
| 52 |
else:
|
| 53 |
-
dbh = [
|
| 54 |
-
height = [
|
| 55 |
else:
|
| 56 |
-
dbh = [
|
| 57 |
-
height = [
|
| 58 |
dbh = np.array(dbh)
|
| 59 |
height = np.array(height)
|
| 60 |
check_complex(dbh, f"{name} DBH (growth)")
|
|
@@ -109,7 +112,7 @@ def create_all_plots(results, species_results, config):
|
|
| 109 |
years = results["year"]
|
| 110 |
fig3 = go.Figure()
|
| 111 |
import tempfile, yaml
|
| 112 |
-
from
|
| 113 |
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
|
| 114 |
yaml.dump(config, tmp)
|
| 115 |
tmp_path = tmp.name
|
|
|
|
| 2 |
Plotting functions for the Mangrove ER Dashboard.
|
| 3 |
"""
|
| 4 |
# Imports will be updated as needed when moving functions from app.py
|
| 5 |
+
import plotly.graph_objects as go
|
| 6 |
from plotly.subplots import make_subplots
|
| 7 |
import numpy as np
|
| 8 |
import warnings
|
| 9 |
import traceback
|
| 10 |
+
from er_model_core.allometry import calculate_biomass
|
| 11 |
+
from er_model_core.er_model import ERModel
|
| 12 |
from pathlib import Path
|
| 13 |
+
from er_model_core.growth_models.chapman_richards import chapman_richards_growth
|
| 14 |
+
from er_model_core.growth_models.linear import linear_growth, linear_plateau_growth
|
| 15 |
+
from er_model_core.growth_models.declining_increment import declining_increment_growth, continuous_declining_increment_growth
|
| 16 |
|
| 17 |
# Helper for checking complex numbers (copied from app.py)
|
| 18 |
def check_complex(arr, label):
|
|
|
|
| 27 |
model_type: 'chapman_richards', 'linear', 'linear_plateau', or 'declining_increment'
|
| 28 |
"""
|
| 29 |
if model_type is None:
|
| 30 |
+
model_type = config.get('growth_model', 'Unknown Model')
|
| 31 |
N = config["project"]["duration_years"]
|
| 32 |
ages = np.arange(0, N + 1) # 0 to N
|
| 33 |
ages_inc = np.arange(1, N + 1) # 1 to N
|
| 34 |
fig = make_subplots(rows=2, cols=2, subplot_titles=("DBH Growth", "HEIGHT Growth", "DBH Annual Increment", "HEIGHT Annual Increment"))
|
| 35 |
+
from er_model_core.er_model import ERModel
|
| 36 |
SPECIES_DISPLAY_NAMES = {
|
| 37 |
'species_A': 'Rhizophora spp.',
|
| 38 |
'species_B': 'Avicennia germinans'
|
|
|
|
| 43 |
initial_dbh = sp["initial_values"]["dbh"]
|
| 44 |
initial_height = sp["initial_values"]["height"]
|
| 45 |
if model_type == "linear":
|
| 46 |
+
dbh = [linear_growth(t, sp["linear"]["dbh"], initial_dbh) for t in ages]
|
| 47 |
+
height = [linear_growth(t, sp["linear"]["height"], initial_height) for t in ages]
|
| 48 |
elif model_type == "linear_plateau":
|
| 49 |
+
dbh = [linear_plateau_growth(t, sp["linear_plateau"]["dbh"], initial_dbh) for t in ages]
|
| 50 |
+
height = [linear_plateau_growth(t, sp["linear_plateau"]["height"], initial_height) for t in ages]
|
| 51 |
elif model_type == "declining_increment":
|
| 52 |
if use_continuous:
|
| 53 |
+
dbh = [continuous_declining_increment_growth(t, sp["declining_increment"]["dbh"], initial_dbh) for t in ages]
|
| 54 |
+
height = [continuous_declining_increment_growth(t, sp["declining_increment"]["height"], initial_height) for t in ages]
|
| 55 |
else:
|
| 56 |
+
dbh = [declining_increment_growth(t, sp["declining_increment"]["dbh"], initial_dbh) for t in ages]
|
| 57 |
+
height = [declining_increment_growth(t, sp["declining_increment"]["height"], initial_height) for t in ages]
|
| 58 |
else:
|
| 59 |
+
dbh = [chapman_richards_growth(t, sp["chapman_richards"]["dbh"], initial_dbh) for t in ages]
|
| 60 |
+
height = [chapman_richards_growth(t, sp["chapman_richards"]["height"], initial_height) for t in ages]
|
| 61 |
dbh = np.array(dbh)
|
| 62 |
height = np.array(height)
|
| 63 |
check_complex(dbh, f"{name} DBH (growth)")
|
|
|
|
| 112 |
years = results["year"]
|
| 113 |
fig3 = go.Figure()
|
| 114 |
import tempfile, yaml
|
| 115 |
+
from er_model_core.er_model import ERModel
|
| 116 |
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
|
| 117 |
yaml.dump(config, tmp)
|
| 118 |
tmp_path = tmp.name
|
dashboard/tables/__init__.py
CHANGED
|
@@ -3,7 +3,8 @@ Table formatting functions for the Mangrove ER Dashboard.
|
|
| 3 |
"""
|
| 4 |
# Imports will be updated as needed when moving functions from app.py
|
| 5 |
import pandas as pd
|
| 6 |
-
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
import yaml
|
| 9 |
|
|
|
|
| 3 |
"""
|
| 4 |
# Imports will be updated as needed when moving functions from app.py
|
| 5 |
import pandas as pd
|
| 6 |
+
import numpy as np
|
| 7 |
+
from er_model_core.er_model import ERModel
|
| 8 |
from pathlib import Path
|
| 9 |
import yaml
|
| 10 |
|
{src → er_model_core}/__init__.py
RENAMED
|
@@ -2,9 +2,9 @@
|
|
| 2 |
ER Model package for calculating carbon sequestration in mangrove projects.
|
| 3 |
"""
|
| 4 |
|
| 5 |
-
from .allometry import calculate_biomass
|
| 6 |
-
from .er_model import ERModel, Species, ProjectConfig, CarbonConfig
|
| 7 |
-
from .metrics import calculate_carbon
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"ERModel",
|
|
|
|
| 2 |
ER Model package for calculating carbon sequestration in mangrove projects.
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
from er_model_core.allometry import calculate_biomass
|
| 6 |
+
from er_model_core.er_model import ERModel, Species, ProjectConfig, CarbonConfig
|
| 7 |
+
from er_model_core.metrics import calculate_carbon
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"ERModel",
|
{src → er_model_core}/allometry.py
RENAMED
|
File without changes
|
er_model_core/config_loader.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration loading and parsing for the ER Model.
|
| 3 |
+
"""
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Dict, List, Tuple, Optional
|
| 7 |
+
import yaml
|
| 8 |
+
|
| 9 |
+
# Assuming these dataclasses are defined in er_model.er_model or a shared types module.
|
| 10 |
+
# For now, let's duplicate them here or import them if they are easily accessible.
|
| 11 |
+
# Ideally, they'd be in a place like 'er_model.types'
|
| 12 |
+
# For this refactor, assuming they are in er_model.er_model and we'll import them.
|
| 13 |
+
from er_model_core.types import Species, ProjectConfig, CarbonConfig
|
| 14 |
+
|
| 15 |
+
def load_model_config(
|
| 16 |
+
config_path: Optional[Path] = None,
|
| 17 |
+
config_dict: Optional[Dict] = None
|
| 18 |
+
) -> Tuple[List[Species], ProjectConfig, CarbonConfig, str, bool, Dict]:
|
| 19 |
+
"""
|
| 20 |
+
Loads model configuration from a YAML file or a dictionary.
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
A tuple containing:
|
| 24 |
+
- List of Species objects
|
| 25 |
+
- ProjectConfig object
|
| 26 |
+
- CarbonConfig object
|
| 27 |
+
- Growth model name (str)
|
| 28 |
+
- Continuous growth flag (bool)
|
| 29 |
+
- The raw config dictionary
|
| 30 |
+
"""
|
| 31 |
+
if config_dict is not None:
|
| 32 |
+
cfg = config_dict
|
| 33 |
+
elif config_path is not None:
|
| 34 |
+
with open(config_path) as f:
|
| 35 |
+
cfg = yaml.safe_load(f)
|
| 36 |
+
else:
|
| 37 |
+
raise ValueError("Either config_path or config_dict must be provided.")
|
| 38 |
+
|
| 39 |
+
species_list = [Species(**s) for s in cfg["species"]]
|
| 40 |
+
project_cfg = ProjectConfig(**cfg["project"])
|
| 41 |
+
carbon_cfg = CarbonConfig(**cfg["carbon"])
|
| 42 |
+
|
| 43 |
+
growth_model_name = cfg.get('growth_model', 'chapman_richards')
|
| 44 |
+
continuous_growth_flag = cfg.get('continuous_growth', False)
|
| 45 |
+
|
| 46 |
+
return species_list, project_cfg, carbon_cfg, growth_model_name, continuous_growth_flag, cfg
|
{src → er_model_core}/er_model.py
RENAMED
|
@@ -10,13 +10,15 @@ import pandas as pd
|
|
| 10 |
import yaml
|
| 11 |
import warnings
|
| 12 |
|
| 13 |
-
from .allometry import calculate_biomass
|
| 14 |
-
from .metrics import calculate_carbon
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
# Growth model imports
|
| 17 |
-
from .growth_models.
|
| 18 |
-
|
| 19 |
-
|
| 20 |
|
| 21 |
|
| 22 |
@dataclass
|
|
@@ -64,32 +66,31 @@ class ERModel:
|
|
| 64 |
mortality, and carbon conversion factors.
|
| 65 |
"""
|
| 66 |
|
| 67 |
-
def __init__(self, config_path: Path = None, config: dict = None):
|
| 68 |
"""
|
| 69 |
Initialize the model from a YAML configuration file or a config dict.
|
| 70 |
Args:
|
| 71 |
config_path: Path to the YAML configuration file
|
| 72 |
config: Config dict (optional)
|
| 73 |
"""
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
"area": 1000.0,
|
| 84 |
"dbh_range": [1.0, 20.0],
|
| 85 |
"height_range": [0.5, 12.0],
|
| 86 |
"growth_rate_factor": 1.0
|
| 87 |
})
|
| 88 |
-
self.results: Optional[pd.DataFrame] = None
|
| 89 |
-
self.species_results: Optional[pd.DataFrame] = None
|
| 90 |
-
self.scenario_results: Optional[pd.DataFrame] = None
|
| 91 |
-
self.growth_model = cfg.get('growth_model', 'chapman_richards')
|
| 92 |
-
self.continuous_growth = cfg.get('continuous_growth', False)
|
| 93 |
|
| 94 |
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:
|
| 95 |
"""
|
|
@@ -170,49 +171,45 @@ class ERModel:
|
|
| 170 |
total += area
|
| 171 |
return total
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
a = params["a"]
|
| 188 |
-
if age <= T_p:
|
| 189 |
-
return initial_value + r * age
|
| 190 |
else:
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
T_m = params["T_m"]
|
| 197 |
-
# Accumulate annual increments, never negative
|
| 198 |
-
total = initial_value
|
| 199 |
-
for i in range(1, int(np.floor(age)) + 1):
|
| 200 |
-
increment = r0 * max(0, 1 - (i - 1) / T_m)
|
| 201 |
-
total += increment
|
| 202 |
-
# If age is fractional, add partial increment for the last year
|
| 203 |
-
frac = age - int(np.floor(age))
|
| 204 |
-
if frac > 0:
|
| 205 |
-
i = int(np.floor(age)) + 1
|
| 206 |
-
increment = r0 * max(0, 1 - (i - 1) / T_m)
|
| 207 |
-
total += frac * increment
|
| 208 |
-
return total
|
| 209 |
|
| 210 |
-
|
| 211 |
-
def continuous_declining_increment_growth(age: float, params: Dict[str, float], initial_value: float) -> float:
|
| 212 |
-
r0 = params["r0"]
|
| 213 |
-
T_m = params["T_m"]
|
| 214 |
-
# Continuous formula: initial + r0 * (age - age^2/(2*Tm))
|
| 215 |
-
return initial_value + r0 * (age - age**2 / (2 * T_m))
|
| 216 |
|
| 217 |
def calculate_carbon_for_species(self, species: Species, age: int, area: float, cohort_age: int) -> float:
|
| 218 |
"""
|
|
@@ -340,69 +337,9 @@ class ERModel:
|
|
| 340 |
|
| 341 |
def save_results(self, output_path: Path) -> None:
|
| 342 |
"""
|
| 343 |
-
Save results to CSV file.
|
| 344 |
-
|
| 345 |
-
Args:
|
| 346 |
-
output_path: Path to save the results CSV
|
| 347 |
"""
|
| 348 |
if self.results is None:
|
| 349 |
-
raise ValueError("
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
def get_growth_function_and_params(self, species, growth_model, dim):
|
| 353 |
-
"""
|
| 354 |
-
Returns the correct growth function and parameter dict for the given species and dimension (dbh or height).
|
| 355 |
-
"""
|
| 356 |
-
if growth_model == "linear":
|
| 357 |
-
# from growth_models.linear import linear_growth
|
| 358 |
-
func = None # ARCHIVED/NOT IN USE
|
| 359 |
-
params = species.linear[dim]
|
| 360 |
-
elif growth_model == "linear_plateau":
|
| 361 |
-
# from growth_models.linear import linear_plateau_growth
|
| 362 |
-
func = None # ARCHIVED/NOT IN USE
|
| 363 |
-
params = species.linear_plateau[dim]
|
| 364 |
-
elif growth_model == "declining_increment":
|
| 365 |
-
if getattr(self, 'continuous_growth', False):
|
| 366 |
-
func = continuous_declining_increment_growth
|
| 367 |
-
else:
|
| 368 |
-
func = declining_increment_growth
|
| 369 |
-
params = species.declining_increment[dim]
|
| 370 |
-
else:
|
| 371 |
-
# from growth_models.chapman_richards import chapman_richards_growth
|
| 372 |
-
func = None # ARCHIVED/NOT IN USE
|
| 373 |
-
params = species.chapman_richards[dim]
|
| 374 |
-
return func, params
|
| 375 |
-
|
| 376 |
-
# --- Parameter sweep/test for plausible survival curves ---
|
| 377 |
-
def test_dbh_mortality_sweep():
|
| 378 |
-
import matplotlib.pyplot as plt
|
| 379 |
-
import numpy as np
|
| 380 |
-
m_refs = [0.01, 0.05, 0.1, 0.16]
|
| 381 |
-
ps = [1.0, 1.5, 2.0]
|
| 382 |
-
DBH_ref = 9.0
|
| 383 |
-
years = np.arange(1, 31)
|
| 384 |
-
initial_trees = 1000
|
| 385 |
-
results = {}
|
| 386 |
-
for m_ref in m_refs:
|
| 387 |
-
for p in ps:
|
| 388 |
-
N_live = initial_trees
|
| 389 |
-
N_lives = []
|
| 390 |
-
for year in years:
|
| 391 |
-
dbh = 1.0 + (year - 1) * 0.5 # simple linear DBH growth for test
|
| 392 |
-
dbh = max(dbh, 1.0)
|
| 393 |
-
m = m_ref * (DBH_ref / dbh) ** p
|
| 394 |
-
m = min(max(m, 0), 0.99)
|
| 395 |
-
N_live = N_live * (1 - m)
|
| 396 |
-
N_lives.append(N_live)
|
| 397 |
-
results[(m_ref, p)] = N_lives
|
| 398 |
-
plt.figure(figsize=(10,6))
|
| 399 |
-
for (m_ref, p), N_lives in results.items():
|
| 400 |
-
plt.plot(years, N_lives, label=f"m_ref={m_ref}, p={p}")
|
| 401 |
-
plt.xlabel("Year")
|
| 402 |
-
plt.ylabel("Surviving Trees")
|
| 403 |
-
plt.title("DBH-dependent Mortality Parameter Sweep")
|
| 404 |
-
plt.legend()
|
| 405 |
-
plt.grid(True)
|
| 406 |
-
plt.show()
|
| 407 |
-
|
| 408 |
-
# To run the test, call test_dbh_mortality_sweep() from __main__ or a notebook.
|
|
|
|
| 10 |
import yaml
|
| 11 |
import warnings
|
| 12 |
|
| 13 |
+
from er_model_core.allometry import calculate_biomass
|
| 14 |
+
from er_model_core.metrics import calculate_carbon
|
| 15 |
+
from er_model_core.config_loader import load_model_config
|
| 16 |
+
from er_model_core.types import Species, ProjectConfig, CarbonConfig
|
| 17 |
|
| 18 |
+
# Growth model imports - ensure all are imported
|
| 19 |
+
from er_model_core.growth_models.chapman_richards import chapman_richards_growth
|
| 20 |
+
from er_model_core.growth_models.linear import linear_growth, linear_plateau_growth
|
| 21 |
+
from er_model_core.growth_models.declining_increment import declining_increment_growth, continuous_declining_increment_growth
|
| 22 |
|
| 23 |
|
| 24 |
@dataclass
|
|
|
|
| 66 |
mortality, and carbon conversion factors.
|
| 67 |
"""
|
| 68 |
|
| 69 |
+
def __init__(self, config_path: Optional[Path] = None, config: Optional[dict] = None):
|
| 70 |
"""
|
| 71 |
Initialize the model from a YAML configuration file or a config dict.
|
| 72 |
Args:
|
| 73 |
config_path: Path to the YAML configuration file
|
| 74 |
config: Config dict (optional)
|
| 75 |
"""
|
| 76 |
+
(
|
| 77 |
+
self.species,
|
| 78 |
+
self.project,
|
| 79 |
+
self.carbon,
|
| 80 |
+
self.growth_model,
|
| 81 |
+
self.continuous_growth,
|
| 82 |
+
self.raw_config # Storing the raw config might be useful for other parts
|
| 83 |
+
) = load_model_config(config_path=config_path, config_dict=config)
|
| 84 |
+
|
| 85 |
+
self.results: Optional[pd.DataFrame] = None
|
| 86 |
+
self.species_results: Optional[pd.DataFrame] = None
|
| 87 |
+
self.scenario_results: Optional[pd.DataFrame] = None
|
| 88 |
+
self.scenarios = self.raw_config.get("scenarios", {
|
| 89 |
"area": 1000.0,
|
| 90 |
"dbh_range": [1.0, 20.0],
|
| 91 |
"height_range": [0.5, 12.0],
|
| 92 |
"growth_rate_factor": 1.0
|
| 93 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
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:
|
| 96 |
"""
|
|
|
|
| 171 |
total += area
|
| 172 |
return total
|
| 173 |
|
| 174 |
+
def get_growth_function_and_params(self, species: Species, growth_model_name: str, dim: str) -> Tuple[callable, Dict[str, float]]:
|
| 175 |
+
"""
|
| 176 |
+
Get the growth function and its parameters for a given species, model, and dimension (dbh or height).
|
| 177 |
+
Uses self.continuous_growth to select continuous version if applicable.
|
| 178 |
+
"""
|
| 179 |
+
params = species.__dict__.get(growth_model_name, {}).get(dim, {})
|
| 180 |
+
initial_value_key = f"initial_{dim}"
|
| 181 |
+
# initial_value = species.initial_values.get(dim, 0) # This was the old way, let's ensure params are complete
|
| 182 |
+
|
| 183 |
+
growth_functions = {
|
| 184 |
+
"chapman_richards": chapman_richards_growth,
|
| 185 |
+
"linear": linear_growth,
|
| 186 |
+
"linear_plateau": linear_plateau_growth,
|
| 187 |
+
"declining_increment": declining_increment_growth,
|
| 188 |
+
# Potentially add more models here
|
| 189 |
+
}
|
| 190 |
|
| 191 |
+
continuous_growth_functions = {
|
| 192 |
+
"declining_increment": continuous_declining_increment_growth,
|
| 193 |
+
# Add other continuous versions if they exist
|
| 194 |
+
}
|
| 195 |
|
| 196 |
+
if self.continuous_growth and growth_model_name in continuous_growth_functions:
|
| 197 |
+
selected_func = continuous_growth_functions[growth_model_name]
|
| 198 |
+
elif growth_model_name in growth_functions:
|
| 199 |
+
selected_func = growth_functions[growth_model_name]
|
|
|
|
|
|
|
|
|
|
| 200 |
else:
|
| 201 |
+
# Fallback or error if model name is not found
|
| 202 |
+
# For now, let's use chapman_richards as a default if self.growth_model itself isn't specific enough
|
| 203 |
+
# However, growth_model_name parameter should be the specific one.
|
| 204 |
+
warnings.warn(f"Growth model '{growth_model_name}' not found or not supported. Defaulting to chapman_richards for {species.name} {dim}. Check config.")
|
| 205 |
+
selected_func = chapman_richards_growth # Default fallback
|
| 206 |
+
params = species.chapman_richards.get(dim, {}) if species.chapman_richards else {}
|
| 207 |
|
| 208 |
+
if not params:
|
| 209 |
+
warnings.warn(f"No parameters found for {growth_model_name} {dim} for species {species.name}. Growth may be incorrect.")
|
| 210 |
+
# Provide some default empty params if really needed, or let it fail if func expects them.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
+
return selected_func, params
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
def calculate_carbon_for_species(self, species: Species, age: int, area: float, cohort_age: int) -> float:
|
| 215 |
"""
|
|
|
|
| 337 |
|
| 338 |
def save_results(self, output_path: Path) -> None:
|
| 339 |
"""
|
| 340 |
+
Save the main results DataFrame to a CSV file.
|
|
|
|
|
|
|
|
|
|
| 341 |
"""
|
| 342 |
if self.results is None:
|
| 343 |
+
raise ValueError("Results have not been calculated yet. Run model.run() first.")
|
| 344 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 345 |
+
self.results.to_csv(output_path, index=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{src → er_model_core}/growth_models/chapman_richards.py
RENAMED
|
File without changes
|
{src → er_model_core}/growth_models/declining_increment.py
RENAMED
|
File without changes
|
{src → er_model_core}/growth_models/linear.py
RENAMED
|
File without changes
|
{src → er_model_core}/metrics.py
RENAMED
|
File without changes
|
er_model_core/types.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import Dict, Optional
|
| 3 |
+
|
| 4 |
+
@dataclass
|
| 5 |
+
class Species:
|
| 6 |
+
"""Species-specific parameters for growth and carbon calculations."""
|
| 7 |
+
name: str
|
| 8 |
+
planting_density: float
|
| 9 |
+
# Old style
|
| 10 |
+
mortality_rates: Optional[Dict[str, float]] = None
|
| 11 |
+
# New style
|
| 12 |
+
m_ref: Optional[float] = None
|
| 13 |
+
DBH_ref: Optional[float] = None
|
| 14 |
+
p: Optional[float] = None
|
| 15 |
+
chapman_richards: Dict[str, Dict[str, float]] = None
|
| 16 |
+
allometry: Dict[str, float] = None
|
| 17 |
+
initial_values: Dict[str, float] = None
|
| 18 |
+
linear: Optional[Dict[str, Dict[str, float]]] = None
|
| 19 |
+
linear_plateau: Optional[Dict[str, Dict[str, float]]] = None
|
| 20 |
+
declining_increment: Optional[Dict[str, Dict[str, float]]] = None
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class ProjectConfig:
|
| 24 |
+
"""Project configuration parameters."""
|
| 25 |
+
duration_years: int
|
| 26 |
+
planting_schedule: Dict[str, float]
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class CarbonConfig:
|
| 30 |
+
"""Carbon conversion and adjustment parameters."""
|
| 31 |
+
biomass_to_carbon: float
|
| 32 |
+
carbon_to_co2: float
|
| 33 |
+
buffer_percentage: float
|
| 34 |
+
leakage_percentage: float
|
| 35 |
+
baseline_emissions: float
|
| 36 |
+
soil_carbon_per_ha_per_year: float = 0.0
|
requirements.txt
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
|
|
| 1 |
gradio>=3.41.2
|
| 2 |
-
|
| 3 |
-
numpy>=1.
|
|
|
|
| 4 |
plotly>=5.0.0
|
| 5 |
-
pyyaml
|
|
|
|
|
|
| 1 |
+
click>=8.1.7
|
| 2 |
gradio>=3.41.2
|
| 3 |
+
matplotlib>=3.8.2
|
| 4 |
+
numpy>=1.26.3
|
| 5 |
+
pandas>=2.2.0
|
| 6 |
plotly>=5.0.0
|
| 7 |
+
pyyaml>=6.0.1
|
| 8 |
+
scipy>=1.12.0
|
scripts/run_pipeline.py
CHANGED
|
@@ -4,14 +4,13 @@ Main pipeline script for running the ER model calculations.
|
|
| 4 |
"""
|
| 5 |
import sys
|
| 6 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# Add the project root to the Python path
|
| 9 |
sys.path.append(str(Path(__file__).parent.parent))
|
| 10 |
|
| 11 |
-
import click
|
| 12 |
-
|
| 13 |
-
from src.er_model import ERModel
|
| 14 |
-
|
| 15 |
|
| 16 |
@click.command()
|
| 17 |
@click.option(
|
|
|
|
| 4 |
"""
|
| 5 |
import sys
|
| 6 |
from pathlib import Path
|
| 7 |
+
import yaml
|
| 8 |
+
import click
|
| 9 |
+
from er_model_core.er_model import ERModel
|
| 10 |
|
| 11 |
# Add the project root to the Python path
|
| 12 |
sys.path.append(str(Path(__file__).parent.parent))
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
@click.command()
|
| 16 |
@click.option(
|
setup.py
CHANGED
|
@@ -3,7 +3,8 @@ from setuptools import find_packages, setup
|
|
| 3 |
setup(
|
| 4 |
name="er_model",
|
| 5 |
version="0.1.0",
|
| 6 |
-
|
|
|
|
| 7 |
install_requires=[
|
| 8 |
"pandas>=2.2.0",
|
| 9 |
"numpy>=1.26.3",
|
|
|
|
| 3 |
setup(
|
| 4 |
name="er_model",
|
| 5 |
version="0.1.0",
|
| 6 |
+
package_dir={'': 'er_model_core'},
|
| 7 |
+
packages=find_packages(where='er_model_core'),
|
| 8 |
install_requires=[
|
| 9 |
"pandas>=2.2.0",
|
| 10 |
"numpy>=1.26.3",
|
tests/test_er_model.py
CHANGED
|
@@ -6,10 +6,11 @@ from pathlib import Path
|
|
| 6 |
import numpy as np
|
| 7 |
import pytest
|
| 8 |
import yaml
|
|
|
|
| 9 |
|
| 10 |
-
from
|
| 11 |
-
from
|
| 12 |
-
from
|
| 13 |
|
| 14 |
|
| 15 |
@pytest.fixture
|
|
@@ -133,7 +134,7 @@ def test_full_pipeline(config_file):
|
|
| 133 |
|
| 134 |
def test_dbh_dependent_mortality():
|
| 135 |
"""Test that DBH-dependent mortality matches expected at DBH=9 and year-5 plateau."""
|
| 136 |
-
from
|
| 137 |
# Minimal species config
|
| 138 |
species = Species(
|
| 139 |
name="Test Species",
|
|
@@ -171,7 +172,7 @@ def test_dbh_dependent_mortality():
|
|
| 171 |
|
| 172 |
def test_allometric_equation_species_A_B():
|
| 173 |
"""Test Zanvo et al. 2023 allometric equations for both species."""
|
| 174 |
-
from
|
| 175 |
# Test values
|
| 176 |
dbh = 10.0 # cm
|
| 177 |
height = 5.0 # m
|
|
@@ -182,4 +183,73 @@ def test_allometric_equation_species_A_B():
|
|
| 182 |
# species_B (Avicennia germinans)
|
| 183 |
expected_B = 1.486 * (dbh**2 * height)**0.55864
|
| 184 |
result_B = calculate_biomass(dbh, height, "species_B", {})
|
| 185 |
-
assert np.isclose(result_B, expected_B, rtol=1e-6), f"species_B: got {result_B}, expected {expected_B}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import numpy as np
|
| 7 |
import pytest
|
| 8 |
import yaml
|
| 9 |
+
import pandas as pd
|
| 10 |
|
| 11 |
+
from er_model_core.allometry import calculate_biomass
|
| 12 |
+
from er_model_core.er_model import ERModel, Species
|
| 13 |
+
from er_model_core.metrics import calculate_carbon
|
| 14 |
|
| 15 |
|
| 16 |
@pytest.fixture
|
|
|
|
| 134 |
|
| 135 |
def test_dbh_dependent_mortality():
|
| 136 |
"""Test that DBH-dependent mortality matches expected at DBH=9 and year-5 plateau."""
|
| 137 |
+
from er_model_core.er_model import ERModel, Species
|
| 138 |
# Minimal species config
|
| 139 |
species = Species(
|
| 140 |
name="Test Species",
|
|
|
|
| 172 |
|
| 173 |
def test_allometric_equation_species_A_B():
|
| 174 |
"""Test Zanvo et al. 2023 allometric equations for both species."""
|
| 175 |
+
from er_model_core.allometry import calculate_biomass
|
| 176 |
# Test values
|
| 177 |
dbh = 10.0 # cm
|
| 178 |
height = 5.0 # m
|
|
|
|
| 183 |
# species_B (Avicennia germinans)
|
| 184 |
expected_B = 1.486 * (dbh**2 * height)**0.55864
|
| 185 |
result_B = calculate_biomass(dbh, height, "species_B", {})
|
| 186 |
+
assert np.isclose(result_B, expected_B, rtol=1e-6), f"species_B: got {result_B}, expected {expected_B}"
|
| 187 |
+
|
| 188 |
+
# Test with a known allometry to check biomass calculation linkage
|
| 189 |
+
from er_model_core.allometry import calculate_biomass
|
| 190 |
+
|
| 191 |
+
# Basic check that biomass is positive for reasonable inputs
|
| 192 |
+
# ... existing code ...
|
| 193 |
+
|
| 194 |
+
# Re-import ERModel and Species for this specific test to ensure clean state if needed
|
| 195 |
+
from er_model_core.er_model import ERModel, Species
|
| 196 |
+
model = ERModel(config=config_dict)
|
| 197 |
+
results_df, species_results_df = model.run()
|
| 198 |
+
|
| 199 |
+
# ... existing code ...
|
| 200 |
+
|
| 201 |
+
# ... existing code ...
|
| 202 |
+
|
| 203 |
+
# --- Parameter sweep/test for plausible survival curves (moved from er_model.py) ---
|
| 204 |
+
def test_dbh_mortality_sweep():
|
| 205 |
+
import matplotlib.pyplot as plt
|
| 206 |
+
import numpy as np
|
| 207 |
+
m_refs = [0.01, 0.05, 0.1, 0.16]
|
| 208 |
+
ps = [1.0, 1.5, 2.0]
|
| 209 |
+
DBH_ref = 9.0
|
| 210 |
+
years = np.arange(1, 31)
|
| 211 |
+
initial_trees = 1000
|
| 212 |
+
results = {}
|
| 213 |
+
for m_ref in m_refs:
|
| 214 |
+
for p in ps:
|
| 215 |
+
N_live = initial_trees
|
| 216 |
+
N_lives = []
|
| 217 |
+
for year in years:
|
| 218 |
+
# This test requires a simplified DBH growth for its internal logic.
|
| 219 |
+
# It does not use the main ERModel's growth functions directly.
|
| 220 |
+
dbh_test_growth = 1.0 + (year - 1) * 0.5 # simple linear DBH growth for test
|
| 221 |
+
dbh = max(dbh_test_growth, 1.0)
|
| 222 |
+
|
| 223 |
+
# Mortality calculation as per ERModel logic being tested
|
| 224 |
+
m = m_ref * (DBH_ref / dbh) ** p
|
| 225 |
+
m = min(max(m, 0), 0.99)
|
| 226 |
+
N_live = N_live * (1 - m)
|
| 227 |
+
N_lives.append(N_live)
|
| 228 |
+
results[(m_ref, p)] = N_lives
|
| 229 |
+
|
| 230 |
+
# This test originally had plotting. For automated tests, assertions are better.
|
| 231 |
+
# If visual inspection is needed, this part can be run in a notebook.
|
| 232 |
+
# For now, let's assert that the number of trees is non-increasing.
|
| 233 |
+
for (m_ref, p), N_lives_curve in results.items():
|
| 234 |
+
for i in range(len(N_lives_curve) - 1):
|
| 235 |
+
assert N_lives_curve[i+1] <= N_lives_curve[i], f"Mortality sweep {m_ref, p}: Trees increased from {N_lives_curve[i]} to {N_lives_curve[i+1]}"
|
| 236 |
+
assert N_lives_curve[-1] < initial_trees, f"Mortality sweep {m_ref, p}: No mortality occurred or trees increased."
|
| 237 |
+
|
| 238 |
+
# plt.figure(figsize=(10,6))
|
| 239 |
+
# for (m_ref, p), N_lives_plot in results.items():
|
| 240 |
+
# plt.plot(years, N_lives_plot, label=f"m_ref={m_ref}, p={p}")
|
| 241 |
+
# plt.xlabel("Year")
|
| 242 |
+
# plt.ylabel("Surviving Trees")
|
| 243 |
+
# plt.title("DBH-dependent Mortality Parameter Sweep")
|
| 244 |
+
# plt.legend()
|
| 245 |
+
# plt.grid(True)
|
| 246 |
+
# To save plot instead of showing:
|
| 247 |
+
# output_dir = Path(__file__).parent.parent / "outputs" / "test_plots"
|
| 248 |
+
# output_dir.mkdir(parents=True, exist_ok=True)
|
| 249 |
+
# plt.savefig(output_dir / "dbh_mortality_sweep.png")
|
| 250 |
+
# plt.close()
|
| 251 |
+
print("test_dbh_mortality_sweep completed basic assertions.")
|
| 252 |
+
|
| 253 |
+
# If you want to run this specific test and see the plot (e.g., during development):
|
| 254 |
+
# if __name__ == "__main__":
|
| 255 |
+
# test_dbh_mortality_sweep() # You would need to handle imports for ERModel parts if called directly here
|