malcolmSQ commited on
Commit ·
9047195
1
Parent(s): 3f105b6
Dashboard cleanup: remove per-hectare chart, improve table formatting, clarify table names and titles
Browse files- configs/declining_increment.yaml +1 -0
- dashboard/app.py +201 -46
- src/er_model.py +45 -59
- tests/test_er_model.py +17 -1
configs/declining_increment.yaml
CHANGED
|
@@ -71,6 +71,7 @@ carbon:
|
|
| 71 |
buffer_percentage: 20 # risk buffer
|
| 72 |
leakage_percentage: 0 # no leakage assumed
|
| 73 |
baseline_emissions: 0 # tCO2/ha/year
|
|
|
|
| 74 |
|
| 75 |
scenarios:
|
| 76 |
area: 1000.0 # ha
|
|
|
|
| 71 |
buffer_percentage: 20 # risk buffer
|
| 72 |
leakage_percentage: 0 # no leakage assumed
|
| 73 |
baseline_emissions: 0 # tCO2/ha/year
|
| 74 |
+
soil_carbon_per_ha_per_year: 1.0 # tCO2/ha/year, added annually
|
| 75 |
|
| 76 |
scenarios:
|
| 77 |
area: 1000.0 # ha
|
dashboard/app.py
CHANGED
|
@@ -17,6 +17,8 @@ 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",
|
|
@@ -45,22 +47,24 @@ def create_survival_table(model):
|
|
| 45 |
"""
|
| 46 |
years = range(1, model.project.duration_years + 1)
|
| 47 |
data = {"Year": []}
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
data[name] = []
|
| 51 |
data["Total Surviving Trees"] = []
|
| 52 |
for year in years:
|
| 53 |
data["Year"].append(year)
|
| 54 |
totals = model.calculate_total_surviving_trees(year)
|
| 55 |
total = 0
|
| 56 |
-
for
|
| 57 |
-
val = totals.get(
|
| 58 |
-
data[
|
| 59 |
total += val
|
| 60 |
data["Total Surviving Trees"].append(total)
|
| 61 |
df = pd.DataFrame(data)
|
| 62 |
# Format numbers: no decimals, thousands separator
|
| 63 |
-
for name in
|
| 64 |
df[name] = df[name].apply(lambda x: f"{x:,.0f}")
|
| 65 |
return df
|
| 66 |
|
|
@@ -116,7 +120,9 @@ def create_growth_increment_plots(config, model_type=None):
|
|
| 116 |
"""
|
| 117 |
if model_type is None:
|
| 118 |
model_type = config.get('growth_model', 'chapman_richards')
|
| 119 |
-
|
|
|
|
|
|
|
| 120 |
fig = make_subplots(rows=2, cols=2, subplot_titles=("DBH Growth", "HEIGHT Growth", "DBH Annual Increment", "HEIGHT Annual Increment"))
|
| 121 |
from src.er_model import ERModel
|
| 122 |
use_continuous = config.get('continuous_growth', False)
|
|
@@ -125,44 +131,33 @@ def create_growth_increment_plots(config, model_type=None):
|
|
| 125 |
initial_dbh = sp["initial_values"]["dbh"]
|
| 126 |
initial_height = sp["initial_values"]["height"]
|
| 127 |
if model_type == "linear":
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
dbh = [ERModel.linear_growth(t, dbh_params, initial_dbh) for t in years]
|
| 131 |
-
height = [ERModel.linear_growth(t, height_params, initial_height) for t in years]
|
| 132 |
elif model_type == "linear_plateau":
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
dbh = [ERModel.linear_plateau_growth(t, dbh_params, initial_dbh) for t in years]
|
| 136 |
-
height = [ERModel.linear_plateau_growth(t, height_params, initial_height) for t in years]
|
| 137 |
elif model_type == "declining_increment":
|
| 138 |
-
dbh_params = sp["declining_increment"]["dbh"]
|
| 139 |
-
height_params = sp["declining_increment"]["height"]
|
| 140 |
if use_continuous:
|
| 141 |
-
dbh = [ERModel.continuous_declining_increment_growth(t,
|
| 142 |
-
height = [ERModel.continuous_declining_increment_growth(t,
|
| 143 |
else:
|
| 144 |
-
dbh = [ERModel.declining_increment_growth(t,
|
| 145 |
-
height = [ERModel.declining_increment_growth(t,
|
| 146 |
-
else:
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
def chapman_richards(t, params, initial):
|
| 150 |
-
a, b, c = params["a"], params["b"], params["c"]
|
| 151 |
-
return initial + (a - initial) * (1 - np.exp(-b * t)) ** c
|
| 152 |
-
dbh = [chapman_richards(t, dbh_params, initial_dbh) for t in years]
|
| 153 |
-
height = [chapman_richards(t, height_params, initial_height) for t in years]
|
| 154 |
dbh = np.array(dbh)
|
| 155 |
height = np.array(height)
|
| 156 |
check_complex(dbh, f"{name} DBH (growth)")
|
| 157 |
check_complex(height, f"{name} Height (growth)")
|
| 158 |
-
dbh_inc =
|
| 159 |
-
height_inc =
|
| 160 |
check_complex(dbh_inc, f"{name} DBH Δ (increment)")
|
| 161 |
check_complex(height_inc, f"{name} Height Δ (increment)")
|
| 162 |
-
fig.add_trace(go.Scatter(x=
|
| 163 |
-
fig.add_trace(go.Scatter(x=
|
| 164 |
-
fig.add_trace(go.Scatter(x=
|
| 165 |
-
fig.add_trace(go.Scatter(x=
|
| 166 |
fig.update_layout(height=700, width=900, title_text="Growth and Increment Curves", hovermode="x unified")
|
| 167 |
fig.update_xaxes(title_text="Age (years)", row=1, col=1)
|
| 168 |
fig.update_xaxes(title_text="Age (years)", row=1, col=2)
|
|
@@ -179,10 +174,17 @@ def create_all_plots(results, species_results, config):
|
|
| 179 |
for col in ["gross_carbon", "net_carbon"]:
|
| 180 |
arr = np.array(results[col])
|
| 181 |
check_complex(arr, f"results['{col}']")
|
| 182 |
-
# 1. Carbon curve (gross and net)
|
| 183 |
fig1 = go.Figure()
|
| 184 |
-
fig1.add_trace(go.Scatter(x=results["year"], y=results["gross_carbon"], mode="lines+markers", name="Gross Carbon", line=dict(width=2)))
|
| 185 |
-
fig1.add_trace(go.Scatter(x=results["year"], y=results["net_carbon"], mode="lines+markers", name="Net Carbon", line=dict(width=2)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
fig1.update_layout(title="Carbon Sequestration Over Time", xaxis_title="Year", yaxis_title="Carbon (tCO2)", hovermode="x unified", template="plotly_white")
|
| 187 |
|
| 188 |
# 2. Annual carbon (ERs)
|
|
@@ -202,6 +204,7 @@ def create_all_plots(results, species_results, config):
|
|
| 202 |
name = SPECIES_DISPLAY_NAMES.get(sp["name"], sp["name"])
|
| 203 |
initial_dbh = sp["initial_values"]["dbh"]
|
| 204 |
initial_height = sp["initial_values"]["height"]
|
|
|
|
| 205 |
if growth_model == "linear":
|
| 206 |
dbh_params = sp["linear"]["dbh"]
|
| 207 |
height_params = sp["linear"]["height"]
|
|
@@ -215,8 +218,12 @@ def create_all_plots(results, species_results, config):
|
|
| 215 |
elif growth_model == "declining_increment":
|
| 216 |
dbh_params = sp["declining_increment"]["dbh"]
|
| 217 |
height_params = sp["declining_increment"]["height"]
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
else: # Default to Chapman-Richards
|
| 221 |
dbh_params = sp["chapman_richards"]["dbh"]
|
| 222 |
height_params = sp["chapman_richards"]["height"]
|
|
@@ -241,10 +248,18 @@ def create_summary(results, species_results, config, model):
|
|
| 241 |
total_area = sum(config["project"]["planting_schedule"].values())
|
| 242 |
final_gross = results["gross_carbon"].iloc[-1]
|
| 243 |
final_net = results["net_carbon"].iloc[-1]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
years = len(results)
|
| 245 |
gross_carbon_per_ha = final_gross / total_area if total_area > 0 else 0
|
| 246 |
net_carbon_per_ha = final_net / total_area if total_area > 0 else 0
|
|
|
|
|
|
|
| 247 |
annual_net_per_ha = net_carbon_per_ha / years if years > 0 else 0
|
|
|
|
|
|
|
| 248 |
# Milestone years
|
| 249 |
def get_val(col, idx):
|
| 250 |
return results[col].iloc[idx] if len(results) > idx else 0
|
|
@@ -256,6 +271,8 @@ def create_summary(results, species_results, config, model):
|
|
| 256 |
year_20_net = get_val("net_carbon", 19)
|
| 257 |
avg_annual_gross = final_gross / years if years > 0 else 0
|
| 258 |
avg_annual_net = final_net / years if years > 0 else 0
|
|
|
|
|
|
|
| 259 |
# Surviving trees at milestones
|
| 260 |
milestones = [5, 10, 20, 30]
|
| 261 |
survival_lines = []
|
|
@@ -272,16 +289,146 @@ def create_summary(results, species_results, config, model):
|
|
| 272 |
per_ha_lines.append(f"{SPECIES_DISPLAY_NAMES.get(k, k)}: {per_ha:,.0f} trees/ha")
|
| 273 |
summary = (
|
| 274 |
f"Project Overview:\n----------------\nDuration: {years} years\nTotal Area Planted: {total_area:,.0f} ha\nBuffer Pool: {config['carbon']['buffer_percentage']}%\n\n"
|
| 275 |
-
f"
|
| 276 |
-
f"
|
|
|
|
|
|
|
| 277 |
"Surviving Trees (Milestones):\n----------------------------\n"
|
| 278 |
+ "\n".join(survival_lines)
|
| 279 |
+ "\n\nPer Hectare Surviving Trees (Final Year):\n----------------------------------------\n"
|
| 280 |
+ "\n".join(per_ha_lines)
|
| 281 |
-
+ 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"
|
| 282 |
)
|
| 283 |
return summary
|
| 284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
with gr.Blocks() as demo:
|
| 286 |
gr.Markdown("# Mangrove ER Model Dashboard\nDeclining Increment Model Only")
|
| 287 |
with gr.Tabs():
|
|
@@ -303,10 +450,14 @@ with gr.Blocks() as demo:
|
|
| 303 |
carbon_plot = gr.Plot()
|
| 304 |
biomass_plot = gr.Plot()
|
| 305 |
annual_plot = gr.Plot()
|
|
|
|
|
|
|
| 306 |
summary_box = gr.Textbox(label="Summary", lines=12)
|
| 307 |
results_box = gr.Dataframe(label="Project Results (Annual)")
|
| 308 |
species_box = gr.Dataframe(label="Species Results (Annual)")
|
| 309 |
survival_box = gr.Dataframe(label="Surviving Trees Table")
|
|
|
|
|
|
|
| 310 |
|
| 311 |
def update_declining_increment(y1, y2, y3, y4, y5):
|
| 312 |
config = update_planting_schedule(MODEL_CONFIGS["Declining Increment"], [y1, y2, y3, y4, y5])
|
|
@@ -320,6 +471,8 @@ with gr.Blocks() as demo:
|
|
| 320 |
plots = create_all_plots(results, species_results, config)
|
| 321 |
summary = create_summary(results, species_results, config, model)
|
| 322 |
survival_table = create_survival_table(model)
|
|
|
|
|
|
|
| 323 |
results_fmt = results.copy()
|
| 324 |
species_results_fmt = species_results.copy()
|
| 325 |
for col in results_fmt.columns:
|
|
@@ -328,21 +481,23 @@ with gr.Blocks() as demo:
|
|
| 328 |
for col in species_results_fmt.columns:
|
| 329 |
if species_results_fmt[col].dtype in [float, int]:
|
| 330 |
species_results_fmt[col] = species_results_fmt[col].apply(lambda x: f"{x:,.2f}" if isinstance(x, float) else f"{x:,}")
|
| 331 |
-
return plots[0], plots[2], plots[1], summary, results_fmt, species_results_fmt, survival_table
|
| 332 |
|
| 333 |
update_btn.click(
|
| 334 |
update_declining_increment,
|
| 335 |
inputs=[year_1, year_2, year_3, year_4, year_5],
|
| 336 |
-
outputs=[carbon_plot, biomass_plot, annual_plot, summary_box, results_box, species_box, survival_box]
|
| 337 |
)
|
| 338 |
|
| 339 |
# Show initial results
|
| 340 |
-
c, b, a, summary, r, s, surv = update_declining_increment(2500, 2500, 0, 0, 0)
|
| 341 |
carbon_plot.value = c
|
| 342 |
biomass_plot.value = b
|
| 343 |
annual_plot.value = a
|
|
|
|
| 344 |
summary_box.value = summary
|
| 345 |
results_box.value = r
|
| 346 |
species_box.value = s
|
| 347 |
survival_box.value = surv
|
|
|
|
| 348 |
demo.launch(share=True)
|
|
|
|
| 17 |
from plotly.subplots import make_subplots
|
| 18 |
import warnings
|
| 19 |
import traceback
|
| 20 |
+
from src.allometry import calculate_biomass
|
| 21 |
+
import copy
|
| 22 |
|
| 23 |
MODEL_CONFIGS = {
|
| 24 |
"Original Model": "configs/params.yaml",
|
|
|
|
| 47 |
"""
|
| 48 |
years = range(1, model.project.duration_years + 1)
|
| 49 |
data = {"Year": []}
|
| 50 |
+
# Use internal names for lookup, but display friendly names
|
| 51 |
+
internal_names = [s.name for s in model.species]
|
| 52 |
+
display_names = [SPECIES_DISPLAY_NAMES.get(s.name, s.name) for s in model.species]
|
| 53 |
+
for name in display_names:
|
| 54 |
data[name] = []
|
| 55 |
data["Total Surviving Trees"] = []
|
| 56 |
for year in years:
|
| 57 |
data["Year"].append(year)
|
| 58 |
totals = model.calculate_total_surviving_trees(year)
|
| 59 |
total = 0
|
| 60 |
+
for internal, display in zip(internal_names, display_names):
|
| 61 |
+
val = totals.get(internal, 0)
|
| 62 |
+
data[display].append(val)
|
| 63 |
total += val
|
| 64 |
data["Total Surviving Trees"].append(total)
|
| 65 |
df = pd.DataFrame(data)
|
| 66 |
# Format numbers: no decimals, thousands separator
|
| 67 |
+
for name in display_names + ["Total Surviving Trees"]:
|
| 68 |
df[name] = df[name].apply(lambda x: f"{x:,.0f}")
|
| 69 |
return df
|
| 70 |
|
|
|
|
| 120 |
"""
|
| 121 |
if model_type is None:
|
| 122 |
model_type = config.get('growth_model', 'chapman_richards')
|
| 123 |
+
N = config["project"]["duration_years"]
|
| 124 |
+
ages = np.arange(0, N + 1) # 0 to N
|
| 125 |
+
ages_inc = np.arange(1, N + 1) # 1 to N
|
| 126 |
fig = make_subplots(rows=2, cols=2, subplot_titles=("DBH Growth", "HEIGHT Growth", "DBH Annual Increment", "HEIGHT Annual Increment"))
|
| 127 |
from src.er_model import ERModel
|
| 128 |
use_continuous = config.get('continuous_growth', False)
|
|
|
|
| 131 |
initial_dbh = sp["initial_values"]["dbh"]
|
| 132 |
initial_height = sp["initial_values"]["height"]
|
| 133 |
if model_type == "linear":
|
| 134 |
+
dbh = [ERModel.linear_growth(t, sp["linear"]["dbh"], initial_dbh) for t in ages]
|
| 135 |
+
height = [ERModel.linear_growth(t, sp["linear"]["height"], initial_height) for t in ages]
|
|
|
|
|
|
|
| 136 |
elif model_type == "linear_plateau":
|
| 137 |
+
dbh = [ERModel.linear_plateau_growth(t, sp["linear_plateau"]["dbh"], initial_dbh) for t in ages]
|
| 138 |
+
height = [ERModel.linear_plateau_growth(t, sp["linear_plateau"]["height"], initial_height) for t in ages]
|
|
|
|
|
|
|
| 139 |
elif model_type == "declining_increment":
|
|
|
|
|
|
|
| 140 |
if use_continuous:
|
| 141 |
+
dbh = [ERModel.continuous_declining_increment_growth(t, sp["declining_increment"]["dbh"], initial_dbh) for t in ages]
|
| 142 |
+
height = [ERModel.continuous_declining_increment_growth(t, sp["declining_increment"]["height"], initial_height) for t in ages]
|
| 143 |
else:
|
| 144 |
+
dbh = [ERModel.declining_increment_growth(t, sp["declining_increment"]["dbh"], initial_dbh) for t in ages]
|
| 145 |
+
height = [ERModel.declining_increment_growth(t, sp["declining_increment"]["height"], initial_height) for t in ages]
|
| 146 |
+
else:
|
| 147 |
+
dbh = [ERModel.chapman_richards_growth(t, sp["chapman_richards"]["dbh"], initial_dbh) for t in ages]
|
| 148 |
+
height = [ERModel.chapman_richards_growth(t, sp["chapman_richards"]["height"], initial_height) for t in ages]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
dbh = np.array(dbh)
|
| 150 |
height = np.array(height)
|
| 151 |
check_complex(dbh, f"{name} DBH (growth)")
|
| 152 |
check_complex(height, f"{name} Height (growth)")
|
| 153 |
+
dbh_inc = dbh[1:] - dbh[:-1]
|
| 154 |
+
height_inc = height[1:] - height[:-1]
|
| 155 |
check_complex(dbh_inc, f"{name} DBH Δ (increment)")
|
| 156 |
check_complex(height_inc, f"{name} Height Δ (increment)")
|
| 157 |
+
fig.add_trace(go.Scatter(x=ages, y=dbh, mode='lines', name=f"{name} DBH", legendgroup=name, line=dict(width=2)), row=1, col=1)
|
| 158 |
+
fig.add_trace(go.Scatter(x=ages, y=height, mode='lines', name=f"{name} Height", legendgroup=name, line=dict(width=2)), row=1, col=2)
|
| 159 |
+
fig.add_trace(go.Scatter(x=ages_inc, y=dbh_inc, mode='lines', name=f"{name} DBH Δ", legendgroup=name, showlegend=False, line=dict(width=2)), row=2, col=1)
|
| 160 |
+
fig.add_trace(go.Scatter(x=ages_inc, y=height_inc, mode='lines', name=f"{name} Height Δ", legendgroup=name, showlegend=False, line=dict(width=2)), row=2, col=2)
|
| 161 |
fig.update_layout(height=700, width=900, title_text="Growth and Increment Curves", hovermode="x unified")
|
| 162 |
fig.update_xaxes(title_text="Age (years)", row=1, col=1)
|
| 163 |
fig.update_xaxes(title_text="Age (years)", row=1, col=2)
|
|
|
|
| 174 |
for col in ["gross_carbon", "net_carbon"]:
|
| 175 |
arr = np.array(results[col])
|
| 176 |
check_complex(arr, f"results['{col}']")
|
| 177 |
+
# 1. Carbon curve (gross and net, with and without soil)
|
| 178 |
fig1 = go.Figure()
|
| 179 |
+
fig1.add_trace(go.Scatter(x=results["year"], y=results["gross_carbon"], mode="lines+markers", name="Gross Carbon", line=dict(width=2, color="blue")))
|
| 180 |
+
fig1.add_trace(go.Scatter(x=results["year"], y=results["net_carbon"], mode="lines+markers", name="Net Carbon", line=dict(width=2, color="red")))
|
| 181 |
+
if "gross_carbon_with_soil" in results.columns:
|
| 182 |
+
# For backward compatibility, but we now sum soil carbon in summary, so recalc here for plot
|
| 183 |
+
soil_cumsum = results["soil_carbon"].cumsum()
|
| 184 |
+
gross_with_soil = results["gross_carbon"] + soil_cumsum
|
| 185 |
+
net_with_soil = results["net_carbon"] + soil_cumsum
|
| 186 |
+
fig1.add_trace(go.Scatter(x=results["year"], y=gross_with_soil, mode="lines+markers", name="Gross Carbon (with Soil)", line=dict(width=2, dash="dot", color="green")))
|
| 187 |
+
fig1.add_trace(go.Scatter(x=results["year"], y=net_with_soil, mode="lines+markers", name="Net Carbon (with Soil)", line=dict(width=2, dash="dot", color="orange")))
|
| 188 |
fig1.update_layout(title="Carbon Sequestration Over Time", xaxis_title="Year", yaxis_title="Carbon (tCO2)", hovermode="x unified", template="plotly_white")
|
| 189 |
|
| 190 |
# 2. Annual carbon (ERs)
|
|
|
|
| 204 |
name = SPECIES_DISPLAY_NAMES.get(sp["name"], sp["name"])
|
| 205 |
initial_dbh = sp["initial_values"]["dbh"]
|
| 206 |
initial_height = sp["initial_values"]["height"]
|
| 207 |
+
use_continuous = config.get('continuous_growth', False)
|
| 208 |
if growth_model == "linear":
|
| 209 |
dbh_params = sp["linear"]["dbh"]
|
| 210 |
height_params = sp["linear"]["height"]
|
|
|
|
| 218 |
elif growth_model == "declining_increment":
|
| 219 |
dbh_params = sp["declining_increment"]["dbh"]
|
| 220 |
height_params = sp["declining_increment"]["height"]
|
| 221 |
+
if use_continuous:
|
| 222 |
+
dbh = [ERModel.continuous_declining_increment_growth(t, dbh_params, initial_dbh) for t in years]
|
| 223 |
+
height = [ERModel.continuous_declining_increment_growth(t, height_params, initial_height) for t in years]
|
| 224 |
+
else:
|
| 225 |
+
dbh = [ERModel.declining_increment_growth(t, dbh_params, initial_dbh) for t in years]
|
| 226 |
+
height = [ERModel.declining_increment_growth(t, height_params, initial_height) for t in years]
|
| 227 |
else: # Default to Chapman-Richards
|
| 228 |
dbh_params = sp["chapman_richards"]["dbh"]
|
| 229 |
height_params = sp["chapman_richards"]["height"]
|
|
|
|
| 248 |
total_area = sum(config["project"]["planting_schedule"].values())
|
| 249 |
final_gross = results["gross_carbon"].iloc[-1]
|
| 250 |
final_net = results["net_carbon"].iloc[-1]
|
| 251 |
+
# Sum soil carbon over all years
|
| 252 |
+
total_soil_carbon = results["soil_carbon"].sum()
|
| 253 |
+
final_gross_soil = final_gross + total_soil_carbon
|
| 254 |
+
final_net_soil = final_net + total_soil_carbon
|
| 255 |
years = len(results)
|
| 256 |
gross_carbon_per_ha = final_gross / total_area if total_area > 0 else 0
|
| 257 |
net_carbon_per_ha = final_net / total_area if total_area > 0 else 0
|
| 258 |
+
gross_carbon_per_ha_soil = final_gross_soil / total_area if total_area > 0 else 0
|
| 259 |
+
net_carbon_per_ha_soil = final_net_soil / total_area if total_area > 0 else 0
|
| 260 |
annual_net_per_ha = net_carbon_per_ha / years if years > 0 else 0
|
| 261 |
+
annual_net_per_ha_soil = net_carbon_per_ha_soil / years if years > 0 else 0
|
| 262 |
+
soil_carbon_val = config["carbon"].get("soil_carbon_per_ha_per_year", 0)
|
| 263 |
# Milestone years
|
| 264 |
def get_val(col, idx):
|
| 265 |
return results[col].iloc[idx] if len(results) > idx else 0
|
|
|
|
| 271 |
year_20_net = get_val("net_carbon", 19)
|
| 272 |
avg_annual_gross = final_gross / years if years > 0 else 0
|
| 273 |
avg_annual_net = final_net / years if years > 0 else 0
|
| 274 |
+
avg_annual_gross_soil = final_gross_soil / years if years > 0 else 0
|
| 275 |
+
avg_annual_net_soil = final_net_soil / years if years > 0 else 0
|
| 276 |
# Surviving trees at milestones
|
| 277 |
milestones = [5, 10, 20, 30]
|
| 278 |
survival_lines = []
|
|
|
|
| 289 |
per_ha_lines.append(f"{SPECIES_DISPLAY_NAMES.get(k, k)}: {per_ha:,.0f} trees/ha")
|
| 290 |
summary = (
|
| 291 |
f"Project Overview:\n----------------\nDuration: {years} years\nTotal Area Planted: {total_area:,.0f} ha\nBuffer Pool: {config['carbon']['buffer_percentage']}%\n\n"
|
| 292 |
+
f"Soil Carbon:\n-----------\nSoil carbon added: {soil_carbon_val} tCO2/ha/year\nTotal soil carbon added over project: {total_soil_carbon:,.0f} tCO2\n\n"
|
| 293 |
+
f"Carbon Sequestration (Biomass Only):\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"
|
| 294 |
+
f"Carbon Sequestration (With Soil Carbon):\n-------------------\nTotal Gross Carbon (Year {years}): {final_gross_soil:,.0f} tCO2\nTotal Net Carbon (Year {years}): {final_net_soil:,.0f} tCO2\nAverage Annual Gross: {avg_annual_gross_soil:,.0f} tCO2/yr\nAverage Annual Net: {avg_annual_net_soil:,.0f} tCO2/yr\n\n"
|
| 295 |
+
f"Milestone Years (Biomass Only):\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"
|
| 296 |
"Surviving Trees (Milestones):\n----------------------------\n"
|
| 297 |
+ "\n".join(survival_lines)
|
| 298 |
+ "\n\nPer Hectare Surviving Trees (Final Year):\n----------------------------------------\n"
|
| 299 |
+ "\n".join(per_ha_lines)
|
| 300 |
+
+ f"\n\nPer Hectare Metrics (Year {years}):\n-----------------------------\nGross Carbon per ha (biomass only): {gross_carbon_per_ha:,.0f} tCO2/ha\nNet Carbon per ha (biomass only): {net_carbon_per_ha:,.0f} tCO2/ha\nGross Carbon per ha (with soil): {gross_carbon_per_ha_soil:,.0f} tCO2/ha\nNet Carbon per ha (with soil): {net_carbon_per_ha_soil:,.0f} tCO2/ha\nAverage Annual Net per ha (biomass only): {annual_net_per_ha:,.2f} tCO2/ha/yr\nAverage Annual Net per ha (with soil): {annual_net_per_ha_soil:,.2f} tCO2/ha/yr\n"
|
| 301 |
)
|
| 302 |
return summary
|
| 303 |
|
| 304 |
+
def create_size_table(config):
|
| 305 |
+
"""
|
| 306 |
+
Returns a DataFrame with ages as rows and columns for each species and dimension (dbh, height).
|
| 307 |
+
"""
|
| 308 |
+
from src.er_model import ERModel
|
| 309 |
+
ages = range(0, config["project"]["duration_years"] + 1)
|
| 310 |
+
columns = []
|
| 311 |
+
data = {}
|
| 312 |
+
for sp in config["species"]:
|
| 313 |
+
sp_disp = SPECIES_DISPLAY_NAMES.get(sp["name"], sp["name"])
|
| 314 |
+
for dim in ["dbh", "height"]:
|
| 315 |
+
col = (sp_disp, dim)
|
| 316 |
+
columns.append(col)
|
| 317 |
+
data[col] = []
|
| 318 |
+
for age in ages:
|
| 319 |
+
for sp in config["species"]:
|
| 320 |
+
sp_disp = SPECIES_DISPLAY_NAMES.get(sp["name"], sp["name"])
|
| 321 |
+
initial_dbh = sp["initial_values"]["dbh"]
|
| 322 |
+
initial_height = sp["initial_values"]["height"]
|
| 323 |
+
model_type = config.get('growth_model', 'chapman_richards')
|
| 324 |
+
use_continuous = config.get('continuous_growth', False)
|
| 325 |
+
if model_type == "linear":
|
| 326 |
+
dbh = ERModel.linear_growth(age, sp["linear"]["dbh"], initial_dbh)
|
| 327 |
+
height = ERModel.linear_growth(age, sp["linear"]["height"], initial_height)
|
| 328 |
+
elif model_type == "linear_plateau":
|
| 329 |
+
dbh = ERModel.linear_plateau_growth(age, sp["linear_plateau"]["dbh"], initial_dbh)
|
| 330 |
+
height = ERModel.linear_plateau_growth(age, sp["linear_plateau"]["height"], initial_height)
|
| 331 |
+
elif model_type == "declining_increment":
|
| 332 |
+
if use_continuous:
|
| 333 |
+
dbh = ERModel.continuous_declining_increment_growth(age, sp["declining_increment"]["dbh"], initial_dbh)
|
| 334 |
+
height = ERModel.continuous_declining_increment_growth(age, sp["declining_increment"]["height"], initial_height)
|
| 335 |
+
else:
|
| 336 |
+
dbh = ERModel.declining_increment_growth(age, sp["declining_increment"]["dbh"], initial_dbh)
|
| 337 |
+
height = ERModel.declining_increment_growth(age, sp["declining_increment"]["height"], initial_height)
|
| 338 |
+
else:
|
| 339 |
+
dbh = ERModel.chapman_richards_growth(age, sp["chapman_richards"]["dbh"], initial_dbh)
|
| 340 |
+
height = ERModel.chapman_richards_growth(age, sp["chapman_richards"]["height"], initial_height)
|
| 341 |
+
data[(sp_disp, "dbh")].append(round(dbh, 5))
|
| 342 |
+
data[(sp_disp, "height")].append(round(height, 5))
|
| 343 |
+
# Build MultiIndex columns
|
| 344 |
+
columns = pd.MultiIndex.from_tuples(columns, names=["Species", "Dimension"])
|
| 345 |
+
df = pd.DataFrame(data, index=ages)
|
| 346 |
+
df.index.name = "Age"
|
| 347 |
+
df = df[columns] # ensure order
|
| 348 |
+
return df
|
| 349 |
+
|
| 350 |
+
def pretty_table_title(title):
|
| 351 |
+
return f"<span style='font-size:1.3em; font-weight:bold'>{title}</span>"
|
| 352 |
+
|
| 353 |
+
def create_biomass_debug_table(config):
|
| 354 |
+
"""
|
| 355 |
+
Returns a DataFrame with years as rows and columns for each species:
|
| 356 |
+
- Surviving Trees
|
| 357 |
+
- DBH (cm)
|
| 358 |
+
- Height (m)
|
| 359 |
+
- Biomass per tree (kg)
|
| 360 |
+
- Total Biomass (kg)
|
| 361 |
+
Uses cohort-level growth (age = year - planting_year + 1).
|
| 362 |
+
"""
|
| 363 |
+
from src.er_model import ERModel
|
| 364 |
+
from src.allometry import calculate_biomass
|
| 365 |
+
years = range(1, config["project"]["duration_years"] + 1)
|
| 366 |
+
data = {}
|
| 367 |
+
for sp in config["species"]:
|
| 368 |
+
sp_disp = SPECIES_DISPLAY_NAMES.get(sp["name"], sp["name"])
|
| 369 |
+
data[(sp_disp, "Surviving Trees")] = []
|
| 370 |
+
data[(sp_disp, "DBH (cm)")] = []
|
| 371 |
+
data[(sp_disp, "Height (m)")] = []
|
| 372 |
+
data[(sp_disp, "Biomass per Tree (kg)")] = []
|
| 373 |
+
data[(sp_disp, "Total Biomass (kg)")] = []
|
| 374 |
+
model = ERModel(config=config)
|
| 375 |
+
for year in years:
|
| 376 |
+
for sp in config["species"]:
|
| 377 |
+
sp_disp = SPECIES_DISPLAY_NAMES.get(sp["name"], sp["name"])
|
| 378 |
+
total_surviving = 0
|
| 379 |
+
total_biomass = 0
|
| 380 |
+
total_dbh = 0
|
| 381 |
+
total_height = 0
|
| 382 |
+
total_biomass_per_tree = 0
|
| 383 |
+
n_cohorts = 0
|
| 384 |
+
for planting_year, area in config["project"]["planting_schedule"].items():
|
| 385 |
+
py = int(planting_year.split("_")[1])
|
| 386 |
+
cohort_age = year - py + 1
|
| 387 |
+
if cohort_age < 1:
|
| 388 |
+
continue
|
| 389 |
+
initial_trees = sp["planting_density"] * area
|
| 390 |
+
plateau_density = sp["planting_density"] * area if cohort_age >= 5 else None
|
| 391 |
+
surviving = model.calculate_cohort_surviving_trees(1, cohort_age, initial_trees, model.species[0], plateau_density, model.growth_model)
|
| 392 |
+
dbh_func, dbh_params = model.get_growth_function_and_params(model.species[0], model.growth_model, 'dbh')
|
| 393 |
+
height_func, height_params = model.get_growth_function_and_params(model.species[0], model.growth_model, 'height')
|
| 394 |
+
dbh = dbh_func(cohort_age, dbh_params, sp["initial_values"]["dbh"])
|
| 395 |
+
height = height_func(cohort_age, height_params, sp["initial_values"]["height"])
|
| 396 |
+
biomass_per_tree = calculate_biomass(dbh, height, sp["name"], sp["allometry"])
|
| 397 |
+
total_surviving += surviving
|
| 398 |
+
total_dbh += dbh * surviving
|
| 399 |
+
total_height += height * surviving
|
| 400 |
+
total_biomass_per_tree += biomass_per_tree * surviving
|
| 401 |
+
total_biomass += biomass_per_tree * surviving
|
| 402 |
+
n_cohorts += surviving
|
| 403 |
+
if total_surviving > 0:
|
| 404 |
+
avg_dbh = total_dbh / total_surviving
|
| 405 |
+
avg_height = total_height / total_surviving
|
| 406 |
+
avg_biomass_per_tree = total_biomass_per_tree / total_surviving
|
| 407 |
+
else:
|
| 408 |
+
avg_dbh = 0
|
| 409 |
+
avg_height = 0
|
| 410 |
+
avg_biomass_per_tree = 0
|
| 411 |
+
data[(sp_disp, "Surviving Trees")].append(total_surviving)
|
| 412 |
+
data[(sp_disp, "DBH (cm)")].append(avg_dbh)
|
| 413 |
+
data[(sp_disp, "Height (m)")].append(avg_height)
|
| 414 |
+
data[(sp_disp, "Biomass per Tree (kg)")].append(avg_biomass_per_tree)
|
| 415 |
+
data[(sp_disp, "Total Biomass (kg)")].append(total_biomass)
|
| 416 |
+
columns = pd.MultiIndex.from_tuples(list(data.keys()))
|
| 417 |
+
df = pd.DataFrame({col: data[col] for col in columns}, index=range(1, config["project"]["duration_years"] + 1))
|
| 418 |
+
df.index.name = "Year"
|
| 419 |
+
# Format numbers
|
| 420 |
+
for col in df.columns:
|
| 421 |
+
if "Surviving Trees" in col:
|
| 422 |
+
df[col] = df[col].apply(lambda x: f"{x:,.0f}")
|
| 423 |
+
elif "Biomass" in col or "DBH" in col or "Height" in col:
|
| 424 |
+
df[col] = df[col].apply(lambda x: f"{x:,.2f}")
|
| 425 |
+
return df
|
| 426 |
+
|
| 427 |
+
def hex_to_rgba(hex_color, alpha):
|
| 428 |
+
hex_color = hex_color.lstrip('#')
|
| 429 |
+
r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
| 430 |
+
return f'rgba({r},{g},{b},{alpha})'
|
| 431 |
+
|
| 432 |
with gr.Blocks() as demo:
|
| 433 |
gr.Markdown("# Mangrove ER Model Dashboard\nDeclining Increment Model Only")
|
| 434 |
with gr.Tabs():
|
|
|
|
| 450 |
carbon_plot = gr.Plot()
|
| 451 |
biomass_plot = gr.Plot()
|
| 452 |
annual_plot = gr.Plot()
|
| 453 |
+
# Add new growth/increment curves plot
|
| 454 |
+
growth_plot = gr.Plot(label="Growth & Increment Curves (DBH/Height)")
|
| 455 |
summary_box = gr.Textbox(label="Summary", lines=12)
|
| 456 |
results_box = gr.Dataframe(label="Project Results (Annual)")
|
| 457 |
species_box = gr.Dataframe(label="Species Results (Annual)")
|
| 458 |
survival_box = gr.Dataframe(label="Surviving Trees Table")
|
| 459 |
+
# Replace size_table with biomass debug table
|
| 460 |
+
biomass_debug_table = gr.Dataframe(label="Biomass Table (inputs & outputs per year)")
|
| 461 |
|
| 462 |
def update_declining_increment(y1, y2, y3, y4, y5):
|
| 463 |
config = update_planting_schedule(MODEL_CONFIGS["Declining Increment"], [y1, y2, y3, y4, y5])
|
|
|
|
| 471 |
plots = create_all_plots(results, species_results, config)
|
| 472 |
summary = create_summary(results, species_results, config, model)
|
| 473 |
survival_table = create_survival_table(model)
|
| 474 |
+
growth_fig = create_growth_increment_plots(config, model_type="declining_increment")
|
| 475 |
+
biomass_debug_df = create_biomass_debug_table(config)
|
| 476 |
results_fmt = results.copy()
|
| 477 |
species_results_fmt = species_results.copy()
|
| 478 |
for col in results_fmt.columns:
|
|
|
|
| 481 |
for col in species_results_fmt.columns:
|
| 482 |
if species_results_fmt[col].dtype in [float, int]:
|
| 483 |
species_results_fmt[col] = species_results_fmt[col].apply(lambda x: f"{x:,.2f}" if isinstance(x, float) else f"{x:,}")
|
| 484 |
+
return plots[0], plots[2], plots[1], growth_fig, summary, results_fmt, species_results_fmt, survival_table, biomass_debug_df
|
| 485 |
|
| 486 |
update_btn.click(
|
| 487 |
update_declining_increment,
|
| 488 |
inputs=[year_1, year_2, year_3, year_4, year_5],
|
| 489 |
+
outputs=[carbon_plot, biomass_plot, annual_plot, growth_plot, summary_box, results_box, species_box, survival_box, biomass_debug_table]
|
| 490 |
)
|
| 491 |
|
| 492 |
# Show initial results
|
| 493 |
+
c, b, a, g, summary, r, s, surv, biomass_debug_df = update_declining_increment(2500, 2500, 0, 0, 0)
|
| 494 |
carbon_plot.value = c
|
| 495 |
biomass_plot.value = b
|
| 496 |
annual_plot.value = a
|
| 497 |
+
growth_plot.value = g
|
| 498 |
summary_box.value = summary
|
| 499 |
results_box.value = r
|
| 500 |
species_box.value = s
|
| 501 |
survival_box.value = surv
|
| 502 |
+
biomass_debug_table.value = biomass_debug_df
|
| 503 |
demo.launch(share=True)
|
src/er_model.py
CHANGED
|
@@ -48,6 +48,7 @@ class CarbonConfig:
|
|
| 48 |
buffer_percentage: float
|
| 49 |
leakage_percentage: float
|
| 50 |
baseline_emissions: float
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
class ERModel:
|
|
@@ -58,34 +59,32 @@ class ERModel:
|
|
| 58 |
mortality, and carbon conversion factors.
|
| 59 |
"""
|
| 60 |
|
| 61 |
-
def __init__(self, config_path: Path):
|
| 62 |
"""
|
| 63 |
-
Initialize the model from a YAML configuration file.
|
| 64 |
-
|
| 65 |
Args:
|
| 66 |
config_path: Path to the YAML configuration file
|
|
|
|
| 67 |
"""
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
self.
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
self.scenarios =
|
| 77 |
"area": 1000.0,
|
| 78 |
"dbh_range": [1.0, 20.0],
|
| 79 |
"height_range": [0.5, 12.0],
|
| 80 |
"growth_rate_factor": 1.0
|
| 81 |
})
|
| 82 |
-
|
| 83 |
-
# Initialize results storage
|
| 84 |
self.results: Optional[pd.DataFrame] = None
|
| 85 |
self.species_results: Optional[pd.DataFrame] = None
|
| 86 |
self.scenario_results: Optional[pd.DataFrame] = None
|
| 87 |
-
|
| 88 |
-
self.
|
| 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 |
"""
|
|
@@ -210,35 +209,34 @@ class ERModel:
|
|
| 210 |
# Continuous formula: initial + r0 * (age - age^2/(2*Tm))
|
| 211 |
return initial_value + r0 * (age - age**2 / (2 * T_m))
|
| 212 |
|
| 213 |
-
def calculate_carbon_for_species(self, species: Species, age: int, area: float) -> float:
|
| 214 |
"""
|
| 215 |
-
Calculate carbon sequestration for a single species and age.
|
| 216 |
Args:
|
| 217 |
species: Species parameters
|
| 218 |
-
age:
|
| 219 |
area: Planted area in hectares
|
|
|
|
| 220 |
Returns:
|
| 221 |
Carbon sequestration in tCO2
|
| 222 |
"""
|
| 223 |
-
|
|
|
|
| 224 |
initial_trees = species.planting_density * area
|
| 225 |
-
plateau_density = species.planting_density * area if
|
| 226 |
-
surviving = self.calculate_cohort_surviving_trees(1,
|
| 227 |
-
# Calculate DBH and height using the selected growth model
|
| 228 |
dbh_func, dbh_params = self.get_growth_function_and_params(species, self.growth_model, 'dbh')
|
| 229 |
height_func, height_params = self.get_growth_function_and_params(species, self.growth_model, 'height')
|
| 230 |
-
dbh = dbh_func(
|
| 231 |
-
height = height_func(
|
| 232 |
-
# Calculate biomass using both DBH and height
|
| 233 |
biomass = calculate_biomass(dbh, height, species.name, species.allometry)
|
| 234 |
-
# Convert to carbon
|
| 235 |
carbon = calculate_carbon(
|
| 236 |
biomass * surviving,
|
| 237 |
self.carbon.biomass_to_carbon,
|
| 238 |
self.carbon.carbon_to_co2
|
| 239 |
)
|
| 240 |
return carbon
|
| 241 |
-
|
| 242 |
def run(self) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
| 243 |
"""
|
| 244 |
Execute the full ER calculation pipeline.
|
|
@@ -248,64 +246,49 @@ class ERModel:
|
|
| 248 |
years = range(1, self.project.duration_years + 1)
|
| 249 |
results = []
|
| 250 |
species_results = []
|
| 251 |
-
|
| 252 |
for year in years:
|
| 253 |
year_results = {"year": year}
|
| 254 |
species_year_results = {"Year": year}
|
| 255 |
total_carbon = 0
|
| 256 |
cumulative_area = self.calculate_cumulative_area(year)
|
| 257 |
-
|
| 258 |
-
# Calculate for each planting cohort and species
|
| 259 |
for species in self.species:
|
| 260 |
species_carbon = 0
|
| 261 |
for planting_year, area in self.project.planting_schedule.items():
|
| 262 |
py = int(planting_year.split("_")[1])
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
dbh_func, dbh_params = self.get_growth_function_and_params(species, self.growth_model, 'dbh')
|
| 269 |
-
height_func, height_params = self.get_growth_function_and_params(species, self.growth_model, 'height')
|
| 270 |
-
dbh = dbh_func(year, dbh_params, species.initial_values["dbh"])
|
| 271 |
-
height = height_func(year, height_params, species.initial_values["height"])
|
| 272 |
-
# Calculate biomass using both DBH and height
|
| 273 |
-
biomass = calculate_biomass(dbh, height, species.name, species.allometry)
|
| 274 |
-
# Convert to carbon
|
| 275 |
-
carbon = calculate_carbon(
|
| 276 |
-
biomass * surviving,
|
| 277 |
-
self.carbon.biomass_to_carbon,
|
| 278 |
-
self.carbon.carbon_to_co2
|
| 279 |
-
)
|
| 280 |
species_carbon += carbon
|
| 281 |
total_carbon += species_carbon
|
| 282 |
-
# Track per-species carbon
|
| 283 |
species_key = f"{species.name} tCO2"
|
| 284 |
species_year_results[species_key] = species_carbon
|
| 285 |
-
|
| 286 |
-
# Add total carbon and area metrics
|
| 287 |
species_year_results["Total tCO2"] = total_carbon
|
| 288 |
species_year_results["Cumulative ha"] = cumulative_area
|
| 289 |
species_year_results["tCO2/ha"] = total_carbon / cumulative_area if cumulative_area > 0 else 0
|
| 290 |
-
|
| 291 |
-
# Apply adjustments for main results
|
| 292 |
gross_carbon = total_carbon
|
| 293 |
net_carbon = gross_carbon * (1 - self.carbon.buffer_percentage / 100)
|
| 294 |
net_carbon -= self.carbon.leakage_percentage / 100 * gross_carbon
|
| 295 |
net_carbon -= self.carbon.baseline_emissions * cumulative_area
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
year_results.update({
|
| 298 |
"gross_carbon": gross_carbon,
|
| 299 |
"net_carbon": net_carbon,
|
| 300 |
-
"cumulative_area": cumulative_area
|
|
|
|
|
|
|
|
|
|
| 301 |
})
|
| 302 |
-
|
| 303 |
results.append(year_results)
|
| 304 |
species_results.append(species_year_results)
|
| 305 |
-
|
| 306 |
self.results = pd.DataFrame(results)
|
| 307 |
self.species_results = pd.DataFrame(species_results)
|
| 308 |
-
|
| 309 |
return self.results, self.species_results
|
| 310 |
|
| 311 |
def save_results(self, output_path: Path) -> None:
|
|
@@ -330,7 +313,10 @@ class ERModel:
|
|
| 330 |
func = ERModel.linear_plateau_growth
|
| 331 |
params = species.linear_plateau[dim]
|
| 332 |
elif growth_model == "declining_increment":
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
| 334 |
params = species.declining_increment[dim]
|
| 335 |
else:
|
| 336 |
func = ERModel.chapman_richards_growth
|
|
|
|
| 48 |
buffer_percentage: float
|
| 49 |
leakage_percentage: float
|
| 50 |
baseline_emissions: float
|
| 51 |
+
soil_carbon_per_ha_per_year: float = 0.0
|
| 52 |
|
| 53 |
|
| 54 |
class ERModel:
|
|
|
|
| 59 |
mortality, and carbon conversion factors.
|
| 60 |
"""
|
| 61 |
|
| 62 |
+
def __init__(self, config_path: Path = None, config: dict = None):
|
| 63 |
"""
|
| 64 |
+
Initialize the model from a YAML configuration file or a config dict.
|
|
|
|
| 65 |
Args:
|
| 66 |
config_path: Path to the YAML configuration file
|
| 67 |
+
config: Config dict (optional)
|
| 68 |
"""
|
| 69 |
+
if config is not None:
|
| 70 |
+
cfg = config
|
| 71 |
+
else:
|
| 72 |
+
with open(config_path) as f:
|
| 73 |
+
cfg = yaml.safe_load(f)
|
| 74 |
+
self.species = [Species(**s) for s in cfg["species"]]
|
| 75 |
+
self.project = ProjectConfig(**cfg["project"])
|
| 76 |
+
self.carbon = CarbonConfig(**cfg["carbon"])
|
| 77 |
+
self.scenarios = cfg.get("scenarios", {
|
| 78 |
"area": 1000.0,
|
| 79 |
"dbh_range": [1.0, 20.0],
|
| 80 |
"height_range": [0.5, 12.0],
|
| 81 |
"growth_rate_factor": 1.0
|
| 82 |
})
|
|
|
|
|
|
|
| 83 |
self.results: Optional[pd.DataFrame] = None
|
| 84 |
self.species_results: Optional[pd.DataFrame] = None
|
| 85 |
self.scenario_results: Optional[pd.DataFrame] = None
|
| 86 |
+
self.growth_model = cfg.get('growth_model', 'chapman_richards')
|
| 87 |
+
self.continuous_growth = cfg.get('continuous_growth', False)
|
| 88 |
|
| 89 |
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:
|
| 90 |
"""
|
|
|
|
| 209 |
# Continuous formula: initial + r0 * (age - age^2/(2*Tm))
|
| 210 |
return initial_value + r0 * (age - age**2 / (2 * T_m))
|
| 211 |
|
| 212 |
+
def calculate_carbon_for_species(self, species: Species, age: int, area: float, cohort_age: int) -> float:
|
| 213 |
"""
|
| 214 |
+
Calculate carbon sequestration for a single species, cohort, and cohort age.
|
| 215 |
Args:
|
| 216 |
species: Species parameters
|
| 217 |
+
age: Project year (not used for growth)
|
| 218 |
area: Planted area in hectares
|
| 219 |
+
cohort_age: Age of this cohort (years since planting)
|
| 220 |
Returns:
|
| 221 |
Carbon sequestration in tCO2
|
| 222 |
"""
|
| 223 |
+
if cohort_age < 1:
|
| 224 |
+
return 0
|
| 225 |
initial_trees = species.planting_density * area
|
| 226 |
+
plateau_density = species.planting_density * area if cohort_age >= 5 else None
|
| 227 |
+
surviving = self.calculate_cohort_surviving_trees(1, cohort_age, initial_trees, species, plateau_density, self.growth_model)
|
|
|
|
| 228 |
dbh_func, dbh_params = self.get_growth_function_and_params(species, self.growth_model, 'dbh')
|
| 229 |
height_func, height_params = self.get_growth_function_and_params(species, self.growth_model, 'height')
|
| 230 |
+
dbh = dbh_func(cohort_age, dbh_params, species.initial_values["dbh"])
|
| 231 |
+
height = height_func(cohort_age, height_params, species.initial_values["height"])
|
|
|
|
| 232 |
biomass = calculate_biomass(dbh, height, species.name, species.allometry)
|
|
|
|
| 233 |
carbon = calculate_carbon(
|
| 234 |
biomass * surviving,
|
| 235 |
self.carbon.biomass_to_carbon,
|
| 236 |
self.carbon.carbon_to_co2
|
| 237 |
)
|
| 238 |
return carbon
|
| 239 |
+
|
| 240 |
def run(self) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
| 241 |
"""
|
| 242 |
Execute the full ER calculation pipeline.
|
|
|
|
| 246 |
years = range(1, self.project.duration_years + 1)
|
| 247 |
results = []
|
| 248 |
species_results = []
|
|
|
|
| 249 |
for year in years:
|
| 250 |
year_results = {"year": year}
|
| 251 |
species_year_results = {"Year": year}
|
| 252 |
total_carbon = 0
|
| 253 |
cumulative_area = self.calculate_cumulative_area(year)
|
|
|
|
|
|
|
| 254 |
for species in self.species:
|
| 255 |
species_carbon = 0
|
| 256 |
for planting_year, area in self.project.planting_schedule.items():
|
| 257 |
py = int(planting_year.split("_")[1])
|
| 258 |
+
cohort_age = year - py + 1
|
| 259 |
+
if cohort_age < 1:
|
| 260 |
+
continue
|
| 261 |
+
# Use correct cohort age for all calculations
|
| 262 |
+
carbon = self.calculate_carbon_for_species(species, year, area, cohort_age)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
species_carbon += carbon
|
| 264 |
total_carbon += species_carbon
|
|
|
|
| 265 |
species_key = f"{species.name} tCO2"
|
| 266 |
species_year_results[species_key] = species_carbon
|
|
|
|
|
|
|
| 267 |
species_year_results["Total tCO2"] = total_carbon
|
| 268 |
species_year_results["Cumulative ha"] = cumulative_area
|
| 269 |
species_year_results["tCO2/ha"] = total_carbon / cumulative_area if cumulative_area > 0 else 0
|
|
|
|
|
|
|
| 270 |
gross_carbon = total_carbon
|
| 271 |
net_carbon = gross_carbon * (1 - self.carbon.buffer_percentage / 100)
|
| 272 |
net_carbon -= self.carbon.leakage_percentage / 100 * gross_carbon
|
| 273 |
net_carbon -= self.carbon.baseline_emissions * cumulative_area
|
| 274 |
+
# Add soil carbon if present
|
| 275 |
+
soil_carbon = 0
|
| 276 |
+
if hasattr(self.carbon, 'soil_carbon_per_ha_per_year'):
|
| 277 |
+
soil_carbon = self.carbon.soil_carbon_per_ha_per_year * cumulative_area
|
| 278 |
+
gross_carbon_with_soil = gross_carbon + soil_carbon
|
| 279 |
+
net_carbon_with_soil = net_carbon + soil_carbon
|
| 280 |
year_results.update({
|
| 281 |
"gross_carbon": gross_carbon,
|
| 282 |
"net_carbon": net_carbon,
|
| 283 |
+
"cumulative_area": cumulative_area,
|
| 284 |
+
"gross_carbon_with_soil": gross_carbon_with_soil,
|
| 285 |
+
"net_carbon_with_soil": net_carbon_with_soil,
|
| 286 |
+
"soil_carbon": soil_carbon
|
| 287 |
})
|
|
|
|
| 288 |
results.append(year_results)
|
| 289 |
species_results.append(species_year_results)
|
|
|
|
| 290 |
self.results = pd.DataFrame(results)
|
| 291 |
self.species_results = pd.DataFrame(species_results)
|
|
|
|
| 292 |
return self.results, self.species_results
|
| 293 |
|
| 294 |
def save_results(self, output_path: Path) -> None:
|
|
|
|
| 313 |
func = ERModel.linear_plateau_growth
|
| 314 |
params = species.linear_plateau[dim]
|
| 315 |
elif growth_model == "declining_increment":
|
| 316 |
+
if getattr(self, 'continuous_growth', False):
|
| 317 |
+
func = ERModel.continuous_declining_increment_growth
|
| 318 |
+
else:
|
| 319 |
+
func = ERModel.declining_increment_growth
|
| 320 |
params = species.declining_increment[dim]
|
| 321 |
else:
|
| 322 |
func = ERModel.chapman_richards_growth
|
tests/test_er_model.py
CHANGED
|
@@ -166,4 +166,20 @@ def test_dbh_dependent_mortality():
|
|
| 166 |
N_live = N_live * (1 - m)
|
| 167 |
if y == 5:
|
| 168 |
N_live = plateau_density
|
| 169 |
-
assert N_live == plateau_density
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
N_live = N_live * (1 - m)
|
| 167 |
if y == 5:
|
| 168 |
N_live = plateau_density
|
| 169 |
+
assert N_live == plateau_density
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_allometric_equation_species_A_B():
|
| 173 |
+
"""Test Zanvo et al. 2023 allometric equations for both species."""
|
| 174 |
+
from src.allometry import calculate_biomass
|
| 175 |
+
# Test values
|
| 176 |
+
dbh = 10.0 # cm
|
| 177 |
+
height = 5.0 # m
|
| 178 |
+
# species_A (Rhizophora spp.)
|
| 179 |
+
expected_A = 1.938 * (dbh**2 * height)**0.67628
|
| 180 |
+
result_A = calculate_biomass(dbh, height, "species_A", {})
|
| 181 |
+
assert np.isclose(result_A, expected_A, rtol=1e-6), f"species_A: got {result_A}, expected {expected_A}"
|
| 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}"
|