pyforestry.simulation.valuation package#

Submodules#

pyforestry.simulation.valuation.cashflow module#

What a run earned, when, and what that is worth at the start of it.

A projection that thins produces revenue in the year the thinning happened. Two things then stand between those figures and a number an analyst can compare against another scenario, and both are forcings:

  • What a cubic metre fetched that year. A PRICE forcing scales each period’s revenue where it is earned, so the flows this module receives are already nominal — in the money of their own year.

  • What a krona in that year is worth now. Discounting, at a rate that may itself vary year by year, which is a DISCOUNT forcing.

Keeping them apart is the point. A single “real” rate folded together hides which half of a difference between two scenarios came from prices and which from time preference; the run manifest records both, separately, with their sources.

A discount rate is a stated preference, not a finding about the world, so it is cited with the (none)/year-0 sentinel this package already uses for anything authored rather than published — stated_choice() builds one.

pyforestry.simulation.valuation.cashflow.CASH_FLOWS_KEY = 'cash_flows'#

Where a run’s cash flows accumulate on the context, one entry per period that earned anything.

class pyforestry.simulation.valuation.cashflow.CashFlow(year: float, amount: float, price_factor: float = 1.0, label: str = '')[source]#

Bases: object

One period’s revenue, in the money of the year it was earned.

Variables:
  • year (float) – The calendar year the revenue was earned in.

  • amount (float) – The revenue, nominal – already scaled by whatever price forcing applied that year.

  • price_factor (float) – The price forcing that was applied, recorded so a reader can separate a change in prices from a change in what was cut.

  • label (str) – What produced it, e.g. the stage’s name.

as_mapping() → Mapping[str, Any][source]#

Return this flow as a plain mapping, for reporting.

label: str = ''#
price_factor: float = 1.0#
pyforestry.simulation.valuation.cashflow.discount_factor(year: float, base_year: float, *, discount_rate: float | None = None, forcings: ForcingSet | None = None) → float[source]#

Return what one unit of money in year is worth in base_year.

With a flat rate this is the textbook 1 / (1 + r) ** (year - base_year). With a DISCOUNT forcing it compounds year by year, so a rate that changes over the horizon discounts each year at its own – which a single exponent cannot express.

Parameters:
  • year – The year the money falls in.

  • base_year – The year to express it in. Usually the run’s first.

  • discount_rate – A flat annual rate, e.g. 0.03.

  • forcings – The run’s forcings; a DISCOUNT one overrides the flat rate.

Returns:

The factor to multiply a nominal amount by. 1.0 when the flow is in the base year, or when no rate applies at all.

Raises:
  • ValueError – If a rate of -1 or below applies, which makes money in the following year infinitely valuable.

  • KeyError – If a DISCOUNT series does not cover every year between base_year and year. Compounding needs a rate for each of them, so a term structure has to be dense over the horizon it is used on.

pyforestry.simulation.valuation.cashflow.net_present_value(cash_flows: Iterable[CashFlow], *, base_year: float, discount_rate: float | None = None, forcings: ForcingSet | None = None) → float[source]#

Return the discounted value of cash_flows, expressed in base_year.

Parameters:
  • cash_flows – What the run earned, and when.

  • base_year – The year to express the total in – the run’s first, usually.

  • discount_rate – A flat annual rate.

  • forcings – The run’s forcings. A DISCOUNT forcing supplies a year-varying rate; a PRICE forcing has already been applied to the amounts, where they were earned.

Returns:

The sum of each flow times its discount factor. Zero for no flows, which is the ordinary case for a projection that never harvested.

pyforestry.simulation.valuation.removals module#

What a run removed, in whatever detail the stand it came from could give.

Two kinds, because two kinds of model produce removals:

  • TreeRemoval – a stem, with a diameter and a height, which can be bucked into assortments and priced by grade. What a tree-list model gives.

  • MeanTreeRemoval – the stand’s representative stem, and how many of them came out. What an aggregate model gives: Kuehne (2022) and its siblings step a basal area and a stem count, so a thinning there has no individual stems – but it does have a quadratic mean diameter, and that mean tree can be bucked like any other.

The second existed nowhere, which is the whole reason Norway had no valuation stage: its models are aggregate, the ledger could only hold stems, so there was nothing for a valuation to price. A stand-level model cannot be bucked stem by stem; it can be bucked once, at its mean tree.

