Elfving 2010 growth demo#

This notebook demonstrates a full workflow:

  1. Reconstruct a young stand using NYSKOG.

  2. Convert heights to DBH and apply sapling damage mortality.

  3. Hand over the tree list to the Elfving (2010) growth model.

  4. Update tree height (Söderberg 1992) and volume (Söderberg 1986) during growth.

  5. Grow in 5-year steps, apply thinning at age 50, and plot results.

Reproducibility: fixed random seeds and deterministic NYSKOG settings.

Notebook Objectives#

  • Show a complete growth workflow around Elfving (2010) stand development equations.

  • Provide runnable, copy-safe snippets that work in the docs build environment.

Prerequisites#

  • Python environment with pyforestry installed from this repository.

  • Execute cells in order; random components should use fixed seeds where shown.

Sources#

  • Elfving (2010) implementations in pyforestry.sweden domain packages.elfving_2010 and related helpers.

[1]:
import copy
import math
import random

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

from pyforestry.base.helpers import CircularPlot, Stand, Tree
from pyforestry.base.helpers.primitives import Age, SiteBase
from pyforestry.base.helpers.tree_species import TreeSpecies
from pyforestry.sweden.adapters.elfving_1982 import (
    HuginMeanHeightModel,
    NfiRegion,
    NyskogReconstruction,
    RegenerationType,
    nyskog_indicators_from_site,
)
from pyforestry.sweden.adapters.elfving_2010 import Elfving2010Model
from pyforestry.sweden.mortality.naslund_1986 import (
    Naslund1986DamageModel,
    SaplingSpeciesGroup,
)
from pyforestry.sweden.systems.nystrom_soderberg_1987 import NystromSoderberg1987
from pyforestry.sweden.height.soderberg_1992 import soderberg_1992_height_tree_age_m
from pyforestry.sweden.site import Sweden, SwedishSite
from pyforestry.sweden.siteindex.sis.generated_site_category_trees import (
    predict_site_categories_county_tree,
)
from pyforestry.sweden.siteindex.sis.hagglund_lundmark_1977 import Hagglund_Lundmark_1977_SIS
from pyforestry.sweden.volume.soderberg_1986_form_height import soderberg_1986_volume_m3


class SwedishSiteDemo(SwedishSite):
    """Concrete wrapper for notebooks (implements SiteBase abstract method)."""

    def compute_attributes(self) -> None:
        SwedishSite.__post_init__(self)

    def __post_init__(self) -> None:
        SiteBase.__post_init__(self)


random.seed(42)
np.random.seed(42)

1. Define a Swedish site#

[2]:
site_index_pine_m = 20.0
site_index_spruce_m = 22.0
county = Sweden.County.KOPPARBERG_OVRIGA
site_category_species = "Pinus sylvestris"
requested_sis = site_index_pine_m

predicted_site_categories = predict_site_categories_county_tree(
    sis_hagglund_1979=requested_sis,
    species=site_category_species,
    Direktlan=county,
)

site = SwedishSiteDemo(
    latitude=60.5,
    longitude=15.0,
    altitude=150.0,
    field_layer=predicted_site_categories["field_layer"],
    bottom_layer=predicted_site_categories["bottom_layer"],
    soil_texture=predicted_site_categories["soil_texture"],
    soil_moisture=predicted_site_categories["soil_moisture"],
    soil_depth=predicted_site_categories["soil_depth"],
    soil_water=predicted_site_categories["soil_water"],
    ditched=predicted_site_categories["ditched"],
)

achieved_sis = Hagglund_Lundmark_1977_SIS(
    species=site_category_species,
    latitude=site.latitude,
    altitude=site.altitude or 0.0,
    soil_moisture=site.soil_moisture,
    ground_layer=site.bottom_layer or Sweden.BottomLayer.FRESH_MOSS,
    vegetation=site.field_layer,
    soil_texture=site.soil_texture or Sweden.SoilTextureTill.SANDY,
    climate_code=site.climate_zone or Sweden.ClimateZone.K1,
    lateral_water=site.soil_water or Sweden.SoilWater.SELDOM_NEVER,
    soil_depth=site.soil_depth or Sweden.SoilDepth.DEEP,
    incline_percent=site.incline_percent or 0.0,
    aspect=site.aspect or 0.0,
    nfi_adjustments=True,
    dlan=site.county or county,
    ditched=bool(site.ditched),
    peat=False,
    gotland=False,
    coast=(site.distance_to_coast or 9999.0) < 50.0,
    limes_norrlandicus=bool(site.n_of_limes_norrlandicus),
)

sis_closeness = {
    "requested_sis": requested_sis,
    "achieved_sis": float(achieved_sis),
    "sis_abs_error": abs(float(achieved_sis) - requested_sis),
    "sis_rel_error_pct": 100.0 * abs(float(achieved_sis) - requested_sis) / requested_sis,
}

