---
type: Notebook
title: Forest Canopy Dimensions and Statistical Validation
description: >-
  Supports Results Table 4 and the Statistical Validation section: transcribes
  the six canopy height model dimensions, pools the leaf, branch/root and canopy
  groups, and runs one-sample t-tests and TOST equivalence tests against the d =
  3/2 prediction.
resource: https://tyson-swetnam.github.io/fractal-notebooks/notebooks/metabolic-scaling/results/3_canopy_statistical_validation/
tags: [notebook, part-ii, metabolic-scaling, canopy-height-model, lidar, statistics]
companion_page: https://tyson-swetnam.github.io/fractal-notebooks/metabolic-scaling/results/
notebook:
  source: https://github.com/tyson-swetnam/fractal-notebooks/blob/main/docs/notebooks/metabolic-scaling/results/3_canopy_statistical_validation.ipynb
  raw: https://raw.githubusercontent.com/tyson-swetnam/fractal-notebooks/main/docs/notebooks/metabolic-scaling/results/3_canopy_statistical_validation.ipynb
  nbviewer: https://nbviewer.org/github/tyson-swetnam/fractal-notebooks/blob/main/docs/notebooks/metabolic-scaling/results/3_canopy_statistical_validation.ipynb
  colab: https://colab.research.google.com/github/tyson-swetnam/fractal-notebooks/blob/main/docs/notebooks/metabolic-scaling/results/3_canopy_statistical_validation.ipynb
  kernel: Python 3 (ipykernel)
  environment: Python 3 with numpy, scipy, pandas and matplotlib
  cells:
    markdown: 7
    code: 5
    executed: 5
    figures: 1
    errors: 0
  outputs: stored
sources:
  - id: ipynb
    resource: https://github.com/tyson-swetnam/fractal-notebooks/blob/main/docs/notebooks/metabolic-scaling/results/3_canopy_statistical_validation.ipynb
    title: 3_canopy_statistical_validation.ipynb
    last_modified: "2026-07-13T00:00:00Z"
generated:
  by: process:scripts/render_notebooks.py
  at: "2026-07-13T00:00:00Z"
status: stable
---

<!-- Generated by scripts/render_notebooks.py from the notebook named in the frontmatter. Do not edit: edit the .ipynb and re-run the script. -->

# Forest Canopy Dimensions and Statistical Validation

