"""Abstract helpers shared between bucking algorithms."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Any, Iterator, List
import numpy as np
from pyforestry.base.taper import Taper
[docs]
class Bucker:
"""Simple base class for bucking implementations."""
def __init__(self) -> None:
"""Initialize a new bucker instance."""
[docs]
class QualityType(IntEnum):
"""Possible quality classes for bucked logs."""
Undefined = 0
ButtLog = 1
MiddleLog = 2
TopLog = 3
Pulp = 4
LogCull = 5
Fuelwood = 6
[docs]
@dataclass
class CrossCutSection:
"""Container for one section in a cutting solution."""
start_point: int
end_point: int
volume: float
top_diameter: float
value: float
species_group: str
timber_proportion: float = 0.0
pulp_proportion: float = 0.0
cull_proportion: float = 0.0
fuelwood_proportion: float = 0.0
quality: QualityType = QualityType.Undefined
[docs]
@dataclass(frozen=True)
class BuckingConfig:
"""Settings that modify bucking algorithm behaviour."""
timber_price_factor: float = 1.0
pulp_price_factor: float = 1.0
use_downgrading: bool = False
save_sections: bool = False
class _TreeCache:
"""Cache for tree taper calculations."""
def __init__(self) -> None:
"""Initialise empty caches for diameter and height lookups."""
self._diameters: dict[int, float] = {}
self._heights: dict[float, int] = {}
def diameter(self, taper: Taper, height: int) -> float:
"""Return the diameter at ``height`` (dm) caching the result."""
if height not in self._diameters:
self._diameters[height] = taper.get_diameter_at_height(height / 10.0)
return self._diameters[height]
def height(self, taper: Taper, target: float) -> int:
"""Return the height (dm) at ``target`` diameter using a cache."""
if target not in self._heights:
self._heights[target] = int(taper.get_height_at_diameter(target) * 10)
return self._heights[target]
[docs]
@dataclass
class BuckingResult(Mapping):
"""Encapsulates the output of a bucking algorithm."""
species_group: str
total_value: float
top_proportion: float
dead_wood_proportion: float
high_stump_volume_proportion: float
high_stump_value_proportion: float
last_cut_relative_height: float
volume_per_quality: List[float]
timber_price_by_quality: List[float]
vol_fub_5cm: float
vol_sk_ub: float
dbh_cm: float
height_m: float
stump_height_m: float
diameter_stump_cm: float
taper_diameters_cm: List[float]
taper_heights_m: List[float]
sections: List[CrossCutSection] | None = field(default_factory=list)
[docs]
@classmethod
def zero(cls, **known: Any) -> "BuckingResult":
"""Return a well-formed zero-value result for a stem with no merchantable bucking.
Every value/volume field defaults to zero and the taper arrays to empty, so a
degenerate stem yields a fully-constructed :class:`BuckingResult` with
``total_value == 0`` instead of a partially-built object. Any geometry actually
known at the call site (``species_group``, ``dbh_cm``, ``height_m``,
``taper_diameters_cm`` ...) may be passed as keyword arguments to override the
corresponding defaults. The per-quality arrays are sized to ``QualityType`` so
they stay indexable by quality value.
"""
defaults: dict[str, Any] = {
"species_group": "",
"total_value": 0.0,
"top_proportion": 0.0,
"dead_wood_proportion": 0.0,
"high_stump_volume_proportion": 0.0,
"high_stump_value_proportion": 0.0,
"last_cut_relative_height": 0.0,
"volume_per_quality": [0.0] * len(QualityType),
"timber_price_by_quality": [0.0] * len(QualityType),
"vol_fub_5cm": 0.0,
"vol_sk_ub": 0.0,
"dbh_cm": 0.0,
"height_m": 0.0,
"stump_height_m": 0.0,
"diameter_stump_cm": 0.0,
"taper_diameters_cm": [],
"taper_heights_m": [],
"sections": None,
}
defaults.update(known)
return cls(**defaults)
def __getitem__(self, key: str) -> Any:
"""Return attribute ``key`` or raise ``KeyError``."""
if hasattr(self, key):
return getattr(self, key)
raise KeyError(key)
def __iter__(self) -> Iterator[str]:
"""Iterate over attribute names."""
return iter(self.__dict__)
def __len__(self) -> int:
"""Return the number of stored attributes."""
return len(self.__dict__)
[docs]
def plot(self) -> None:
"""Plot a simple representation of the bucking result."""
if not self.sections:
raise ValueError("No sections available for plotting.")
try:
import matplotlib.pyplot as plt
except ImportError as exc: # pragma: no cover - optional plotting dependency
raise ImportError(
"Plotting requires matplotlib; install it with `pip install pyforestry[plot]`."
) from exc
fig, ax = plt.subplots(figsize=(10, 6))
taper_heights = np.array(self.taper_heights_m, dtype=float)
taper_diams = np.array(self.taper_diameters_cm, dtype=float)
taper_x = (taper_heights - self.stump_height_m) * 10
taper_y = taper_diams
ax.plot(taper_x, taper_y, linestyle="-", color="black", label="Taper")
for section in self.sections:
mask = (taper_x >= section.start_point) & (taper_x <= section.end_point)
section_x = taper_x[mask]
section_y = taper_y[mask]
if len(section_x) == 0:
section_x = np.array([section.start_point, section.end_point])
section_y = np.interp(section_x, taper_x, taper_y)
if section_x[0] > section.start_point:
section_x = np.insert(section_x, 0, section.start_point)
section_y = np.insert(
section_y,
0,
start_diameter := (
start_diameter
if (start_diameter := np.interp(section.start_point, taper_x, taper_y)) > 0
else 0
),
)
if section_x[-1] < section.end_point:
section_x = np.append(section_x, section.end_point)
section_y = np.append(
section_y,
end_diameter := (
end_diameter
if (end_diameter := np.interp(section.end_point, taper_x, taper_y)) > 0
else 0
),
)
ax.fill_between(section_x, 0, section_y, alpha=0.3)
length = (section.end_point - section.start_point) / 10
midpoint = (section.start_point + section.end_point) / 2
ax.text(
midpoint,
section.top_diameter / 2,
f"{section.top_diameter:.1f} cm\n{length:.2f} m\n Vol {section.volume * 1000:.0f} "
f"$dm^3$\n {section.value:.0f} :-",
ha="center",
va="center",
fontsize=8,
)
ax.plot(taper_x, taper_y, linestyle="-", color="black")
for section in self.sections:
p = section.end_point
ax.axvline(x=p, color="grey", linestyle="--", alpha=0.7)
y_value = np.interp(p, taper_x, taper_y)
ax.text(
p - 2, y_value + 0.5, f"{p}", ha="center", va="bottom", rotation=90, fontsize=9
)
ax.scatter(
(1.3 - self.stump_height_m) * 10, self.dbh_cm, color="red", label="Diameter @ 1.3 m"
)
ax.set_xlabel("Distance from stump (dm)")
ax.set_ylabel("Diameter (cm)")
ax.set_title("Bucking Result Log Profile")
ax.set_xlim(left=0)
ax.set_ylim(bottom=0)
leg = ax.legend(loc="upper right")
leg.get_frame().set_alpha(1)
textstr = "\n".join(
(
f"Species Group: {self.species_group}",
f"DBH: {self.dbh_cm:.1f} cm",
f"Height: {self.height_m:.1f} m",
f"Stump Height: {self.stump_height_m:.1f} m",
f"Stump Diameter: {self.diameter_stump_cm:.1f} cm",
f"Vol fub 5cm: {self.vol_fub_5cm:.3f} m³",
)
)
plt.tight_layout()
legend_x0, legend_y0, _, _ = (
ax.get_legend().get_window_extent().transformed(ax.transAxes.inverted()).bounds
)
textbox_x = legend_x0
textbox_y = legend_y0 - 0.02
ax.text(
textbox_x,
textbox_y,
textstr,
transform=ax.transAxes,
fontsize=9,
verticalalignment="top",
horizontalalignment="left",
bbox=dict(boxstyle="round", facecolor="white", alpha=1),
)
plt.show()