display({"sis_closeness": sis_closeness, "predicted_site_categories": predicted_site_categories})
site
{'sis_closeness': {'requested_sis': 20.0,
  'achieved_sis': 20.698654683125625,
  'sis_abs_error': 0.6986546831256248,
  'sis_rel_error_pct': 3.493273415628124},
 'predicted_site_categories': {'field_layer': <SwedenFieldLayer.LICHEN_FREQUENT: Vegetation(code=17, swedish_name='Lavrik', english_name='Lichen, frequent occurrence', index=-0.5)>,
  'bottom_layer': <SwedenBottomLayer.BOGMOSS_TYPE: BottomLayerType(code=4, english_name='Bogmoss type (Sphagnum)', swedish_name='Vitmosstyp')>,
  'soil_texture': <SwedenSoilTextureTill.SANDY_MOIG: SoilTextureCategory(code=4, swedish_name='Sandig-moig morän', english_name='Sandy-silty till', short_name='Medium sand')>,
  'soil_moisture': <SwedenSoilMoisture.MESIC: SoilMoistureData(code=2, swedish_description='frisk', english_description='Mesic (subsoil water depth = 1-2 m)')>,
  'soil_depth': <SwedenSoilDepth.DEEP: SoilDepthCat(code=1, swedish_description='Mäktigt >70 cm. Inga synliga hällar', english_description='Deep >70cm. No visible stone outcrops.')>,
  'soil_water': <SwedenSoilWater.SHORTER_PERIODS: SoilWaterCat(code=2, swedish_description='kortare perioder', english_description='Shorter periods')>,
  'ditched': False}}
[2]:
SwedishSiteDemo(latitude=60.5, longitude=15.0, altitude=150.0, field_layer=<SwedenFieldLayer.LICHEN_FREQUENT: Vegetation(code=17, swedish_name='Lavrik', english_name='Lichen, frequent occurrence', index=-0.5)>, bottom_layer=<SwedenBottomLayer.BOGMOSS_TYPE: BottomLayerType(code=4, english_name='Bogmoss type (Sphagnum)', swedish_name='Vitmosstyp')>, soil_texture=<SwedenSoilTextureTill.SANDY_MOIG: SoilTextureCategory(code=4, swedish_name='Sandig-moig morän', english_name='Sandy-silty till', short_name='Medium sand')>, soil_moisture=<SwedenSoilMoisture.MESIC: SoilMoistureData(code=2, swedish_description='frisk', english_description='Mesic (subsoil water depth = 1-2 m)')>, soil_depth=<SwedenSoilDepth.DEEP: SoilDepthCat(code=1, swedish_description='Mäktigt >70 cm. Inga synliga hällar', english_description='Deep >70cm. No visible stone outcrops.')>, soil_water=<SwedenSoilWater.SHORTER_PERIODS: SoilWaterCat(code=2, swedish_description='kortare perioder', english_description='Shorter periods')>, aspect=None, incline_percent=None, ditched=False, temperature_sum_odin1983=1215.1999999999998, county=<SwedenCounty.KOPPARBERG_OVRIGA: CountyData(code=12, label='Kopparberg (Dalarna), övriga (W övr)')>, humidity=np.float64(75.0), distance_to_coast=np.float64(254.68051713105228), climate_zone=<SwedenClimateZone.M3: ClimateZoneData(code=3, label='M3', description='Maritime, Mountain range')>, sis_spruce_100=None, sis_pine_100=None, sis_birch_50=SiteIndexValue(19.889499999999984, reference_age=AgeMeasurement(50.0, code=2 [DBH]), species={TreeName(genus=TreeGenus(name='Betula', code='BETULA'), species_name='pendula', code='BPEN'), TreeName(genus=TreeGenus(name='Betula', code='BETULA'), species_name='pubescens', code='BPUB')}, fn=eriksson_1997_height_trajectory_sweden_birch), n_of_limes_norrlandicus=True)

2. Reconstruct a young stand (NYSKOG)#

[3]:
regen_type = RegenerationType.NATURAL
species_to_plant = TreeSpecies.Sweden.pinus_sylvestris
nfi_region = NfiRegion.REG3

age_years = 12.0

mean_height_main_m = HuginMeanHeightModel.mean_height(
    age_years=age_years,
    species=species_to_plant,
    site_index_pine_m=site_index_pine_m,
    site_index_spruce_m=site_index_spruce_m,
)

indicators = nyskog_indicators_from_site(
    field_layer=site.field_layer,
    soil_moisture=site.soil_moisture,
)

# NOTE: ASINW is typically derived from the published Elfving regeneration functions (Appendix 2).
asinw = 110.0
q = NyskogReconstruction.production_potential_q(asinw)
ln_q = math.log(q)
ln_si = math.log(site_index_pine_m)

under_dimension_prob = NyskogReconstruction.udim_probability(q)
height_indicator_dm = max(15.0, 10.0 * mean_height_main_m)

stem_total = NyskogReconstruction.total_stems(
    regeneration_type=regen_type,
    mean_height_main_m=mean_height_main_m,
    q=q,
    ln_q=ln_q,
    ln_si=ln_si,
    under_dimension_prob=under_dimension_prob,
    wet=indicators["wet"],
    dry=indicators["dry"],
    height_indicator_dm=height_indicator_dm,
    deterministic=True,
)

prop_conifer = NyskogReconstruction.proportion_conifer(
    regeneration_type=regen_type,
    q=q,
    ln_qind=ln_q,
    stem_total=stem_total,
    ln_si=ln_si,
    wet=indicators["wet"],
    dry=indicators["dry"],
    rich=indicators["rich"],
    poor=indicators["poor"],
    deterministic=True,
)

prop_dom_conifer = NyskogReconstruction.dominant_conifer_share(
    regeneration_type=regen_type,
    qind=q,
    ln_si=ln_si,
    wet=indicators["wet"],
    dry=indicators["dry"],
    rich=indicators["rich"],
    poor=indicators["poor"],
    hwod=indicators["hwod"],
    hwd=indicators["hwd"],
    shrubs=indicators["shrubs"],
    lichen=indicators["lichen"],
    deterministic=True,
)

