Source code for fresh_fvsbc.nemora

"""The `nemora` adapter.

``docs/reference/nemora-ingest.rst`` is the contract. It takes a synthetic stem population shaped
like `nemora`'s output and returns the tree-list fields that :mod:`fresh_fvsbc.treelist` writes.

Three things the adapter cannot take from the population
--------------------------------------------------------

`nemora` emits no species and no tree age, and FVS-BC needs both, so the caller supplies them. The
adapter fails when either is missing rather than choosing one, which is what the contract requires;
a defaulted species would quietly model the wrong tree.

The plot-level fields — slope, aspect, habitat, topographic position, and site preparation — are
properties of a run rather than of a stem population, so the contract takes them from the keyword
set. This adapter writes the values it is given for them, and zero when it is given none, because a
tree record has to carry something in those columns and the keyword set supplies the run's own.

The crown ratio is a conversion, not a copy
-------------------------------------------

The two sides are not the same shape. `nemora` emits a **fraction**, and the tree-list field is a
**crown class, 1 to 9**, which FVS expands to a percent crown ratio by ``n*10-5`` — see
:mod:`fresh_fvsbc.treelist` and the contract for the source. So class 7 means 65 percent.

The adapter takes the class whose expanded percentage is nearest the fraction's, and **refuses a
fraction it cannot represent**. FVS's own arithmetic sets the reachable band: class 9 reaches 85
percent and class 10 would be needed above it, which the field's single digit cannot hold. So a
fraction at or above 0.9 is refused rather than rounded down, because rounding it down would
silently understate a crown by up to five percent.
"""

from __future__ import annotations

import math
from collections.abc import Mapping, Sequence
from typing import TypedDict

from fresh_fvsbc.treelist import TreeListFields, resolve_species

__all__ = [
    "CROWN_CLASS_MAX",
    "CROWN_CLASS_MIN",
    "CrownRatioError",
    "MissingAssignmentError",
    "PlotFields",
    "PopulationError",
    "crown_class",
    "crown_percent",
    "to_tree_list",
]

#: The reachable crown classes. FVS treats a value below 10 as a class and expands it by ``n*10-5``.
CROWN_CLASS_MIN: int = 1
CROWN_CLASS_MAX: int = 9