class pyforestry.simulation.valuation.removals.CohortRemoval(identifier: str, species: TreeName | str, metadata: MutableMapping[str, ~typing.Any]=<factory>, trees: list[TreeRemoval] = <factory>)[source]#

Bases: object

Group a collection of removed trees for a cohort.

iter_trees() → Iterator[TreeRemoval][source]#

Yield the recorded tree removals.

record_tree(tree: Tree, *, weight: float | None = None, metadata: Mapping[str, Any] | None = None) → TreeRemoval[source]#

Append a tree removal to the cohort.

property total_weight: float#

Return the sum of removal weights in the cohort.

property tree_count: int#

Return the number of recorded tree removals.

class pyforestry.simulation.valuation.removals.MeanTreeRemoval(cohort_id: str, species: TreeName | str, diameter_cm: float, height_m: float, stems: float, volume_m3: float, metadata: Dict[str, ~typing.Any]=<factory>)[source]#

Bases: object

Record a removal as one representative stem, and how many came out.

What an aggregate model can say. There are no individual stems, but a stand has a quadratic mean diameter and a mean height, and the stem those describe can be bucked like any other – which is how a stand-level model gets an assortment split at all.

volume_m3 is the total the model says left the stand, in the model’s own measure – m3sk for the Nordic stand volume functions – and it is what the run’s summary reports as harvested. It is deliberately not imposed on the logs: MeanTreeVolumeDescriptor bucks the mean stem and multiplies by stems, because a price list buys the narrower m3to and scaling the grades up to an m3sk total pays m3to prices on m3sk cubic metres. It is still the ceiling – logs cannot exceed the stem volume they came from – and the descriptor raises if they do.

Variables:
  • cohort_id (str) – Which removal event this belongs to.

  • species (pyforestry.base.helpers.tree_species.TreeName | str) – The species removed.

  • diameter_cm (float) – The representative stem’s diameter, normally the stand’s QMD.

  • height_m (float) – Its height. Lorey’s mean height where the stand has one.

  • stems (float) – How many such stems came out, per hectare.

  • volume_m3 (float) – The total volume removed, per hectare, from the model.

  • metadata (Dict[str, Any]) – Carried through to the piece records.

property species_name: str#

The species’ full scientific name.

to_timber() → Timber[source]#

Build the Timber for the mean stem.

class pyforestry.simulation.valuation.removals.StandRemovalLedger(stand_id: str | None = None, metadata: MutableMapping[str, ~typing.Any]=<factory>, cohorts: MutableMapping[str, ~pyforestry.simulation.valuation.removals.CohortRemoval]=<factory>, mean_trees: list[MeanTreeRemoval] = <factory>)[source]#

Bases: object

Top-level container aggregating removal cohorts for a stand.

add_cohort(identifier: str, *, species: TreeName | str, metadata: Mapping[str, Any] | None = None) → CohortRemoval[source]#

Register a cohort and return it (existing cohorts are updated).

extend(cohorts: Iterable[CohortRemoval]) → None[source]#

Merge cohorts into the ledger.

property is_empty: bool#

Return True when nothing at all was recorded.

Both kinds count: a ledger holding only mean-tree removals is not empty, which is what lets an aggregate model’s thinning be priced.

iter_cohorts() → Iterator[CohortRemoval][source]#

Yield the registered cohorts.

iter_mean_tree_removals() → Iterator[MeanTreeRemoval][source]#

Yield mean-tree removals, in the order they were recorded.

iter_tree_removals() → Iterator[TreeRemoval][source]#

Yield tree removals across all cohorts.

record_mean_tree(identifier: str, *, species: TreeName | str, diameter_cm: float, height_m: float, stems: float, volume_m3: float, metadata: Mapping[str, Any] | None = None) → MeanTreeRemoval[source]#

Record a removal as a representative stem, for a stand holding no stems.

Parameters:
  • identifier – The removal event, e.g. "management@2035".

  • species – What was removed.

  • diameter_cm – The representative stem’s diameter, normally the QMD.

  • height_m – Its height.

  • stems – How many came out, per hectare.

  • volume_m3 – The total volume removed, per hectare, from the model.

  • metadata – Carried through to the piece records.

Returns:

The recorded removal.

record_tree(identifier: str, tree: Tree, *, species: TreeName | str | None = None, weight: float | None = None, metadata: Mapping[str, Any] | None = None) → TreeRemoval[source]#

Record a tree removal under identifier and return the entry.

stand_id: str | None = None#
property total_volume_m3: float#