stems = NyskogReconstruction.stems_per_species(
    regeneration_type=regen_type,
    species_to_plant=species_to_plant,
    stem_total=stem_total,
    prop_conifer=prop_conifer,
    prop_dom_conifer=prop_dom_conifer,
    site_index_m=site_index_pine_m,
    nfi_region=nfi_region,
)

stems
[3]:
{'pine': 1.1237727333829194e-08,
 'spruce': 2.7121535622305318e-05,
 'contorta': 0.0,
 'larch': 0.0,
 'birch': 4.107623694305894,
 'other_broadleaf': 0.1711509872627456}
[4]:
def mean_height_secondary(species: TreeSpecies) -> float:
    site_index_m = (
        site_index_spruce_m
        if species
        in {
            TreeSpecies.Sweden.picea_abies,
            TreeSpecies.Sweden.picea_sitchensis,
            TreeSpecies.Sweden.picea_mariana,
        }
        else site_index_pine_m
    )

    return NyskogReconstruction.secondary_mean_height(
        regeneration_type=regen_type,
        secondary_species=species,
        site_index_m=site_index_m,
        mean_height_main_m=mean_height_main_m,
        herb=indicators["herb"],
        dry=indicators["dry"],
        wet=indicators["wet"],
        deterministic=True,
    )


species_map = {
    "pine": TreeSpecies.Sweden.pinus_sylvestris,
    "spruce": TreeSpecies.Sweden.picea_abies,
    "contorta": TreeSpecies.Sweden.pinus_contorta,
    "larch": TreeSpecies.Sweden.larix_sibirica,
    "birch": TreeSpecies.Sweden.betula_pendula,
    "other_broadleaf": TreeSpecies.Sweden.populus_tremula,
}

mean_heights = {
    "pine": mean_height_main_m,
    "spruce": mean_height_secondary(TreeSpecies.Sweden.picea_abies),
    "contorta": mean_height_secondary(TreeSpecies.Sweden.pinus_contorta),
    "larch": mean_height_secondary(TreeSpecies.Sweden.larix_sibirica),
    "birch": mean_height_secondary(TreeSpecies.Sweden.betula_pendula),
    "other_broadleaf": mean_height_secondary(TreeSpecies.Sweden.populus_tremula),
}

sample_n = 200

total_stems = sum(stems.values())
trees: list[Tree] = []

for label, stems_per_ha in stems.items():
    if stems_per_ha <= 0.0:
        continue
    share = stems_per_ha / total_stems
    n_trees = max(1, int(round(sample_n * share)))

    species = species_map[label]
    mean_height = mean_heights[label]
    cvh = NyskogReconstruction.height_variation(
        species=species,
        species_height_m=mean_height,
        q=q,
        ln_q=ln_q,
        self_rejuvenated=1,
        deterministic=True,
    )
    beta, shape = NyskogReconstruction.weibull_parameters(
        species=species,
        cvh=cvh,
        mean_height_m=mean_height,
    )

    heights = beta * np.random.weibull(shape, size=n_trees)
    weight = stems_per_ha / n_trees

    for height in heights:
        trees.append(
            Tree(
                species=species,
                height_m=float(height),
                weight_n=weight,
            )
        )

base_trees = copy.deepcopy(trees)
len(trees)
[4]:
202

3. Convert heights to DBH and apply sapling damage#

[5]:
def stand_metrics(tree_list: list[Tree]) -> dict[str, float]:
    heights = np.array([t.height_m or 0.0 for t in tree_list])
    weights = np.array([t.weight_n or 0.0 for t in tree_list])
    total_weight = weights.sum()
    if total_weight <= 0.0:
        return {
            "mean_height_m": 0.0,
            "total_height_sqr_m2_per_100m2": 0.0,
            "broadleaf_height_sqr_share": 0.0,
            "h_max_m": 0.0,
        }
    mean_height = float((heights * weights).sum() / total_weight)

    height_sqr_sum = float(((heights**2) * weights).sum())
    total_height_sqr_m2_per_100m2 = height_sqr_sum * 0.01

    broadleaf_species = {
        TreeSpecies.Sweden.betula_pendula,
        TreeSpecies.Sweden.betula_pubescens,
        TreeSpecies.Sweden.populus_tremula,
    }
    broadleaf_mask = np.array([t.species in broadleaf_species for t in tree_list])
    broadleaf_sqr_sum = float(((heights**2) * weights * broadleaf_mask).sum())
    if height_sqr_sum > 0.0:
        broadleaf_share = broadleaf_sqr_sum / height_sqr_sum
    else:
        broadleaf_share = 0.0

    top_heights = sorted(heights, reverse=True)[:3]
    h_max_m = float(np.mean(top_heights)) if top_heights else 0.0

    return {
        "mean_height_m": mean_height,
        "total_height_sqr_m2_per_100m2": total_height_sqr_m2_per_100m2,
        "broadleaf_height_sqr_share": broadleaf_share,
        "h_max_m": h_max_m,
    }