#: The tree-list fields a run's keyword set supplies, which the adapter passes through.
[docs] class PlotFields(TypedDict, total=False): """The plot-level fields, which belong to a run rather than to a stem population.""" plot_slope: int plot_aspect: int plot_habitat: int plot_topo_pos: int plot_site_prep: int
#: The plot-level fields, with the value used when the caller supplies none. _PLOT_FIELDS: tuple[str, ...] = ( "plot_slope", "plot_aspect", "plot_habitat", "plot_topo_pos", "plot_site_prep", )
[docs] class PopulationError(ValueError): """A population the adapter cannot turn into a tree list."""
[docs] class MissingAssignmentError(PopulationError): """An input the caller must supply, which `nemora` does not emit."""
[docs] class CrownRatioError(PopulationError): """A crown ratio that the one-digit tree-list field cannot carry."""
[docs] def crown_percent(crown_class_value: int) -> int: """Return the percent crown ratio FVS expands a class to. Parameters ---------- crown_class_value : int A crown class, 1 to 9. Returns ------- int ``n*10-5``, which is what ``base/intree.f`` computes for a value below 10. Raises ------ CrownRatioError If the class is outside 1 to 9. """ if not CROWN_CLASS_MIN <= crown_class_value <= CROWN_CLASS_MAX: raise CrownRatioError( f"{crown_class_value} is not a crown class; the field holds " f"{CROWN_CLASS_MIN} to {CROWN_CLASS_MAX}" ) return crown_class_value * 10 - 5
[docs] def crown_class(crown_ratio: float) -> int: """Return the crown class nearest a fraction. Parameters ---------- crown_ratio : float A crown ratio as a fraction, which is the shape `nemora` emits. Returns ------- int The crown class whose expanded percentage is nearest, by :func:`crown_percent`. Raises ------ CrownRatioError If the fraction is outside 0 to 1, or if the nearest class falls outside 1 to 9. The second is the case the contract calls a value the one-digit field cannot represent: a fraction at or above 0.9 would need class 10, and rounding it down would understate a crown by up to five percent, so it is refused rather than approximated. """ if not 0.0 <= crown_ratio <= 1.0: raise CrownRatioError(f"{crown_ratio!r} is not a crown ratio; it is a fraction, 0 to 1") percent = crown_ratio * 100.0 nearest = math.floor((percent + 5.0) / 10.0 + 0.5) if not CROWN_CLASS_MIN <= nearest <= CROWN_CLASS_MAX: raise CrownRatioError( f"a crown ratio of {crown_ratio!r} needs crown class {nearest}, and the field holds " f"{CROWN_CLASS_MIN} to {CROWN_CLASS_MAX}" ) return nearest
#: The most trees one plot can carry. BC's tree record opens ``I4,T1,I7``, and ``T1`` makes the #: tree number read from column 1, so the plot's four columns and the tree number share the same #: digits. This writer places the tree number in columns 5 to 7, which leaves three digits. MAX_TREES_PER_PLOT: int = 999 def _text(record: Mapping[str, object], field: str) -> str: """Return a population field as text, refusing a record that does not carry it.""" if field not in record: raise PopulationError(f"the population record has no {field!r}") return str(record[field]) def _number(record: Mapping[str, object], field: str) -> float: """Return a population field as a number, refusing a record that does not carry it.""" if field not in record: raise PopulationError(f"the population record has no {field!r}") value = record[field] if isinstance(value, bool) or not isinstance(value, int | float | str): raise PopulationError(f"the population field {field!r} is not a number: {value!r}") return float(value)
[docs] def to_tree_list( population: Sequence[Mapping[str, object]], *, species: str | None = None, tree_age: float | None = None, plot_fields: PlotFields | None = None, ) -> list[TreeListFields]: """Turn a synthetic stem population into tree-list fields. Parameters ---------- population : Sequence of Mapping The stems, shaped like `nemora`'s output. Only `stand_id`, `dbh_cm`, `height_m`, and `crown_ratio` are read; the spatial fields have no home in an FVS tree list, and the attribute fields are not columns FVS-BC reads. species : str, optional The species code, from any of BC's three lists. Required: `nemora` emits none, and the adapter fails rather than defaulting. tree_age : float, optional The age at breast height, in years. Required, for the same reason. plot_fields : PlotFields, optional The plot-level fields, which belong to a run. Each defaults to zero. Returns ------- list of TreeListFields One record per stem, ready for :func:`fresh_fvsbc.treelist.write_tree_record`. Raises ------ MissingAssignmentError If ``species`` or ``tree_age`` is not supplied. PopulationError If a record is missing a field the adapter reads, or carries a non-numeric one. CrownRatioError If a crown ratio cannot be represented by the tree-list field. SpeciesError If the species code resolves to no BC species, or to more than one. """ if species is None: raise MissingAssignmentError( "the adapter needs a species, because nemora emits none; pass one from BC's three lists" ) if tree_age is None: raise MissingAssignmentError( "the adapter needs a tree age, because nemora emits none; pass one in years" ) per_stand: dict[str, int] = {} for stem in population: stand = _text(stem, "stand_id") per_stand[stand] = per_stand.get(stand, 0) + 1 oversized = {stand: count for stand, count in per_stand.items() if count > MAX_TREES_PER_PLOT} if oversized: stand, count = sorted(oversized.items())[0] raise PopulationError( f"the stand {stand!r} carries {count} stems and one plot holds at most " f"{MAX_TREES_PER_PLOT}: BC's tree record shares columns 1 to 4 between the plot " "and the tree number, so a plot can only number 999 trees. Subdivide the stand into " "plots, which is a modelling decision this adapter does not make for you" ) resolved = resolve_species(species) supplied = plot_fields or {} records: list[TreeListFields] = [] plots: dict[str, int] = {} tree_numbers: dict[str, int] = {} for stem in population: stand = _text(stem, "stand_id") plot_id = plots.setdefault(stand, len(plots) + 1) tree_number = tree_numbers.get(stand, 0) + 1 tree_numbers[stand] = tree_number record: TreeListFields = { "plot_id": plot_id, "tree_num": tree_number, "tree_count": 1.0, "tree_history": 0, "species": resolved, "diam": _number(stem, "dbh_cm"), "dg_incr": 0.0, "live_ht": _number(stem, "height_m"), "trunc_ht": 0.0, "htg_incr": 0.0, "crown_ratio": crown_class(_number(stem, "crown_ratio")), "damage1": 0, "severity1": 0, "damage2": 0, "severity2": 0, "damage3": 0, "severity3": 0, "tree_val": 0, "tree_rx": 0, "plot_slope": int(supplied.get("plot_slope", 0)), "plot_aspect": int(supplied.get("plot_aspect", 0)), "plot_habitat": int(supplied.get("plot_habitat", 0)), "plot_topo_pos": int(supplied.get("plot_topo_pos", 0)), "plot_site_prep": int(supplied.get("plot_site_prep", 0)), "tree_age": float(tree_age), } records.append(record) return records