Return the volume recorded through mean-tree removals.

property total_weight: float#

Return the total removal weight across cohorts.

property tree_count: int#

Return the total number of tree removals.

class pyforestry.simulation.valuation.removals.TreeRemoval(cohort_id: str, species: TreeName | str, tree: Tree, weight: float | None = None, metadata: MutableMapping[str, ~typing.Any]=<factory>)[source]#

Bases: object

Record the removal of an individual (possibly weighted) tree.

property diameter_cm: float#

Return the tree diameter in centimetres.

property height_m: float#

Return the tree height in metres.

property species_name: str#

Return the lower-case "genus species" representation.

property stump_height_m: float#

Return the stump height in metres for the removal.

to_timber() → Timber[source]#

Convert the removal into a Timber.

weight: float | None = None#

pyforestry.simulation.valuation.step module#

Pricing removals as a step in a simulation pipeline.

This is what gives pyforestry.simulation.valuation a runtime. The ledger, the connector and the piece records were a complete design with no caller, because nothing harvested through a runtime; a pipeline that thins and then prices what it removed is that caller.

class pyforestry.simulation.valuation.step.ValuationStep(settings: ValuationSettings, connector: VolumeConnector = <factory>, forcings: ForcingSet = <factory>, name: str = 'valuation')[source]#

Bases: object

Price whatever the run has removed, and add it to the run’s cash.

The price list, taper and bucking config are constructor arguments, because they are properties of the pipeline a caller assembles and do not change between periods. Only the ledger comes off the context, at ctx.attrs["removal_ledger"] – the one place – and the priced result goes back to ctx.attrs["valuation"].

They used to be looked for on ctx.attrs["valuation_settings"], falling back to “the context itself”, which could not work: a SimulationContext has no price list, so the documented default raised AttributeError the first time a run removed anything. A required argument cannot be forgotten.

Does nothing when there is no ledger or it is empty, which is the ordinary case for a step that did not thin.

CASH_KEY = 'cash'#
LEDGER_KEY = 'removal_ledger'#

Where the ledger is read from and the result is written to.

RESULT_KEY = 'valuation'#
name: str = 'valuation'#
run(ctx: SimulationContext, dt: float) → None[source]#

Price this period’s removals into ctx.attrs, then clear the ledger.

Clearing is what makes the step correct over more than one period. The ledger accumulates as steps remove stems; pricing it without emptying it re-prices every earlier period’s removals again, so cash compounds what was already banked and the bucking cost grows with the square of the run length. Nothing ran this step for two periods until the scenario runtime did, so neither showed.

pyforestry.simulation.valuation.volume module#

Volume conversion utilities for valuation workflows.

Pricing a removal needs three things the ledger does not carry: a price list, a taper function and a bucking configuration. They arrive as one typed ValuationSettings.

Two routes, chosen by what the stand could report. A tree list is bucked stem by stem and priced by grade (TreeVolumeDescriptor). An aggregate model has no individual stems, but it has a quadratic mean diameter and a mean height, so its representative stem is bucked once and scaled up (MeanTreeVolumeDescriptor) – which gets a stand-level model a real assortment split rather than pulpwood for everything.

That used to be a model_view: Any – a name left over from a subsystem this package no longer has – resolved by getattr across four spellings (pricelist or price_list, taper_class or get_taper_class(), each optionally callable). It is the defect ValuationStep was written to fix one layer up, where a ledger had been looked for in six places: several conventions for one value is no convention, and the failure mode is an object that implements a seventh spelling being read as implementing nothing.

class pyforestry.simulation.valuation.volume.EmptyVolumeDescriptor(*, ledger: StandRemovalLedger, metadata: MutableMapping[str, ~typing.Any]=<factory>, reason: str = 'empty')[source]#

Bases: VolumeDescriptor

Descriptor used when no removals are present.

evaluate() → VolumeResult[source]#

Return an empty result tagged with the reason for emptiness.