def sapling_group(species: TreeSpecies) -> SaplingSpeciesGroup:
    if species in {
        TreeSpecies.Sweden.pinus_sylvestris,
        TreeSpecies.Sweden.larix_sibirica,
        TreeSpecies.Sweden.larix_decidua,
        TreeSpecies.Sweden.larix_europaea_x_leptolepis,
        TreeSpecies.Sweden.larix_sukaczewii,
    }:
        return SaplingSpeciesGroup.PINE
    if species == TreeSpecies.Sweden.pinus_contorta:
        return SaplingSpeciesGroup.CONTORTA
    if species == TreeSpecies.Sweden.picea_abies:
        return SaplingSpeciesGroup.SPRUCE
    if species in {
        TreeSpecies.Sweden.betula_pendula,
        TreeSpecies.Sweden.betula_pubescens,
    }:
        return SaplingSpeciesGroup.BIRCH
    if species in {
        TreeSpecies.Sweden.populus_tremula,
        TreeSpecies.Sweden.populus_tremula_x_tremuloides,
    }:
        return SaplingSpeciesGroup.ASPEN
    return SaplingSpeciesGroup.OTHER_BROADLEAF


def apply_dbh(tree_list: list[Tree]) -> None:
    metrics = stand_metrics(tree_list)
    if metrics["mean_height_m"] <= 0.0:
        return
    for tree in tree_list:
        if tree.height_m is None or tree.species is None:
            continue
        tree.diameter_cm = NystromSoderberg1987.dbh_from_height(
            height_dm=tree.height_m * 10.0,
            species=tree.species,
            total_height_sqr_m2_per_100m2=metrics["total_height_sqr_m2_per_100m2"],
            broadleaf_height_sqr_share=metrics["broadleaf_height_sqr_share"],
            natural_regeneration=1,
            cleaning_indicator=0,
            years_since_cleaning=0,
            altitude_m=site.altitude or 0.0,
            latitude_deg=site.latitude,
            shrubs=indicators["shrubs"],
            herb_grass=indicators["herb"],
            near_coast=1 if (site.distance_to_coast or 99.0) < 5.0 else 0,
            site_index_pine_m=site_index_pine_m,
            h_max_m=metrics["h_max_m"],
            veg=0,
        )


def damage_proportions(tree_list: list[Tree]) -> dict[SaplingSpeciesGroup, float]:
    stems = {sg: 0.0 for sg in SaplingSpeciesGroup}
    height_sums = {sg: 0.0 for sg in SaplingSpeciesGroup}

    for tree in tree_list:
        if tree.species is None or tree.height_m is None:
            continue
        group = sapling_group(tree.species)
        stems[group] += tree.weight_n or 0.0
        height_sums[group] += (tree.weight_n or 0.0) * tree.height_m

    mean_heights = {
        sg: (height_sums[sg] / stems[sg] if stems[sg] > 0.0 else 0.0)
        for sg in SaplingSpeciesGroup
    }

    return Naslund1986DamageModel.damage_proportions(
        stems=stems,
        mean_heights=mean_heights,
        site_index_pine_m=site_index_pine_m,
        site_index_spruce_m=site_index_spruce_m,
        latitude_deg=site.latitude,
        altitude_m=site.altitude or 0.0,
    )


def apply_damage_mortality(
    tree_list: list[Tree],
    damage_props: dict[SaplingSpeciesGroup, float],
) -> None:
    for tree in tree_list:
        if tree.species is None:
            continue
        group = sapling_group(tree.species)
        damage_prop = damage_props.get(group, 0.0)
        damage_prop = min(1.0, max(0.0, damage_prop))
        if tree.weight_n is not None:
            tree.weight_n *= 1.0 - damage_prop


apply_dbh(trees)
apply_damage_mortality(trees, damage_proportions(trees))

for tree in trees:
    tree.age = Age.DBH(age_years)

len(trees)
[5]:
202

4. Hand over to Elfving 2010#

[6]:
plot = CircularPlot(
    id=1,
    area_m2=10000.0,
    trees=trees,
)
stand = Stand(site=site, plots=[plot])

base_attrs = {
    "site_index_m": site_index_pine_m,
    "temperature_sum_dd": 1200.0,
    "latitude_deg": site.latitude,
    "altitude_m": site.altitude or 0.0,
    "distance_to_coast_km": 50.0,
    "field_estimated_basal_area_m2_ha": 0.0,
    "thinned_0_10_years": False,
    "thinned_11_25_years": False,
    "thinned_11_30_years": False,
}

# build_context resolves the model's inputs as it builds, so the attributes it
# reads (site index among them) have to be supplied to the call rather than
# set on the context afterwards.
model = Elfving2010Model()
ctx = model.build_context(stand, mode_hint="tree_list", attrs=base_attrs)

5. Growth + thinning + Söderberg helpers#

[7]:
PINE_SET = {
    TreeSpecies.Sweden.pinus_sylvestris,
    TreeSpecies.Sweden.pinus_contorta,
    TreeSpecies.Sweden.larix_sibirica,
    TreeSpecies.Sweden.larix_decidua,
    TreeSpecies.Sweden.larix_europaea_x_leptolepis,
    TreeSpecies.Sweden.larix_sukaczewii,
}
SPRUCE_SET = {
    TreeSpecies.Sweden.picea_abies,
    TreeSpecies.Sweden.picea_sitchensis,
    TreeSpecies.Sweden.picea_mariana,
}
BIRCH_SET = {
    TreeSpecies.Sweden.betula_pendula,
    TreeSpecies.Sweden.betula_pubescens,
}
BEECH_SET = {TreeSpecies.Sweden.fagus_sylvatica}
OAK_SET = {
    TreeSpecies.Sweden.quercus_robur,
    TreeSpecies.Sweden.quercus_petraea,
    TreeSpecies.Sweden.quercus_rubra,
}