!!! info "Jupyter notebook"

    This page was rendered from the stored outputs of `3_canopy_statistical_validation.ipynb` as of 2026-07-13; it is not executed at build time.

    [View on GitHub](https://github.com/tyson-swetnam/fractal-notebooks/blob/main/docs/notebooks/metabolic-scaling/results/3_canopy_statistical_validation.ipynb) · [Open in nbviewer](https://nbviewer.org/github/tyson-swetnam/fractal-notebooks/blob/main/docs/notebooks/metabolic-scaling/results/3_canopy_statistical_validation.ipynb) · [Open in Colab](https://colab.research.google.com/github/tyson-swetnam/fractal-notebooks/blob/main/docs/notebooks/metabolic-scaling/results/3_canopy_statistical_validation.ipynb) · [Raw .ipynb](https://raw.githubusercontent.com/tyson-swetnam/fractal-notebooks/main/docs/notebooks/metabolic-scaling/results/3_canopy_statistical_validation.ipynb)

    Kernel: Python 3 (ipykernel) · Environment: Python 3 with numpy, scipy, pandas and matplotlib · Companion page: [metabolic-scaling/results/](https://tyson-swetnam.github.io/fractal-notebooks/metabolic-scaling/results/)

**Part II: Metabolic Scaling Theory and Biological Fractals**

---

## Overview

This notebook supports the Results section **"Table 4: Forest Canopy Height Model Dimensions"** and the **"Statistical Validation"** section. Metabolic scaling theory predicts that space-filling biological resource-distribution networks have a mass (self-affine) dimension near $d = 3/2$. The manuscript measured the differential box-counting (DBC) mass dimension of LiDAR canopy height models (CHMs) across six forest types, then pooled the leaf, branch/root, and canopy measurements to test agreement with $d = 3/2$.

Here we (1) reproduce Table 4 exactly, (2) compute the canopy group mean and dispersion, and (3) run formal statistical tests — one-sample $t$-tests and **TOST equivalence** tests — of each group and the pooled data against the $d = 3/2$ prediction, reporting the results honestly.

## 1. Published Table 4 (reference values)

Transcribed directly from the manuscript. Each CHM is a LiDAR-derived raster of roughly $3.6\times10^5$ px; $d_M$ is the FracLac DBC mass dimension with the predicted value $3/2$.


```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats

# Table 4 - forest canopy height model (CHM) mass dimensions (FracLac DBC), predicted d_M = 3/2
table4 = pd.DataFrame([
    ["Lowland Rainforest",   361201, 1.3313, 0.9860],
    ["Pine/Hardwood SC",     362403, 1.5223, 0.9898],
    ["Sierra Madre Oaks AZ", 362404, 1.4973, 0.9886],
    ["Western Ponderosa NM", 361802, 1.4566, 0.9847],
    ["SW Mixed Conifer NM",  361802, 1.5319, 0.9869],
    ["SW Spruce-Fir NM",     361802, 1.5355, 0.9862],
], columns=["Site","Pixels","d_M","mu_r2"])

# leaf and branch/root values from Tables 2 and 3 (for pooled validation)
leaf_vals   = np.array([1.5384, 1.4844, 1.5525, 1.5135, 1.5083])
branch_vals = np.array([1.4946, 1.4775, 1.4549])
canopy_vals = table4["d_M"].values

pd.set_option("display.float_format", lambda v: f"{v:.4f}")
print("TABLE 4 - Forest canopy height model mass dimensions")
print(table4.to_string(index=False))
print()
cm = canopy_vals.mean(); csd = canopy_vals.std(ddof=1); cse = csd/np.sqrt(len(canopy_vals))
print(f"Canopy mean = {cm:.4f}   SD = {csd:.4f}   SE = {cse:.4f}")
print(f"Manuscript reports canopy 1.479 +/- 0.080  ->  matches (mean {cm:.3f}, SD {csd:.3f})")
```

```text
TABLE 4 - Forest canopy height model mass dimensions
                Site  Pixels    d_M  mu_r2
  Lowland Rainforest  361201 1.3313 0.9860
    Pine/Hardwood SC  362403 1.5223 0.9898
Sierra Madre Oaks AZ  362404 1.4973 0.9886
Western Ponderosa NM  361802 1.4566 0.9847
 SW Mixed Conifer NM  361802 1.5319 0.9869
    SW Spruce-Fir NM  361802 1.5355 0.9862

Canopy mean = 1.4791   SD = 0.0781   SE = 0.0319
Manuscript reports canopy 1.479 +/- 0.080  ->  matches (mean 1.479, SD 0.078)
```

## 2. Group summary statistics

We assemble the three measurement groups — leaves (Table 2), branch/root (Table 3), and canopy (Table 4) — and compute each group's mean, standard deviation, and standard error, comparing to the manuscript's reported values (leaf $1.519$, branch $1.476$, canopy $1.479$).


```python
def group_stats(x):
    n = len(x); return n, x.mean(), x.std(ddof=1), x.std(ddof=1)/np.sqrt(n)

rows = []
for label, arr, reported in [("Leaf", leaf_vals, "1.519 +/- 0.027"),
                             ("Branch/Root", branch_vals, "1.476 +/- 0.020"),
                             ("Canopy", canopy_vals, "1.479 +/- 0.080")]:
    n, m, sd, se = group_stats(arr)
    rows.append([label, n, round(m,4), round(sd,4), round(se,4), reported])
summary = pd.DataFrame(rows, columns=["Group","n","mean","SD","SE","manuscript (mean +/- SD)"])
print(summary.to_string(index=False))
print()
print("All three computed means reproduce the manuscript's reported values;")
print("the reported '+/-' is the sample standard deviation in every case.")
```

```text
      Group  n   mean     SD     SE manuscript (mean +/- SD)
       Leaf  5 1.5194 0.0266 0.0119          1.519 +/- 0.027
Branch/Root  3 1.4757 0.0199 0.0115          1.476 +/- 0.020
     Canopy  6 1.4792 0.0781 0.0319          1.479 +/- 0.080

All three computed means reproduce the manuscript's reported values;
the reported '+/-' is the sample standard deviation in every case.
```

## 3. One-sample $t$-tests against $d = 3/2$

The classical null-hypothesis test asks whether each group mean *differs* from $1.5$. Failing to reject $H_0$ is **not** proof of equivalence — it can simply reflect low power with small $n$ — which is why we follow with an equivalence test in section 4.


```python
print("One-sample t-tests of each group mean against the MST prediction d = 3/2:")
print()
for label, arr in [("Leaf", leaf_vals), ("Branch/Root", branch_vals), ("Canopy", canopy_vals)]:
    t, p = stats.ttest_1samp(arr, 1.5)
    verdict = "reject (differs from 1.5)" if p < 0.05 else "cannot reject (consistent with 1.5)"
    print(f"  {label:12s} n={len(arr)}  mean={arr.mean():.4f}  t={t:+.3f}  p={p:.4f}  -> {verdict}")
print()
pooled = np.concatenate([leaf_vals, branch_vals, canopy_vals])
t, p = stats.ttest_1samp(pooled, 1.5)
print(f"  POOLED (all 14):  mean={pooled.mean():.4f}  t={t:+.3f}  p={p:.4f}")
print(f"  -> pooled mean is statistically indistinguishable from 1.5 (p={p:.3f}).")
```

```text
One-sample t-tests of each group mean against the MST prediction d = 3/2:

  Leaf         n=5  mean=1.5194  t=+1.630  p=0.1785  -> cannot reject (consistent with 1.5)
  Branch/Root  n=3  mean=1.4757  t=-2.116  p=0.1685  -> cannot reject (consistent with 1.5)
  Canopy       n=6  mean=1.4791  t=-0.654  p=0.5422  -> cannot reject (consistent with 1.5)

  POOLED (all 14):  mean=1.4928  t=-0.489  p=0.6333
  -> pooled mean is statistically indistinguishable from 1.5 (p=0.633).
```

## 4. TOST equivalence test against $d = 3/2$

The **two one-sided tests (TOST)** procedure reverses the burden of proof: the null hypothesis is *non-equivalence*, and rejecting it establishes that the mean lies within a pre-set equivalence margin $[1.5-\delta,\ 1.5+\delta]$. We test margins $\delta = 0.10$ and the stricter $\delta = 0.05$.

$$t_{\text{lower}} = \frac{\bar{x} - (1.5-\delta)}{\mathrm{SE}}, \qquad t_{\text{upper}} = \frac{\bar{x} - (1.5+\delta)}{\mathrm{SE}}$$

Equivalence is declared when **both** one-sided tests reject, i.e. $\max(p_{\text{lower}}, p_{\text{upper}}) < \alpha$.


```python
def tost(arr, mu=1.5, margin=0.1):
    """Two one-sided tests for equivalence within [mu-margin, mu+margin].
       Returns the (larger) of the two one-sided p-values; p < alpha => equivalent."""
    n = len(arr); mean = arr.mean(); se = arr.std(ddof=1)/np.sqrt(n); df = n-1
    low, high = mu-margin, mu+margin
    t_low  = (mean-low)/se;  p_low  = stats.t.sf(t_low, df)    # H0: mean <= low
    t_high = (mean-high)/se; p_high = stats.t.cdf(t_high, df)  # H0: mean >= high
    return max(p_low, p_high)

print("TOST equivalence tests against d = 3/2 (alpha = 0.05):")
print()
for margin in [0.10, 0.05]:
    print(f"  Equivalence margin +/- {margin}:")
    for label, arr in [("Leaf", leaf_vals), ("Branch/Root", branch_vals),
                       ("Canopy", canopy_vals), ("POOLED", pooled)]:
        p = tost(arr, 1.5, margin)
        verdict = "EQUIVALENT to 3/2" if p < 0.05 else "not shown equivalent"
        print(f"    {label:12s} TOST p = {p:.4f}  ->  {verdict}")
    print()
```

```text
TOST equivalence tests against d = 3/2 (alpha = 0.05):

  Equivalence margin +/- 0.1:
    Leaf         TOST p = 0.0012  ->  EQUIVALENT to 3/2
    Branch/Root  TOST p = 0.0112  ->  EQUIVALENT to 3/2
    Canopy       TOST p = 0.0279  ->  EQUIVALENT to 3/2
    POOLED       TOST p = 0.0000  ->  EQUIVALENT to 3/2

  Equivalence margin +/- 0.05:
    Leaf         TOST p = 0.0311  ->  EQUIVALENT to 3/2
    Branch/Root  TOST p = 0.0776  ->  not shown equivalent
    Canopy       TOST p = 0.2013  ->  not shown equivalent
    POOLED       TOST p = 0.0062  ->  EQUIVALENT to 3/2
```

## 5. Combined forest plot

All 14 observed dimensions are shown against the $d = 3/2$ reference line, with the $\pm0.05$ equivalence band shaded and each group's mean $\pm$ SD.


```python
fig, ax = plt.subplots(figsize=(9, 7))
groups = [("Leaf", leaf_vals, "#228833", "o"),
          ("Branch/Root", branch_vals, "#cc6677", "s"),
          ("Canopy", canopy_vals, "#4477aa", "^")]
y = 0; yticks = []; ylabels = []
for label, arr, color, marker in groups:
    for v in arr:
        ax.plot(v, y, marker, color=color, ms=8, alpha=0.85)
        yticks.append(y); ylabels.append(label); y += 1
    y += 0.6

ax.axvline(1.5, ls="--", color="black", lw=1.4, label="d = 3/2 (MST prediction)")
ax.axvspan(1.45, 1.55, color="gray", alpha=0.12, label="+/- 0.05 equivalence band")

# group means with SD bars, placed at the top
ytop = y + 0.5
for label, arr, color, marker in groups:
    m = arr.mean(); sd = arr.std(ddof=1)
    ax.errorbar(m, ytop, xerr=sd, fmt="D", color=color, capsize=6, ms=10,
                label=f"{label} mean {m:.3f} +/- {sd:.3f} (SD)")
    ytop += 0.9

ax.set_yticks(yticks); ax.set_yticklabels(ylabels, fontsize=8)
ax.set_xlabel("mass dimension  $d_M$"); ax.set_xlim(1.28, 1.60)
ax.set_title("All observed mass dimensions (leaf + branch/root + canopy) vs d = 3/2")
ax.legend(fontsize=8, loc="lower right"); fig.tight_layout(); plt.show()
```


![Figure 1: output of code cell 10](3_canopy_statistical_validation_files/3_canopy_statistical_validation_10_0.png)


## Summary

**What was reproduced / verified (computed):**

- **Table 4** (6 forest CHM sites) transcribed verbatim; **canopy mean $= 1.4791$, SD $= 0.0781$**, matching the manuscript's $1.479 \pm 0.080$.
- Group means reproduce the manuscript: leaf $1.5194$, branch/root $1.4757$, canopy $1.4791$.
- **One-sample $t$-tests** against $1.5$: no group's mean is significantly different from $3/2$ at $\alpha = 0.05$ (leaf $p \approx 0.18$, branch $p \approx 0.17$, canopy $p \approx 0.54$); the pooled mean $1.4928$ is statistically indistinguishable from $1.5$ ($p \approx 0.63$).
- **TOST equivalence** ($\alpha = 0.05$): at margin $\pm 0.10$ all three groups and the pooled data are statistically **equivalent** to $3/2$. At the stricter $\pm 0.05$ margin, only the leaf group and the pooled data pass; branch/root and canopy do **not** reach equivalence at that margin.

**Honest interpretation:**

- **Canopy and leaf** dimensions straddle $1.5$ (leaf mean slightly above, canopy essentially on it). **Branch/root** sits a little below $1.5$ (mean $1.476$), consistent with the manuscript's own framing.
- The pooled data support the MST prediction at the $\pm 0.10$ margin, but the stricter $\pm 0.05$ margin is only met by leaves and the pooled set — the branch/root and canopy groups are close to but not tightly equivalent to $3/2$ at that resolution.

**What is asserted vs computed:**

- The Table 4 $d_M$, $\mu r^2$, pixel counts, and site identities are **asserted from the manuscript** (the authors' FracLac output on their LiDAR CHMs; raw rasters not in the repository).
- Every mean, SD, SE, $t$-statistic, $p$-value, and TOST result is **computed** here with `scipy.stats`.

**Caveats:**

- Group sizes are small ($n = 5, 3, 6$; pooled $14$), so both the $t$-tests (low power) and the TOST (wide equivalence bounds) are limited in resolution. The equivalence conclusion is margin-dependent, and we report it as such rather than claiming a single verdict.
