"""Thin model facade for Maleki et al. (2022) Norway stand equations."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from pyforestry.base.contracts import FormulaDescriptor, SourceReference
from pyforestry.base.helpers import Age, Stand, TreeSpecies
from pyforestry.base.helpers.primitives import (
AgeMeasurement,
QuadraticMeanDiameter,
SiteIndexValue,
StandBasalArea,
StandVolume,
Stems,
)
from pyforestry.base.simulation import GrowthModel, Requirements, SimulationContext
from pyforestry.norway.growth.maleki_2022 import (
Maleki2022Species,
maleki_2022_basal_area_projection,
maleki_2022_height_trajectory,
maleki_2022_ingrowth_count,
maleki_2022_ingrowth_probability,
maleki_2022_stand_volume,
maleki_2022_stem_density,
maleki_2022_stem_survival,
)
@dataclass(frozen=True)
class _MalekiSpeciesFacade:
"""Species-scoped callable facade for Maleki equations."""
species: Maleki2022Species
def get_stand_volume(
self,
dominant_height_m: float,
basal_area: StandBasalArea | float,
age: AgeMeasurement,
) -> StandVolume:
"""Return stand volume (`m3/ha`) for this species group."""
return maleki_2022_stand_volume(self.species, dominant_height_m, basal_area, age)
def get_stem_survival(
self,
age1: AgeMeasurement,
age2: AgeMeasurement,
stems1: Stems | float,
si_h40: SiteIndexValue,
) -> Stems:
"""Return projected surviving stems for this species group."""
return maleki_2022_stem_survival(self.species, age1, age2, stems1, si_h40)
def get_stem_density(
self,
age1: AgeMeasurement,
age2: AgeMeasurement,
stems1: Stems | float,
si_h40: SiteIndexValue,
) -> Stems:
"""Return projected stem density for this species group."""
return maleki_2022_stem_density(self.species, age1, age2, stems1, si_h40)
def get_ingrowth_count(
self,
*,
basal_area: StandBasalArea | float | None = None,
qmd: QuadraticMeanDiameter | float | None = None,
) -> float:
"""Return expected ingrowth count per 5-year period."""
return maleki_2022_ingrowth_count(self.species, basal_area=basal_area, qmd=qmd)
def get_ingrowth_probability(
self,
qmd: QuadraticMeanDiameter | float,
stems: Stems | float,
) -> float:
"""Return probability of ingrowth occurrence for this species group."""
return maleki_2022_ingrowth_probability(self.species, qmd, stems)
def get_height_trajectory(
self,
dominant_height_m: float,
age1: AgeMeasurement,
age2: AgeMeasurement,
) -> float:
"""Return projected dominant height (`m`) for this species group."""
return maleki_2022_height_trajectory(self.species, dominant_height_m, age1, age2)
def get_basal_area_projection(
self,
basal_area1: StandBasalArea | float,
age1: AgeMeasurement,
age2: AgeMeasurement,
si_h40: SiteIndexValue,
stems1: Stems | float,
stems2: Stems | float,
) -> StandBasalArea:
"""Return projected basal area (`m2/ha`) for this species group."""
return maleki_2022_basal_area_projection(
self.species,
basal_area1,
age1,
age2,
si_h40,
stems1,
stems2,
)
[docs]
class Maleki2022ModelNorway:
"""Compatibility model facade matching the Norway asset shape."""
norway_spruce = _MalekiSpeciesFacade(Maleki2022Species.NORWAY_SPRUCE)
scots_pine = _MalekiSpeciesFacade(Maleki2022Species.SCOTS_PINE)
broadleaves = _MalekiSpeciesFacade(Maleki2022Species.BROADLEAVES)
# Asset-compatibility aliases.
picea_abies = norway_spruce
pinus_sylvestris = scots_pine
[docs]
@dataclass(frozen=True)
class Maleki2022Config:
"""Configuration for the simulation-facing Maleki growth adapter."""
species: Maleki2022Species = Maleki2022Species.NORWAY_SPRUCE
h40_m: float = 14.0
dominant_height_m: float = 14.0
start_total_age_years: float = 40.0
[docs]
class Maleki2022GrowthModel(GrowthModel):
"""Aggregate-inventory growth adapter using Maleki et al. (2022)."""
def __init__(self, config: Optional[Maleki2022Config] = None) -> None:
"""Store adapter configuration."""
self.config = config or Maleki2022Config()
@property
def component_id(self) -> str:
"""Stable identifier for the Maleki 2022 model."""
return "maleki_2022"
@property
def source(self) -> SourceReference:
"""Bibliographic provenance."""
return SourceReference(
author="Maleki, K., Astrup, R., Kuehne, C., McLean, J.P. & Antón-Fernández, C.",
year=2022,
title=(
"Stand-level growth models for long-term projections of the main "
"species groups in Norway"
),
note=(
"Scandinavian Journal of Forest Research 37(2):130-143. "
"doi:10.1080/02827581.2022.2056632"
),
)
[docs]
def requirements(self) -> Requirements:
"""Declare aggregate inventory requirement for the adapter."""
return Requirements(inventory="aggregate")
@staticmethod
def _species_ref(species: Maleki2022Species):
"""Return representative species metadata for H40 site-index values."""
if species is Maleki2022Species.NORWAY_SPRUCE:
return TreeSpecies.Norway.picea_abies
if species is Maleki2022Species.SCOTS_PINE:
return TreeSpecies.Norway.pinus_sylvestris
return TreeSpecies.Norway.betula_pubescens
[docs]
def build_context(
self,
stand: Stand,
*,
config: Optional[Maleki2022Config] = None,
**kwargs,
) -> SimulationContext:
"""Build and seed simulation context for Maleki projections."""
cfg = config or self.config
ctx = super().build_context(stand, **kwargs)
ctx.state["t"] = float(cfg.start_total_age_years)
ctx.attrs["maleki_species"] = cfg.species.value
ctx.attrs["maleki_h40_m"] = float(cfg.h40_m)
ctx.attrs["dominant_height_m"] = float(cfg.dominant_height_m)
# Publish the volume at t0 as well as after each step. The equation is the
# same one update_step uses; a model that reports its volume only after
# being stepped cannot state where the stand started, which is the
# opening balance of every run summary.
ctx.attrs["stand_volume_m3_per_ha"] = float(
maleki_2022_stand_volume(
cfg.species,
float(cfg.dominant_height_m),
float(ctx.metrics["BasalArea"]["TOTAL"]),
Age.TOTAL(float(cfg.start_total_age_years)),
)
)
return ctx
[docs]
def update_step(self, ctx: SimulationContext, dt: float) -> None:
"""Advance one step using Maleki stem, basal-area, and height equations."""
if dt <= 0.0:
raise ValueError("dt must be positive.")
age1 = Age.TOTAL(float(ctx.state.get("t", self.config.start_total_age_years)))
age2 = Age.TOTAL(float(age1) + dt)
species_raw = str(ctx.attrs.get("maleki_species", self.config.species.value))
species = Maleki2022Species(species_raw)
h40_m = float(ctx.attrs.get("maleki_h40_m", self.config.h40_m))
si_h40 = SiteIndexValue(
h40_m,
reference_age=Age.TOTAL(40.0),
species={self._species_ref(species)},
fn=maleki_2022_height_trajectory,
)
stems1 = float(ctx.metrics["Stems"]["TOTAL"])
ba1 = float(ctx.metrics["BasalArea"]["TOTAL"])
h1 = float(ctx.attrs.get("dominant_height_m", self.config.dominant_height_m))
stems2 = maleki_2022_stem_density(species, age1, age2, stems1, si_h40)
ba2 = maleki_2022_basal_area_projection(species, ba1, age1, age2, si_h40, stems1, stems2)
h2 = maleki_2022_height_trajectory(species, h1, age1, age2)
volume2 = maleki_2022_stand_volume(species, h2, ba2, age2)
ctx.set_aggregate_metrics(ba_total=float(ba2), stems_total=float(stems2))
ctx.attrs["dominant_height_m"] = float(h2)
ctx.attrs["stand_volume_m3_per_ha"] = float(volume2)
ctx.state["t"] = float(age2)
__all__ = [
"Maleki2022ModelNorway",
"Maleki2022Species",
"Maleki2022Config",
"Maleki2022GrowthModel",
]
DESCRIPTOR = FormulaDescriptor(
component_id="maleki_2022_model",
source=SourceReference(
author="Maleki, K., Astrup, R., Kuehne, C., McLean, J.P. & Antón-Fernández, C.",
year=2022,
title=(
"Stand-level growth models for long-term projections of the main "
"species groups in Norway"
),
note=(
"Scandinavian Journal of Forest Research 37(2):130-143. "
"doi:10.1080/02827581.2022.2056632"
),
),
kind="model",
domain="growth",
composes=("maleki_2022_growth",),
kernel_names=("Maleki2022GrowthModel",),
)