def infer_part_of_sweden(latitude_deg: float) -> str:
    if latitude_deg < 57.0:
        return "south"
    if latitude_deg < 59.0:
        return "middle"
    return "north"


def soderberg_climate_flags() -> tuple[bool, bool]:
    climate_zone = getattr(site, "climate_zone", None)
    if climate_zone is None:
        return False, False
    label = climate_zone.value.label
    return label.startswith("M"), label.startswith("K")


def basal_area_m2_ha(tree_list: list[Tree]) -> float:
    total = 0.0
    for tree in tree_list:
        d = tree.diameter_cm or 0.0
        w = tree.weight_n or 0.0
        total += (math.pi * (d / 200.0) ** 2) * w
    return float(total)


def soderberg_stand_context(tree_list: list[Tree]) -> dict[str, object]:
    ba_total = basal_area_m2_ha(tree_list)
    max_diameter = max((t.diameter_cm or 0.0 for t in tree_list), default=0.0)

    species_ba: dict[TreeSpecies, float] = {}
    group_ba = {"pine": 0.0, "spruce": 0.0, "birch": 0.0, "beech": 0.0, "oak": 0.0}

    for tree in tree_list:
        if tree.species is None:
            continue
        d = tree.diameter_cm or 0.0
        w = tree.weight_n or 0.0
        ba = (math.pi * (d / 200.0) ** 2) * w
        if ba <= 0.0:
            continue

        species_ba[tree.species] = species_ba.get(tree.species, 0.0) + ba

        if tree.species in PINE_SET:
            group_ba["pine"] += ba
        elif tree.species in SPRUCE_SET:
            group_ba["spruce"] += ba
        elif tree.species in BIRCH_SET:
            group_ba["birch"] += ba
        elif tree.species in BEECH_SET:
            group_ba["beech"] += ba
        elif tree.species in OAK_SET:
            group_ba["oak"] += ba

    dominant_species = (
        max(species_ba.items(), key=lambda kv: kv[1])[0]
        if species_ba
        else TreeSpecies.Sweden.pinus_sylvestris
    )
    site_index_dominant_m = (
        site_index_spruce_m if dominant_species in SPRUCE_SET else site_index_pine_m
    )
    near_coast = (site.distance_to_coast or 999.0) < 50.0
    maritime, continental = soderberg_climate_flags()

    return {
        "stand_basal_area_m2_ha": ba_total,
        "max_diameter_cm": max_diameter,
        "dominant_species": dominant_species,
        "site_index_dominant_m": site_index_dominant_m,
        "part_of_sweden": infer_part_of_sweden(site.latitude),
        "near_coast": near_coast,
        "maritime": maritime,
        "continental": continental,
        "prop_pine": group_ba["pine"] / ba_total if ba_total > 0.0 else 0.0,
        "prop_spruce": group_ba["spruce"] / ba_total if ba_total > 0.0 else 0.0,
        "prop_birch": group_ba["birch"] / ba_total if ba_total > 0.0 else 0.0,
        "prop_beech": group_ba["beech"] / ba_total if ba_total > 0.0 else 0.0,
        "prop_oak": group_ba["oak"] / ba_total if ba_total > 0.0 else 0.0,
    }


def apply_soderberg_height_volume(
    tree_list: list[Tree],
    *,
    fallback_age_years: float,
) -> None:
    stand_ctx = soderberg_stand_context(tree_list)
    max_diameter = float(stand_ctx["max_diameter_cm"])

    if max_diameter <= 0.0:
        for tree in tree_list:
            tree.height_m = 0.0
            tree.volume_m3 = 0.0
        return

    for tree in tree_list:
        if tree.species is None:
            continue

        d = tree.diameter_cm or 0.0
        if d <= 0.0:
            tree.height_m = 0.0
            tree.volume_m3 = 0.0
            continue

        age_bh_years = float(tree.age) if tree.age is not None else float(fallback_age_years)
        age_bh_years = max(2.0, age_bh_years)

        tree.height_m = soderberg_1992_height_tree_age_m(
            species=tree.species,
            diameter_cm=d,
            tree_age_bh_years=age_bh_years,
            max_diameter_cm=max_diameter,
            stand_basal_area_m2_ha=float(stand_ctx["stand_basal_area_m2_ha"]),
            dominant_species=stand_ctx["dominant_species"],
            site_index_dominant_m=float(stand_ctx["site_index_dominant_m"]),
            latitude_deg=site.latitude,
            altitude_m=site.altitude or 0.0,
            prop_pine=float(stand_ctx["prop_pine"]),
            prop_spruce=float(stand_ctx["prop_spruce"]),
            prop_birch=float(stand_ctx["prop_birch"]),
            prop_beech=float(stand_ctx["prop_beech"]),
            part_of_sweden=str(stand_ctx["part_of_sweden"]),
            maritime=bool(stand_ctx["maritime"]),
            continental=bool(stand_ctx["continental"]),
            near_coast=bool(stand_ctx["near_coast"]),
            south_east=False,
            region5=False,
            split_plot=False,
        )

        tree.volume_m3 = soderberg_1986_volume_m3(
            species=tree.species,
            diameter_cm=d,
            age_bh_years=age_bh_years,
            max_diameter_cm=max_diameter,
            stand_basal_area_m2_ha=float(stand_ctx["stand_basal_area_m2_ha"]),
            dominant_species=stand_ctx["dominant_species"],
            site_index_dominant_m=float(stand_ctx["site_index_dominant_m"]),
            latitude_deg=site.latitude,
            altitude_m=site.altitude or 0.0,
            part_of_sweden=str(stand_ctx["part_of_sweden"]),
            distance_to_coast_lt_50km=bool(stand_ctx["near_coast"]),
            split_plot=False,
            crowberry=site.field_layer == Sweden.FieldLayer.CROWBERRY,
            south_slope=False,
            wet_soil=bool(indicators["wet"]),
            fertilized=False,
            herbs=bool(indicators["herb"]),
            maritime=bool(stand_ctx["maritime"]),
            region5=False,
            continental=bool(stand_ctx["continental"]),
            north_slope=False,
            dry_soil=bool(indicators["dry"]),
            south_east=False,
            groundwater_never=False,
            prop_pine=float(stand_ctx["prop_pine"]),
            prop_spruce=float(stand_ctx["prop_spruce"]),
            prop_birch=float(stand_ctx["prop_birch"]),
            prop_beech=float(stand_ctx["prop_beech"]),
            prop_oak=float(stand_ctx["prop_oak"]),
        )