reason: str = 'empty'#
class pyforestry.simulation.valuation.volume.MeanTreeVolumeDescriptor(*, ledger: ~pyforestry.simulation.valuation.removals.StandRemovalLedger, metadata: ~typing.MutableMapping[str, ~typing.Any] = <factory>, removals: ~typing.Tuple[~pyforestry.simulation.valuation.removals.MeanTreeRemoval, ...], pricelist: ~pyforestry.base.pricelist.pricelist.Pricelist, taper_class: ~typing.Type[~pyforestry.base.taper.taper.Taper], bucking_config: ~pyforestry.base.helpers.bucking.BuckingConfig = <factory>, bucker_cls: ~typing.Type[~pyforestry.base.timber_bucking.nasberg_1985.Nasberg_1985_BranchBound] = <class 'pyforestry.base.timber_bucking.nasberg_1985.Nasberg_1985_BranchBound'>, min_diam_dead_wood: float = 0.0, timber_factory: ~typing.Callable[[~pyforestry.simulation.valuation.volume.StemDimensions], ~pyforestry.base.timber.timber_base.timber.Timber] = <function _default_timber_factory>)[source]#

Bases: VolumeDescriptor

Buck the stand’s representative stem, and scale it to the stems removed.

An aggregate model reports a basal area and a stem count, so a thinning from it has no individual stems to cut. It does have a quadratic mean diameter and a mean height, and the stem those describe is a real stem: bucking it once gives the proportions of butt, middle, top and pulp a stand of that mean size yields.

One stem’s worth of logs, multiplied by how many came out – the same arithmetic TreeVolumeDescriptor does with an expansion factor, so the two routes report volume and value on the same basis.

This used to scale the grades so they summed to the model’s own volume figure, on the reasoning that how much came out is the model’s business and only the split is the mean tree’s. Those are not the same measure of volume. A stand volume function reports m3sk – stem volume over bark, on the wider Nordic definition; the price list buys m3to, top-measured log volume, as TimberPricelist.volume_type declares. Scaling m3to grades up to an m3sk total pays m3to prices on m3sk cubic metres, about 21% more per removal than the stem yields. Nothing caught it because it made priced volume equal harvested volume by construction, and the tree-list route – which prices around 78% of what its own reporter counts – was the only place the difference showed.

So the model’s figure is no longer imposed on the logs. It still says how much left the stand, which is what the summary’s harvested_m3 reports in m3sk; the logs are what the mean stem yields in m3to. share_of_removed_volume_sold is the ratio between them, so the conversion is a number a reader can see instead of an assumption.

Three things it assumes, all worth knowing before comparing the result with a bucked inventory:

  • The mean tree’s grade split is the stand’s. It is not: value is convex in diameter, so a stand with the same mean but a wider spread yields more sawtimber than its mean tree suggests. This understates a heterogeneous stand and is exact only for a uniform one.

  • Whatever height the run supplied for the mean stem is the mean stem’s. A model that predicts only dominant height has none, and using that overstates the stem’s taper – which is why the height is the run’s to supply rather than something guessed here. That overstatement now shows up where it can be seen, in the share of removed volume sold, rather than being absorbed into a scale factor.

  • The mean stem stands for every stem removed. A thinning from below takes stems smaller than the mean, so pricing them all at the mean overstates the grade split of a low thinning.

Raises:

ValueError – From evaluate(), if a mean stem bucks to more log volume than the model says came out of the stand. m3to is a narrower measure than m3sk, so the logs cannot exceed the stem volume they came from; when they do, the representative stem is too large for the removal – most often a dominant height standing in for a mean one.

evaluate() → VolumeResult[source]#

Buck each mean stem and multiply its grades by the stems removed.

Raises:

ValueError – If a mean stem’s logs exceed the volume the model says was removed, which no real stem can do.

min_diam_dead_wood: float = 0.0#
timber_factory() → Timber#

Build the base Timber for a removal.

class pyforestry.simulation.valuation.volume.PieceRecord(cohort_id: str, species: str, quality: QualityType, length_m: float, top_diameter_cm: float, volume_m3: float, value: float, weight: float)[source]#

Bases: object

Representation of one bucked piece aggregated across identical trees.

as_mapping() → Dict[str, Any][source]#

Return a plain mapping useful for serialisation.

class pyforestry.simulation.valuation.volume.StemDimensions(*args, **kwargs)[source]#

Bases: Protocol

What a timber factory needs from a removal, of either kind.

TreeRemoval and MeanTreeRemoval both satisfy it, so one factory serves both routes – a region whose taper needs its own Timber subclass writes it once.

property diameter_cm: float#

Breast-height diameter, in cm.

property height_m: float#

Total height, in m.

property species_name: str#

The species’ full scientific name.

to_timber() → Timber[source]#

Build the base timber for this stem.