def summarize_stand(tree_list: list[Tree], age: float) -> dict[str, float]:
    weights = np.array([t.weight_n or 0.0 for t in tree_list])
    diameters = np.array([t.diameter_cm or 0.0 for t in tree_list])
    heights = np.array([t.height_m or 0.0 for t in tree_list])
    volumes = np.array([getattr(t, "volume_m3", 0.0) for t in tree_list])
    total_w = float(weights.sum())

    if total_w <= 0.0:
        return {
            "age_years": age,
            "mean_dbh_cm": 0.0,
            "mean_height_m": 0.0,
            "qmd_cm": 0.0,
            "hq_m": 0.0,
            "basal_area_m2_ha": 0.0,
            "volume_m3_ha": 0.0,
            "stems_per_ha": 0.0,
            "pine_share": 0.0,
            "spruce_share": 0.0,
            "birch_share": 0.0,
            "pine_mean_height_m": 0.0,
            "spruce_mean_height_m": 0.0,
            "birch_mean_height_m": 0.0,
        }

    mean_dbh = float((diameters * weights).sum() / total_w)
    mean_height = float((heights * weights).sum() / total_w)
    ba_total = basal_area_m2_ha(tree_list)
    qmd_cm = math.sqrt((40000.0 * ba_total) / (math.pi * total_w)) if ba_total > 0.0 else 0.0
    volume_total = float((volumes * weights).sum())

    valid = (diameters > 0.0) & (heights > 0.0) & (weights > 0.0)
    if qmd_cm > 0.0 and np.any(valid):
        d_bins_cm = np.round(diameters[valid], 1)
        h_valid = heights[valid]
        w_valid = weights[valid]

        d_unique_cm, inverse = np.unique(d_bins_cm, return_inverse=True)
        h_weighted_sum = np.zeros(d_unique_cm.shape, dtype=float)
        w_sum = np.zeros(d_unique_cm.shape, dtype=float)
        np.add.at(h_weighted_sum, inverse, h_valid * w_valid)
        np.add.at(w_sum, inverse, w_valid)

        h_mean_by_bin = np.divide(
            h_weighted_sum,
            w_sum,
            out=np.zeros_like(h_weighted_sum),
            where=w_sum > 0.0,
        )

        if d_unique_cm.size == 1:
            hq_m = float(h_mean_by_bin[0])
        else:
            q_eval_cm = float(np.clip(qmd_cm, d_unique_cm[0], d_unique_cm[-1]))
            hq_m = float(np.interp(q_eval_cm, d_unique_cm, h_mean_by_bin))
    else:
        hq_m = 0.0

    group_ba = {"pine": 0.0, "spruce": 0.0, "birch": 0.0}
    group_height_sum = {"pine": 0.0, "spruce": 0.0, "birch": 0.0}
    group_weight = {"pine": 0.0, "spruce": 0.0, "birch": 0.0}
    for tree in tree_list:
        d = tree.diameter_cm or 0.0
        w = tree.weight_n or 0.0
        h = tree.height_m or 0.0
        ba = (math.pi * (d / 200.0) ** 2) * w
        if tree.species in PINE_SET:
            group_ba["pine"] += ba
            group_height_sum["pine"] += h * w
            group_weight["pine"] += w
        elif tree.species in SPRUCE_SET:
            group_ba["spruce"] += ba
            group_height_sum["spruce"] += h * w
            group_weight["spruce"] += w
        elif tree.species in BIRCH_SET:
            group_ba["birch"] += ba
            group_height_sum["birch"] += h * w
            group_weight["birch"] += w

    def _mean_height(group: str) -> float:
        return group_height_sum[group] / group_weight[group] if group_weight[group] > 0 else 0.0

    return {
        "age_years": age,
        "mean_dbh_cm": mean_dbh,
        "mean_height_m": mean_height,
        "qmd_cm": qmd_cm,
        "hq_m": hq_m,
        "basal_area_m2_ha": ba_total,
        "volume_m3_ha": volume_total,
        "stems_per_ha": total_w,
        "pine_share": group_ba["pine"] / ba_total if ba_total > 0 else 0.0,
        "spruce_share": group_ba["spruce"] / ba_total if ba_total > 0 else 0.0,
        "birch_share": group_ba["birch"] / ba_total if ba_total > 0 else 0.0,
        "pine_mean_height_m": _mean_height("pine"),
        "spruce_mean_height_m": _mean_height("spruce"),
        "birch_mean_height_m": _mean_height("birch"),
    }
def increment_tree_ages(tree_list: list[Tree], dt: float) -> None:
    for tree in tree_list:
        if tree.age is None:
            continue
        tree.age = Age.DBH(float(tree.age) + dt)


def apply_thinning(
    tree_list: list[Tree],
    *,
    target_ba_reduction: float = 0.30,
) -> None:
    total_ba = basal_area_m2_ha(tree_list)
    if total_ba <= 0.0 or target_ba_reduction <= 0.0:
        return

    target_remove = total_ba * target_ba_reduction
    removed = 0.0

    tree_list.sort(key=lambda t: t.diameter_cm or 0.0)

    for tree in tree_list:
        d = tree.diameter_cm or 0.0
        w = tree.weight_n or 0.0
        ba = (math.pi * (d / 200.0) ** 2) * w
        if removed >= target_remove:
            break
        if ba <= 0:
            continue
        remaining = target_remove - removed
        if ba <= remaining:
            tree.weight_n = 0.0
            removed += ba
        else:
            keep_ratio = (ba - remaining) / ba
            tree.weight_n = w * keep_ratio
            removed = target_remove
            break

    tree_list[:] = [t for t in tree_list if (t.weight_n or 0.0) > 0.0]


def update_thinning_flags(
    ctx,
    *,
    years_since_thinning: float | None,
) -> None:
    if years_since_thinning is None:
        ctx.attrs["thinned_0_10_years"] = False
        ctx.attrs["thinned_11_25_years"] = False
        ctx.attrs["thinned_11_30_years"] = False
        return

    years = float(years_since_thinning)
    ctx.attrs["thinned_0_10_years"] = 0.0 <= years <= 10.0
    ctx.attrs["thinned_11_25_years"] = 10.0 < years <= 25.0
    ctx.attrs["thinned_11_30_years"] = 10.0 < years <= 30.0

6. Run growth with thinning at age 50#

[8]:
end_age = age_years + 100.0
step_years = 5.0
thin_at_age = 50.0

years_elapsed = 0.0
current_age = age_years
last_thin_age: float | None = None

ctx.attrs["field_estimated_basal_area_m2_ha"] = basal_area_m2_ha(ctx.plots[0].trees)
update_thinning_flags(ctx, years_since_thinning=None)

records: list[dict[str, float]] = []

while True:
    apply_soderberg_height_volume(
        ctx.plots[0].trees,
        fallback_age_years=current_age,
    )
    records.append(summarize_stand(ctx.plots[0].trees, current_age))

    if current_age >= end_age:
        break

    dt = min(step_years, end_age - current_age)
    if last_thin_age is None and current_age < thin_at_age < current_age + dt:
        dt = thin_at_age - current_age

    # Update local field-estimated BA every growth step (angle-count proxy).
    ctx.attrs["field_estimated_basal_area_m2_ha"] = basal_area_m2_ha(ctx.plots[0].trees)
    years_since_thinning = None if last_thin_age is None else current_age - last_thin_age
    update_thinning_flags(ctx, years_since_thinning=years_since_thinning)

    ctx.update_step(dt)
    increment_tree_ages(ctx.plots[0].trees, dt)

    years_elapsed += dt
    current_age += dt

    if last_thin_age is None and math.isclose(current_age, thin_at_age):
        apply_thinning(ctx.plots[0].trees, target_ba_reduction=0.30)
        last_thin_age = current_age

        # Re-estimate local BA after thinning and rebuild context to refresh metrics.
        local_ba_after_thin = basal_area_m2_ha(ctx.plots[0].trees)
        stand = Stand(site=site, plots=ctx.plots)
        ctx = model.build_context(stand, mode_hint="tree_list", attrs=base_attrs)
        update_thinning_flags(ctx, years_since_thinning=0.0)
        ctx.attrs["field_estimated_basal_area_m2_ha"] = local_ba_after_thin
        ctx.state["t"] = years_elapsed
        ctx.state["years_since_thin"] = 0.0

results = pd.DataFrame(records)
results

/home/runner/work/pyforestry/pyforestry/src/pyforestry/base/simulation/core.py:261: UserWarning: Elfving growth scaled linearly from 5-year period.
  self.model.update_step(self, years)