class pyforestry.simulation.valuation.volume.TreeVolumeDescriptor(*, ledger: ~pyforestry.simulation.valuation.removals.StandRemovalLedger, metadata: ~typing.MutableMapping[str, ~typing.Any] = <factory>, removals: ~typing.Tuple[~pyforestry.simulation.valuation.removals.TreeRemoval, ...], pricelist: ~pyforestry.base.pricelist.pricelist.Pricelist, taper_class: ~typing.Type[~pyforestry.base.taper.taper.Taper], bucking_config: ~pyforestry.base.helpers.bucking.BuckingConfig = <factory>, bucker_cls: ~typing.Type[~pyforestry.base.timber_bucking.nasberg_1985.Nasberg_1985_BranchBound] = <class 'pyforestry.base.timber_bucking.nasberg_1985.Nasberg_1985_BranchBound'>, min_diam_dead_wood: float = 0.0, timber_factory: ~typing.Callable[[~pyforestry.simulation.valuation.volume.StemDimensions], ~pyforestry.base.timber.timber_base.timber.Timber] = <function _default_timber_factory>)[source]#

Bases: VolumeDescriptor

Descriptor based on individual tree removals.

evaluate() → VolumeResult[source]#

Run bucking and valuation over the recorded tree removals.

min_diam_dead_wood: float = 0.0#
timber_factory() → Timber#

Build the base Timber for a removal.

class pyforestry.simulation.valuation.volume.ValuationSettings(pricelist: ~pyforestry.base.pricelist.pricelist.Pricelist, taper_class: ~typing.Type[~pyforestry.base.taper.taper.Taper], bucking_config: ~pyforestry.base.helpers.bucking.BuckingConfig = <factory>, min_diam_dead_wood: float = 0.0, timber_factory: ~typing.Callable[[~pyforestry.simulation.valuation.volume.StemDimensions], ~pyforestry.base.timber.timber_base.timber.Timber] = <function _default_timber_factory>)[source]#

Bases: object

What pricing a removal needs beyond the removal itself.

One typed object, validated when it is built, so a misconfiguration is a named error at the call site rather than an AttributeError raised the first time a run happens to thin something.

Parameters:
  • pricelist – The price list to value the bucked assortments against.

  • taper_class – The Taper subclass the bucker takes stem dimensions from.

  • bucking_config – Bucking options. save_sections is forced on, because the piece records this package reports are the sections.

  • min_diam_dead_wood – Minimum top diameter, in cm, below which wood is treated as dead and not merchandised.

  • timber_factory – Builds the Timber a removal is bucked as, for either kind of removal – an individual stem or a stand’s mean tree. Defaults to the base Timber; a region whose taper needs its own subclass supplies one, which is what Sweden’s EdgrenNylinder1949 requires – it rejects anything that is not a SweTimber, so the default produced a ValueError from inside the bucker rather than at configuration time.

Raises:

TypeError – If any field is not of its declared type.

min_diam_dead_wood: float = 0.0#
timber_factory() → Timber#

Build the base Timber for a removal.

class pyforestry.simulation.valuation.volume.VolumeConnector(*, bucker_cls: Type[Nasberg_1985_BranchBound] | None = None)[source]#

Bases: object

Resolve removal ledgers into volume descriptors and valuation results.

connect(settings: ValuationSettings, ledger: StandRemovalLedger) → VolumeResult[source]#

Return the evaluated volume result for ledger under settings.

describe(settings: ValuationSettings, ledger: StandRemovalLedger) → VolumeDescriptor[source]#

Return the descriptor describing ledger priced under settings.

Raises:

TypeError – If settings or ledger is not of its declared type.

class pyforestry.simulation.valuation.volume.VolumeDescriptor(*, ledger: StandRemovalLedger, metadata: MutableMapping[str, ~typing.Any]=<factory>)[source]#

Bases: object

Base descriptor for conversion inputs.

evaluate() → VolumeResult[source]#

Return an empty result for descriptors that cannot produce volume.

class pyforestry.simulation.valuation.volume.VolumeResult(descriptor: ~pyforestry.simulation.valuation.volume.VolumeDescriptor, pieces: ~typing.Tuple[~pyforestry.simulation.valuation.volume.PieceRecord, ...], total_value: float, volume_by_quality: ~typing.Mapping[~pyforestry.base.helpers.bucking.QualityType, float], metadata: ~typing.Mapping[str, ~typing.Any] = <factory>)[source]#

Bases: object

Container describing the outcome of a valuation conversion.

property total_volume: float#

Return the total merchantable volume (m³).

Module contents#

Valuation helpers for converting removals into marketable products.