[8]:
age_years mean_dbh_cm mean_height_m qmd_cm hq_m basal_area_m2_ha volume_m3_ha stems_per_ha pine_share spruce_share birch_share pine_mean_height_m spruce_mean_height_m birch_mean_height_m
0 12.0 1.395086 2.470412 2.265931 4.124162 0.000955 0.002862 2.368427 0.0 0.000043 0.984297 0.0 4.113981 2.475246
1 17.0 2.329235 2.980948 3.368532 4.715803 0.002111 0.006889 2.368427 0.0 0.000042 0.984779 0.0 5.535170 2.984977
2 22.0 3.211238 3.507309 4.468189 5.465801 0.003714 0.013223 2.368427 0.0 0.000042 0.983933 0.0 6.963704 3.509782
3 27.0 4.071547 4.032469 5.594204 5.101074 0.005821 0.022753 2.368427 0.0 0.000057 0.982735 0.0 9.201403 4.034062
4 32.0 4.923954 4.541554 6.743671 6.937440 0.008459 0.036102 2.368427 0.0 0.000069 0.981420 0.0 11.240370 4.542921
5 37.0 5.818204 5.039912 7.978673 7.718284 0.011842 0.054841 2.368427 0.0 0.000077 0.977152 0.0 12.906758 5.039005
6 42.0 6.769560 5.531740 9.298358 8.535502 0.016083 0.080074 2.368427 0.0 0.000081 0.971319 0.0 14.251347 5.527325
7 47.0 7.792975 6.023089 10.703169 9.327913 0.021310 0.112976 2.368427 0.0 0.000083 0.965514 0.0 15.342692 6.015745
8 50.0 7.173664 4.842055 11.663715 10.879386 0.017577 0.101900 1.645017 0.0 0.000117 0.946805 0.0 15.897510 4.800170
9 55.0 8.059591 5.157194 13.075046 11.815321 0.022088 0.133821 1.645017 0.0 0.000117 0.940498 0.0 16.708738 5.111840
10 60.0 8.915445 5.434183 14.442628 12.640876 0.026950 0.169447 1.645017 0.0 0.000117 0.935151 0.0 17.391539 5.386674
11 65.0 9.741326 5.679285 15.764860 13.372407 0.032110 0.208364 1.645017 0.0 0.000116 0.930805 0.0 17.974259 5.630680
12 70.0 10.537943 5.897690 17.041528 14.024229 0.037521 0.250177 1.645017 0.0 0.000116 0.927402 0.0 18.477562 5.848807
13 75.0 11.306399 6.093636 18.273457 14.608443 0.043142 0.294524 1.645017 0.0 0.000115 0.924828 0.0 18.916658 6.045094
14 80.0 12.048020 6.270569 19.462170 15.135120 0.048938 0.341084 1.645017 0.0 0.000115 0.922956 0.0 19.302907 6.222828
15 85.0 12.764224 6.431291 20.609605 15.612596 0.054878 0.389580 1.645017 0.0 0.000115 0.921659 0.0 19.644944 6.384698
16 90.0 13.456447 6.578092 21.717899 16.047765 0.060939 0.439769 1.645017 0.0 0.000114 0.920826 0.0 19.949450 6.532907
17 95.0 14.126088 6.712856 22.789244 16.446347 0.067100 0.491442 1.645017 0.0 0.000114 0.920361 0.0 20.221679 6.669274
18 100.0 14.774483 6.837140 23.825798 16.813101 0.073342 0.544419 1.645017 0.0 0.000113 0.920187 0.0 20.465839 6.795310
19 105.0 15.402888 6.952241 24.829631 17.152010 0.079653 0.598544 1.645017 0.0 0.000113 0.920239 0.0 20.685344 6.912277
20 110.0 16.012478 7.059248 25.802698 17.466423 0.086018 0.653679 1.645017 0.0 0.000113 0.920466 0.0 20.883007 7.021238
21 112.0 16.249224 7.099757 26.180319 17.585234 0.088554 0.675800 1.645017 0.0 0.000113 0.920615 0.0 20.956447 7.062551

7. Plot summary results#

[9]:
fig, axes = plt.subplots(2, 3, figsize=(13, 8), sharex=True)
axes = axes.flatten()

axes[0].plot(results["age_years"], results["qmd_cm"], marker="o")
axes[0].set_ylabel("QMD (cm)")

axes[1].plot(results["age_years"], results["basal_area_m2_ha"], marker="o")
axes[1].set_ylabel("Basal area (m²/ha)")

axes[2].plot(results["age_years"], results["volume_m3_ha"], marker="o")
axes[2].set_ylabel("Volume (m³/ha)")

axes[3].plot(results["age_years"], results["stems_per_ha"], marker="o")
axes[3].set_ylabel("Stems (n/ha)")

axes[4].plot(results["age_years"], results["hq_m"], marker="o")
axes[4].set_ylabel("HQ (m)")

axes[5].plot(results["age_years"], results["pine_share"], label="Pine")
axes[5].plot(results["age_years"], results["spruce_share"], label="Spruce")
axes[5].plot(results["age_years"], results["birch_share"], label="Birch")
axes[5].set_ylabel("BA share")
axes[5].legend(loc="best")

for ax in axes:
    ax.axvline(thin_at_age, color="tab:red", linestyle="--", alpha=0.6)
    ax.grid(True, alpha=0.3)

axes[3].set_xlabel("Age (years)")
axes[4].set_xlabel("Age (years)")
axes[5].set_xlabel("Age (years)")
fig.suptitle(
    "Elfving 2010 growth with Söderberg height/volume and thinning at age 50",
    fontsize=12,
)
fig.tight_layout()


fig2, ax2 = plt.subplots(1, 1, figsize=(8, 4))
ax2.plot(results["age_years"], results["pine_mean_height_m"], label="Pine")
ax2.plot(results["age_years"], results["spruce_mean_height_m"], label="Spruce")
ax2.plot(results["age_years"], results["birch_mean_height_m"], label="Birch")
ax2.set_ylabel("Mean height (m)")
ax2.set_xlabel("Age (years)")
ax2.axvline(thin_at_age, color="tab:red", linestyle="--", alpha=0.6)
ax2.grid(True, alpha=0.3)
ax2.legend(loc="best")
fig2.suptitle("Söderberg 1992 mean height by species", fontsize=12)
fig2.tight_layout()

../_images/notebooks_elfving_2010_17_0.png
../_images/notebooks_elfving_2010_17_1.png