pyforestry.simulation package#

Subpackages#

Submodules#

pyforestry.simulation.artifacts module#

The artifacts a scenario run emits, and what has to be in them.

These three files are how a run is read back without rerunning it:

  • run_manifest.json – how the run was constructed: which configuration and scenario, which models with which citations, the ordered stages, the rulesets that were applied and their values, the guard policy, the seeds, and the git revision. This is the artifact’s reason to exist. A number in the summary is only interpretable against the manifest that says what produced it.

  • scenario_summary.parquet – one row per stand: the volume balance, and what it was worth.

  • quality_report.json – what was checked, and the determinism hash that lets two runs of the same configuration be compared without diffing floats.

The schema was Sweden’s (sweden/simulation/data/schema.py) and is region- agnostic; Norway had none, which is one reason it had no runbook of its own.

Every field in the summary closes an identity:

net_volume_m3 == initial_volume_m3 + gross_growth_m3
                 - disturbance_loss_m3 - harvested_m3

valued is False  =>  nominal_revenue == 0 and net_present_value == 0
harvested_m3 == 0  =>  nominal_revenue == 0

validate_artifact_contract() checks both per row, so a run whose bookkeeping does not add up fails at the point of writing rather than in whatever reads it.

Why ``valued`` is a column and not a null. Not every run prices what it cuts: a configuration without a "valuation" stage removes wood and never asks what it fetched. Writing 0 for such a run beside a non-zero harvested_m3 reads as sold forty cubic metres for nothing, and writing null leaves a reader unable to tell “earned nothing” from “never asked” without going to the manifest – which defeats the summary’s purpose. The flag makes the three states distinguishable from the row alone: (False, 0) was not priced, (True, 0) was priced and earned nothing, (True, x) earned x.

What the money is in is recorded rather than assumed. valuation.price_list names the list a run priced against, states the currency its prices are quoted in and carries its publisher’s citation – read off the PricelistIdentity that price list declares – while valuation.discount_rate and valuation.base_year say how that money was moved in time. Two runs’ figures are comparable when those blocks agree, which a reader can now check: until a price list had an identity to record, the summary carried sums in an unnamed currency against prices from nowhere. A run priced against the analyst’s own table says that here too, in the (none)/year-0 form the rest of the package uses for anything authored rather than published.

pyforestry.simulation.artifacts.MANIFEST_REQUIRED_KEYS = ('schema_version', 'preset_id', 'scenario_id', 'region', 'global_seed', 'scenario_seed', 'processes', 'n_steps', 'step_years', 'n_stands', 'scenario_summary_encoding', 'required_artifacts', 'stages', 'rulesets_applied', 'forcings_applied', 'guard_policy', 'valuation', 'models_run', 'provenance')#

Version 1.0 carried synthetic, because the only writer produced a random walk. It is gone: a run emits these artifacts or it does not run. models_run, rulesets_applied, forcings_applied and guard_policy are required because they are what makes the summary interpretable. A forcing record carries its own citation, so a reader can see not just that growth was scaled but by whom it was said to be. valuation is required for the same reason the others are: a net present value without the price list it was earned against, the rate it was discounted at and the year it is expressed in is not a number anyone can use.

pyforestry.simulation.artifacts.RUN_MANIFEST_SCHEMA_VERSION = '3.2'#

Both bumped from "2.0" when the summary grew the three money columns and the manifest grew the valuation block that makes them readable. Adding a column is a break here by construction: load_scenario_summary() validates the column tuple exactly, so a 2.0 artifact does not load under 3.x and is not made to. These files are the output of a run, not a store – regenerating one costs a rerun, and a migration path would be a promise about numbers whose construction has changed. What 3.x does owe a reader is a diagnosis rather than two tuples to diff, which is what the loader gives.

"3.1" added valuation.price_list: the manifest now records which list produced the money columns and what currency they are in. A minor bump because the summary is untouched – 3.0’s columns are 3.1’s, and a 3.0 file still loads – while a 3.0 manifest is missing a block rather than unreadable, and the version is how a reader tells the two apart instead of guessing from an absent key. "3.2" replaced thin_at_years with management_schedule, which says what measure the thinning times are in. The old key was a bare list of numbers compared against each model’s own clock, so the same manifest field meant elapsed years for Sweden’s adapter and total stand age for Norway’s. A minor bump again: the summary columns are untouched, and a reader tells a 3.1 manifest from a 3.2 one by the version rather than by which key is missing.

class pyforestry.simulation.artifacts.ScenarioArtifacts(run_manifest_path: Path, scenario_summary_path: Path, quality_report_path: Path)[source]#

Bases: object

Resolved paths for the artifacts a scenario run produced.

property output_dir: Path#

The directory the three artifacts were written into.

pyforestry.simulation.artifacts.check_value_consistency(rows: Sequence[Mapping[str, Any]]) → None[source]#

Check that every summary row’s money says the same thing as the rest of it.

Two things are definitionally true of the schema, and both are checked:

  • A row that was never valued reports no revenue. valued is False exactly when the configuration declared no "valuation" stage, and then nothing priced anything, so a non-zero figure beside it could only have come from somewhere it should not have.

  • A row that harvested nothing earned nothing. Revenue reaches a run through the removal ledger, which only a merchantable removal writes to – a storm is a loss, not a sale. The converse is deliberately not required: a thinning of stems too small to buck harvests volume and earns nothing.

Raises:

ValueError – If either fails, or a money figure is not a finite number.

pyforestry.simulation.artifacts.check_volume_balance(rows: Sequence[Mapping[str, Any]]) → None[source]#

Check that every summary row’s volume components add up.

Raises:

ValueError – If a row’s net_volume_m3 does not equal initial + gross_growth - disturbance_loss - harvested. Its purpose is to catch a run whose bookkeeping lost volume somewhere between the steps that removed it and the row that reports it.

pyforestry.simulation.artifacts.determinism_hash(rows: Sequence[Mapping[str, Any]]) → str[source]#

Return a stable hash of the summary rows, for replay comparison.

pyforestry.simulation.artifacts.git_revision() → str[source]#

Return the short git revision of this package, or "unknown".

Resolved against the directory pyforestry is installed in, not the working directory. Asking git where it happened to be invoked answers a different question: run a projection from inside your own repository and the manifest recorded your HEAD under provenance.git_revision, which claims the run was produced by code it was not. A wrong revision is worse than none, so an installed copy outside a repository reports "unknown" and says nothing.

pyforestry.simulation.artifacts.load_scenario_summary(path: Path) → list[dict[str, Any]][source]#

Load summary rows from a parquet artifact or its JSON fallback.

Raises:

ValueError – If the columns are not the schema’s, or the fallback payload is malformed.

pyforestry.simulation.artifacts.validate_artifact_contract(output_dir: Path) → None[source]#

Check that a run’s output directory satisfies the artifact contract.

Raises:

ValueError – If an artifact is missing, a required key is absent, the summary columns are wrong, or a row’s volume balance or money does not add up.

pyforestry.simulation.artifacts.write_artifacts(*, output_dir: Path, manifest: Mapping[str, Any], rows: Sequence[Mapping[str, Any]]) → ScenarioArtifacts[source]#

Write the three artifacts and validate them before returning.

Parameters:
  • output_dir – Directory to write into; created if absent.

  • manifest – Everything about how the run was constructed. The encoding and schema version are filled in here.

  • rows – One summary row per stand.

Returns:

The resolved artifact paths.

Raises:

ValueError – If the artifacts do not satisfy the contract – which is checked here rather than left to the reader.

pyforestry.simulation.contracts module#

Simulation-runtime contracts.

This module holds only what the runtime defines. The provenance/introspection vocabulary (SourceReference, Describable, FormulaModuleDescriptor) lives in pyforestry.base.contracts and is not re-exported here: formula and domain modules across every region expose that vocabulary, and nothing above base should have to import from the simulation tier to obtain it. Import it from pyforestry.base.contracts directly.

ParityCase and AssertionResult were removed rather than kept: a Protocol for reproducible parity fixtures with no implementer and no caller, exported from two __all__ lists, reads as a contract something honours.

class pyforestry.simulation.contracts.SimulationPreset(*args, **kwargs)[source]#

Bases: Protocol

Contract for a regional scenario configuration.

Implemented by pyforestry.simulation.presets.ScenarioConfig and executed by pyforestry.simulation.scenario.run_scenario(), which reads all five: stages() becomes the steps a period runs, rulesets() supplies the management plan, guard_policy() the guard flags, seed_strategy() each stand’s seed, and required_artifacts() what the run must emit. All of them are recorded in the run manifest as well.

This said the opposite until the runtime existed – that nothing executed a preset, that rulesets() and guard_policy() had no caller, and that the harness consuming the result filled it with synthetic numbers. All of that was true, and none of it is now.

The other runnable things are the composite pipelines under <region>/simulation/presets/ and pyforestry.project().

guard_policy() → Mapping[str, object][source]#

Return runtime guard settings.

required_artifacts() → Sequence[str][source]#

Return artifact ids that must be emitted under this configuration.

rulesets() → Mapping[str, Callable[[...], Any]][source]#

Return scenario rulesets keyed by concern.

seed_strategy(**kwargs: Any) → int[source]#

Return a deterministic seed for a run.

stages() → Sequence[str][source]#

Return the ordered stage identifiers for execution.

pyforestry.simulation.forcing module#

Forcings: named values a run can read for the period it is in.

A forcing is something imposed on a projection from outside it — not anything a published model predicts. A weather correction on growth, a windthrow rate, a thinning intensity, a price index: the package provides the mechanism and ships none of them, because every such number is a claim about the world and this package has no basis for one.

A forcing is just a named value the state can look up at a time. Three things vary, and the type is deliberately open on all three:

  • What it is called — the name. The shipped steps read four (GROWTH, DISTURBANCE, THINNING, PRICE); the set is open, and a step you write reads whatever name it likes.

  • Its resolution in time — ConstantForcing holds one value for the whole run; AnnualForcing a value per calendar year:

    AnnualForcing(GROWTH, {2014: 1.04, 2015: 1.02, 2016: 0.98, 2019: 0.67},
                  source=SourceReference(...))
    
  • Its type — the value at a year is whatever you put there. A float is the common case; a mapping is how a forcing varies by something else as well, most obviously species:

    AnnualForcing(
        GROWTH,
        {2014: {PICEA_ABIES: 1.06, PINUS_SYLVESTRIS: 1.01},
         2015: {PICEA_ABIES: 0.98, PINUS_SYLVESTRIS: 1.03}},
        source=SourceReference(...),
    )
    

    ForcingSet.multiplier() takes an optional key and indexes into such a mapping, so a step asks for “the growth forcing for spruce in this period” and gets 1.0 if nothing forces it. Values that are neither – a flag, a category, a list – are read with ForcingSet.value() and interpreted by whatever step asked for them.

Any forcing that is not exactly neutral must carry a SourceReference. What neutral means depends on the name: a multiplier leaves a model alone at 1.0, a rate at 0.0. neutral_value() is the one place that is decided, and getting it wrong inverts the rule – it once refused a DISCOUNT of 0.0 while waving through 1.0, a 100%-a-year rate, with no source.

The package shipped one forcing that carried no source: a climate_rcp45 scenario whose multipliers arrived in the first commit with no source, under a name asserting a specific IPCC pathway. Every forcing a run applies is written into its manifest with its citation attached, which is what makes the run readable afterwards.

class pyforestry.simulation.forcing.AnnualForcing(name: str, series: Mapping[float, Any], outside_series: str = 'raise', source: SourceReference | None = None)[source]#

Bases: object

A value that changes from year to year.

The weather-correction case: a value per calendar year, with the run’s clock supplying the year. A period spanning several years takes the value at the year the period starts, which is the resolution a stepped projection has; use a shorter step if a single year matters.

Parameters:
  • name – What it is called.

  • series – Calendar year to value. Need not be contiguous, and the values may be any type – a float, or a mapping keyed by species.

  • outside_series – What to do for a year the series does not cover – "raise" (the default), "hold" the nearest year’s value, or "neutral" for this name’s no-op (neutral_value()).

  • source – Where the series comes from. Required unless every value is neutral.

Raises:

ValueError – If the series is empty, is not neutral and has no source, or outside_series is not one of the three.

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

Return this forcing as a manifest record.

at(year: float) → Any[source]#

Return the value for year.

A part-way year falls in the calendar year containing it, which is what “the year the period starts” means for a run whose period is not a whole number of years. Matching exactly and nothing else, this raised for 2027.5 against a series covering 2025-2028 – a year it does cover – so a fractional step and an annual forcing could not be used together. An exact key still wins, for a series that really is keyed sub-annually.

Raises:

KeyError – If the series covers neither year nor the calendar year containing it, and outside_series is "raise".

outside_series: str = 'raise'#
source: SourceReference | None = None#
pyforestry.simulation.forcing.CALENDAR_YEAR_KEY = 'calendar_year'#

Where the period’s calendar year is stamped on the context. A projection’s own clock is elapsed years from wherever the model started – and some models start it at the stand’s age – while a forcing series is keyed by calendar year. This is where the two are reconciled.

class pyforestry.simulation.forcing.ConstantForcing(name: str, value: Any = None, source: SourceReference | None = None)[source]#

Bases: object

One value, held for the whole run.

Parameters:
  • name – What it is called – GROWTH, PRICE, or any name a step of yours reads.

  • value – The value. Any type. Defaults to whatever leaves the models alone for this name – 1.0 for a multiplier, 0.0 for one of the RATE_FORCINGS – which is an exact no-op and needs no source.

  • source – Where it comes from. Required unless the value is neutral.

Raises:

ValueError – If the value is not neutral and has no source.

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

Return this forcing as a manifest record.

at(year: float) → Any[source]#

Return the value, which does not depend on the year.

source: SourceReference | None = None#
value: Any = None#
pyforestry.simulation.forcing.DISCOUNT = 'discount'#

A discount rate, read by pyforestry.simulation.valuation.cashflow. Unlike the others this is a rate rather than a multiplier, and it is a stated preference rather than a finding – cite it with stated_choice().

class pyforestry.simulation.forcing.Forcing(*args, **kwargs)[source]#

Bases: Protocol

A named value a run can read for the period it is in.

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

Return a JSON-serialisable record of this forcing, for the run manifest.

at(year: float) → Any[source]#

Return this forcing’s value in year. Any type.

class pyforestry.simulation.forcing.ForcingSet(forcings: tuple[~pyforestry.simulation.forcing.Forcing, ...]=<factory>)[source]#

Bases: object

The forcings a run applies, looked up by name and year.

Several forcings may share a name – a weather correction and a nitrogen response both act on growth – so multiplier() composes them by multiplication, in the order given. A name with no forcing multiplies by 1.0, so a run that declares none gets exactly what the models predict.

as_manifest() → list[Mapping[str, Any]][source]#

Return every forcing as a manifest record, in order.

merge(other: ForcingSet) → ForcingSet[source]#

Return a set holding this set’s forcings followed by other’s.

multiplier(name: str, year: float, *, key: Any = None) → float[source]#

Return the combined multiplier for name in year.

Parameters:
  • name – The forcing’s name.

  • year – The calendar year of the period being run.

  • key – Index into a forcing whose value is a mapping – a species, most often. A mapping that does not hold the key contributes 1.0, so a per-species forcing that names only spruce leaves pine alone.

Returns:

The product of every forcing of that name, or 1.0 if there is none.

Raises:

TypeError – If a value is neither a number nor a mapping, since it cannot be multiplied by. Read it with value() instead.

names() → frozenset[str][source]#

Return every name this set forces.

value(name: str, year: float, *, default: Any = None) → Any[source]#

Return the last value declared for name in year, or default.

For a forcing that is not a multiplier – a flag, a category, a list – where composing several makes no sense and the last declaration wins.

values(name: str, year: float) → tuple[Any, ...][source]#

Return each forcing of name evaluated at year, in order.

The general accessor: the values may be of any type, and it is the caller’s business what they mean. multiplier() is the numeric special case.

pyforestry.simulation.forcing.GROWTH = 'growth'#

Names the shipped steps read. Any string works; these are the ones with a consumer, named so a caller does not have to guess the spelling.

pyforestry.simulation.forcing.RATE_FORCINGS = frozenset({'discount'})#

Forcing names whose value is a rate rather than a multiplier, and which are therefore left alone at 0.0 instead of 1.0. A rate of 1.0 is 100% a year; a multiplier of 0.0 wipes the stand out. Getting this the wrong way round inverts the citation rule, which is what it did: DISCOUNT at 0.0 – no time preference, the answer this package documents as legitimate – was refused as uncited, while 1.0, a rate that leaves a cash flow five years out worth 3% of its face value, was waved through with no source at all. The rule is only worth having if it fails in the safe direction.

pyforestry.simulation.forcing.is_neutral_value(value: Any, name: str = '') → bool[source]#

Whether value leaves a model’s own prediction exactly as it is.

Parameters:
  • value – The value to test, or a mapping every one of whose values is tested.

  • name – The forcing it belongs to, which decides what neutral means – see neutral_value(). Defaults to the multiplier sense.

Returns:

Whether the value is neutral. Anything else – a different number, a flag, a category – changes something, and so needs a citation.

pyforestry.simulation.forcing.neutral_value(name: str) → float[source]#

Return the value of name that changes nothing: 0.0 or 1.0.

Parameters:

name – The forcing’s name. Anything not in RATE_FORCINGS is taken to be a multiplier, so a name a caller invents behaves like the four the shipped steps read.

pyforestry.simulation.forcing.period_time(ctx: SimulationContext) → float[source]#

Return the period’s calendar year, or the run’s own clock if it has none.

For labelling rather than lookup. A cash flow needs to know when it happened to be discounted, but a run with no calendar still has an ordering – its elapsed clock – and that is a better answer than refusing to record the flow at all. period_year() stays strict, because a forcing series read at the wrong year is wrong quietly.

pyforestry.simulation.forcing.period_year(ctx: SimulationContext) → float[source]#

Return the calendar year of the period currently running.

Stamped by CalendarStep, which build_pipeline() puts first. Reading a year-keyed forcing without one would silently take whatever value the series holds at year zero, so this raises instead.

Raises:

RuntimeError – If no calendar was stamped.

pyforestry.simulation.forcing.stated_choice(what: str) → SourceReference[source]#

Cite a forcing that is a decision rather than a finding.

A discount rate, a thinning intensity, an assumed price level: the analyst chose it, and no paper says it is so. This builds the (none)/year-0 sentinel the package already uses for anything authored rather than published, so such a forcing satisfies the citation rule and says plainly in the run manifest that it is a choice.

Parameters:

what – What was chosen, e.g. "3% real discount rate".

pyforestry.simulation.policy module#

What a scenario’s rulesets return, and what the numbers in them mean.

A ruleset maps a scenario id to the knobs that scenario turns. The values are regional policy and live in <region>/simulation/policy/; the contract – what the fields mean, in what units, and what happens when a scenario id is unknown – is here, because the two regions had answered both questions differently:

  • management_plan("bogus") raised ValueError in Sweden and silently returned the baseline in Norway;

  • scenario_factors("bogus") likewise;

  • and thinning_ratio was 0.20 in Sweden – a fraction of stems removed – against 1.0 in Norway, which was an intensity multiplier wearing the same name. A run configured for Norway’s “intensive” scenario would have been read as thinning 120% of the stand.

An unknown scenario id now raises in both. Defaulting to baseline turns a typo into a full run of plausible numbers under a scenario nobody chose, which is the failure this package’s artifacts exist to make impossible.

This module used to also hold ScenarioFactors, a frozen pair of growth_factor and disturbance_factor. Those were two instances of one idea – an external factor imposed on the run – hard-coded as two fields, which is why there was no way to express a third (a price index), or a factor that changes from year to year, or one that differs by species. The general form is pyforestry.simulation.forcing.

class pyforestry.simulation.policy.ManagementPlan(thinning_ratio: float)[source]#

Bases: object

What management a scenario prescribes.

Variables:

thinning_ratio (float) – Fraction of standing stems removed at a thinning event, in [0, 1]. This is a fraction, not a multiplier: a region whose scenarios differ by intensity expresses that as a different fraction, not as a factor applied to an unstated base.

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

Return the plan as a plain mapping, for the run manifest.

class pyforestry.simulation.policy.ManagementRuleset(values: Mapping[str, _T], what: str)[source]#

Bases: ScenarioTable[float]

A management table, whose values are the fraction of stems a thinning removes.

The unit is the reason this is its own class rather than a bare ScenarioTable: thinning_ratio once meant a fraction of stems in Sweden and an intensity multiplier in Norway under the same name, so a run configured for Norway’s “intensive” scenario read as thinning 120% of the stand. A region builds one of these from ratios, and plan() is the only way the number reaches a run.

plan(scenario_id: str) → ManagementPlan[source]#

Return the management plan for scenario_id.

Raises:

ValueError – If the scenario id is unknown.

class pyforestry.simulation.policy.ScenarioTable(values: Mapping[str, _T], what: str)[source]#

Bases: Generic[_T]

A region’s scenario table, with the two accessors every region needs.

Both regions had written these accessors out by hand, once each: a supported_* returning tuple(sorted(table)) and a lookup delegating to lookup_scenario(). The two copies were identical apart from the table and the string in what, which is the shape this class exists to remove. What stays regional is the table – the numbers are policy, and policy is regional. The accessors are not.

Variables:
  • values (Mapping[str, pyforestry.simulation.policy._T]) – The region’s table, scenario id to value.

  • what (str) – What the table holds, used to name it in the error a bad id raises (e.g. "Sweden management").

lookup(scenario_id: str) → _T[source]#

Return this table’s entry for scenario_id.

Raises:

ValueError – If the scenario id is unknown. Both regions raise; neither falls back to baseline, because that turns a typo into a full run of plausible numbers under a scenario nobody chose.

supported() → tuple[str, ...][source]#

Return the scenario ids this table covers, sorted.

pyforestry.simulation.policy.lookup_scenario(table: Mapping[str, _T], scenario_id: str, *, what: str) → _T[source]#

Look scenario_id up in table, naming the alternatives if it is absent.

Parameters:
  • table – The region’s scenario table.

  • scenario_id – The scenario asked for.

  • what – What is being looked up, for the error message.

Returns:

The table’s entry for scenario_id.

Raises:

ValueError – If scenario_id is not in table.

pyforestry.simulation.presets module#

Shared scaffolding for regional scenario configurations.

A scenario configuration is a frozen declaration: a seed strategy, an ordered list of stage names, ruleset callables, forcings and a required-artifact list. It runs nothing itself – pyforestry.simulation.scenario.run_scenario() runs it, resolving the stage names into steps, deriving each stand’s seed from the strategy, and applying the rulesets and the guard policy. Holding the declaration apart from the runner is what lets a run manifest say how a run was constructed without the configuration knowing how a projection is stepped. The other runnable things in this package are the composite pipelines in <region>/simulation/presets/ and pyforestry.project().

Sweden and Norway each had their own near-copy of this file. The two differed in ways that were not decisions: one class was called ScenarioConfig and the other NorwayScenarioPreset (the rename that separated “configuration” from “pipeline” reached one region), their RulesetFn aliases disagreed, their REQUIRED_ARTIFACTS lived in different modules, and each carried its own _stable_seed – one joining on "|" and masking to 31 bits, the other joining on ":" and taking half as many hex digits. Nothing scientific distinguishes them; a seed derivation that differs by region is a difference that will be read as meaningful.

class pyforestry.simulation.presets.ScenarioConfig(preset_id: str, scenario_id: str, region: str, required_artifacts_: tuple[str, ...] = ())[source]#

Bases: SimulationPreset

A region’s scenario configuration: seeds, stage names, rulesets, artifacts.

A configuration declares and run_scenario() executes: stages() names the steps a period runs, in order; rulesets() supplies the management plan; guard_policy() the non-formula guard flags; and seed_strategy() each stand’s seed. All four are read and applied by the runner, and all four are written into the run manifest.

This said “this runs nothing” until the runtime existed, and it was true: the only thing that consumed a configuration filled its artifacts with a seeded random walk and stamped them synthetic.

A region subclasses this to declare its own stages, rulesets and identity; everything else – the seed derivation, the guard flags, the artifact accessor, the provenance sentinel – is shared, and was duplicated per region with unexplained divergences before.

Parameters:
  • preset_id – Identifies the configuration, e.g. "sweden_minimal".

  • scenario_id – Identifies the scenario within it, e.g. "baseline".

  • region – The region this configuration belongs to, used in its provenance.

  • required_artifacts – Artifact filenames a run under this configuration must emit. Trailing underscore because required_artifacts() is the accessor the contract names.

property component_id: str#

Stable identifier (<preset_id>/<scenario_id>).

property components: Sequence[Describable]#

Describable components composed by this configuration.

Empty: a configuration selects rulesets, it does not compose models. The science belongs to whatever a runtime would drive with it.

forcings() → ForcingSet[source]#

Return the forcings this scenario declares.

A forcing is a named value the run reads for the period it is in – a weather correction, a disturbance rate, a price index – imposed from outside the models. Empty by default, which is what every scenario in this package declares: each such number is a claim about the world, and the package has no basis for one. Callers supply theirs to run_scenario().

guard_policy() → Mapping[str, object][source]#

Return non-formula guard policy flags a run would apply.

required_artifacts() → Sequence[str][source]#

Return the artifact names required by this configuration.

required_artifacts_: tuple[str, ...] = ()#
rulesets() → Mapping[str, Callable[[], ManagementPlan]][source]#

Return scenario rulesets keyed by concern.

Raises:

NotImplementedError – Always; a region declares its own rulesets.

seed_strategy(**kwargs: Any) → int[source]#

Derive this scenario’s deterministic seed from the run’s global seed.

Parameters:

**kwargs – Must contain global_seed.

Returns:

A seed stable across processes and platforms.

property source: SourceReference#

Bibliographic provenance for this configuration (there is none).

stages() → Sequence[str][source]#

Return the ordered stage identifiers a run would execute.

Raises:

NotImplementedError – Always; a region declares its own stages.

pyforestry.simulation.presets.ScenarioConfigBase#

Historic name for ScenarioConfig. It described a base class that subclasses had to complete with three undeclared attributes; those are fields on ScenarioConfig now.

pyforestry.simulation.presets.stable_seed(*parts: object) → int[source]#

Derive a deterministic non-negative seed from arbitrary identifying parts.

Parameters:

*parts – Values identifying the run – preset id, scenario id, global seed.

Returns:

A stable seed in [0, 2**31), the same for the same parts on any platform and in any process.

pyforestry.simulation.provenance module#

Collecting the citations behind a run.

One traversal, used by pyforestry.project() and by the scenario runner, so a run manifest and a ProjectionResult report the same papers for the same models. The scenario runbook used to walk components itself with a second, laxer copy that fell back to {"class": type(c).__name__} for anything that did not describe itself – a class name in a provenance record, where a citation should be.

pyforestry.simulation.provenance.as_manifest_entries(provenance: Mapping[str, SourceReference]) → list[dict[str, Any]][source]#

Render collected citations as JSON-serialisable manifest entries.

pyforestry.simulation.provenance.as_manifest_source(source: SourceReference | None) → dict[str, Any] | None[source]#

Render one citation as a manifest record, or None for no citation.

The single spelling of a citation in a run manifest. There were two – this module’s, and a private one in pyforestry.simulation.forcing – and a third was about to be written for the discount rate. Several conventions for one value is no convention.

pyforestry.simulation.provenance.collect_provenance(component: Any) → Dict[str, SourceReference][source]#

Return every citation component and its components carry, by id.

components is Sequence[Describable]: each entry declares its own component_id and source, and each is descended into, so a composition of compositions reports the papers at the bottom.

Raises:

TypeError – If an entry of components does not describe itself. Skipping them is how seven mortality citations once went missing without a word.

pyforestry.simulation.scenario module#

Running a scenario configuration over real stands, and recording how.

This is what closes the gap the configuration tier carried from the start: a ScenarioConfig declared a seed strategy, an ordered list of stage names, rulesets and a guard policy, and nothing executed any of them. The harness that consumed it filled its artifacts with a seeded random walk and stamped them synthetic: true.

run_scenario() executes all four. It resolves stages() into steps via pyforestry.simulation.stages, seeds each stand from seed_strategy(), steps a real GrowthModel through run_pipeline(), applies the guard policy, and writes the volume balance each stand actually produced.

The manifest is the point. It records which models ran with which citations, which stages in which order, which ruleset values were applied, the guard policy, which price list the money is in, the discount rate and every seed – so a summary row can be read back against the construction that produced it without rerunning anything.

The discount rate is here rather than on ScenarioRunResult.net_present_value() because the summary carries a net present value, and a figure in an artifact has to have been discounted at a rate the artifact records. As a call argument it was whatever the reader happened to pass, which is fine for exploring and useless in a file. A run that prices its removals must now state a rate – 0.0 states that a krona at the end of the horizon is worth a krona now, which is a preference like any other – or declare a DISCOUNT forcing for one that varies by year. The method remains, for asking the same run what it would be worth at another rate.

class pyforestry.simulation.scenario.ScenarioRunResult(artifacts: ~pyforestry.simulation.artifacts.ScenarioArtifacts, rows: tuple[~typing.Mapping[str, ~typing.Any], ...], manifest: ~typing.Mapping[str, ~typing.Any], contexts: tuple[~pyforestry.base.simulation.core.SimulationContext, ...] = (), forcings: ~pyforestry.simulation.forcing.ForcingSet = <factory>, discount_rate: float | None = None)[source]#

Bases: object

What a scenario run produced.

Variables:
  • artifacts (pyforestry.simulation.artifacts.ScenarioArtifacts) – The three written files.

  • rows (tuple[Mapping[str, Any], ...]) – The summary rows, as written.

  • manifest (Mapping[str, Any]) – The manifest payload, as written.

  • contexts (tuple[pyforestry.base.simulation.core.SimulationContext, ...]) – One finished context per stand, in stand order, for a caller that wants the history rather than the summary.

  • forcings (pyforestry.simulation.forcing.ForcingSet) – The forcings this run applied, kept so a horizon calculation can read a year-varying discount rate off the same set.

  • discount_rate (float | None) – The flat rate the run was configured with, and the one its summary’s net_present_value column was discounted at. None when a DISCOUNT forcing supplied the rate instead, or when the run priced nothing.

cash_flows() → tuple[tuple[int, tuple[CashFlow, ...]], ...][source]#

Return each stand’s revenue by year, as (stand_id, flows).

Empty for a run that never harvested, or one whose configuration declares no valuation stage.

contexts: tuple[SimulationContext, ...] = ()#
discount_rate: float | None = None#
net_present_value(*, discount_rate: float | None = None, base_year: float | None = None) → dict[int, float][source]#

Return each stand’s discounted revenue over the horizon, by stand id.

The revenue in each entry is already nominal – a PRICE forcing was applied in the year it was earned – so discounting it here completes the pair: prices move the money, the discount rate moves it in time, and the manifest records both separately.

Parameters:
  • discount_rate – A flat annual rate. Defaults to the rate the run was configured with, so calling this with no arguments reproduces the summary’s net_present_value column. Pass one to ask what the same run would be worth at a different rate. Ignored if the run applied a DISCOUNT forcing, which may vary by year.

  • base_year – The year to express the total in. Defaults to the run’s first, which is the year the summary column is expressed in.

Returns:

Stand id to net present value. Zero for a stand that earned nothing.

property start_year: float#

The calendar year the projection began in.

thinnings() → tuple[tuple[int, tuple[Mapping[str, Any], ...]], ...][source]#

Return the thinnings each stand actually performed, as (stand_id, fired).

A scheduled point falls due in the period whose span covers it, so a thinning asked for at age 62 of a run stepping five years from 40 happens at 60. Each entry says both, which is the difference between what a run was asked for – the manifest’s management_schedule – and what it did.

class pyforestry.simulation.scenario.StandUnit(stand_id: int, stand: Stand, species: Any | None = None, age: AgeMeasurement | None = None)[source]#

Bases: object

One stand in a scenario run, with the id its summary row is keyed by.

Parameters:
  • stand_id – The id its summary row is keyed by.

  • stand – The inventory to project.

  • species – What the stand is, for pricing removals an aggregate model produces. Such a model steps a whole-stand basal area, and Stand.set_aggregate_metrics drops species detail by design – a total genuinely has none – so the species cannot be recovered from the metrics after the first step. Whoever built the stand knows it; this is where they say so. Unnecessary for a tree list, where every stem carries its own.

  • age – How old this stand is when the projection starts, as Age.TOTAL(...) or Age.DBH(...). Overrides the run’s start_age, because real inventory is not all one age and a thinning prescribed at age 60 falls in a different year for each stand. Only needed by a run that schedules its thinnings by age.

age: AgeMeasurement | None = None#
pyforestry.simulation.scenario.run_scenario(config: ScenarioConfig, *, build_model: Callable[[], GrowthModel], stands: Sequence[StandUnit], volume: Callable[[SimulationContext], float], global_seed: int, n_steps: int, step_years: float | None = None, output_dir: Path, attrs: Mapping[str, Any] | None = None, valuation: ValuationSettings | None = None, discount_rate: float | None = None, disturbance_rate_per_year: float = 0.0, thin_at_age: Sequence[AgeMeasurement] | None = None, thin_at_year: Sequence[float] | None = None, start_age: AgeMeasurement | None = None, time_to_breast_height: float | None = None, start_year: float = 0.0, forcings: ForcingSet | None = None, mean_tree: Callable[[SimulationContext, float], MeanTree | None] | None = None) → ScenarioRunResult[source]#

Run config over stands and write the three artifacts.

Parameters:
  • config – The scenario configuration. Its stages(), rulesets(), guard_policy() and seed_strategy() are all executed.

  • build_model – Builds a fresh model per stand. A model may carry per-run state, so stands must not share one.

  • stands – The stands to project, each with the id its row is keyed by.

  • volume – Standing volume of the stand as it currently is, in m³/ha, evaluated on demand. It must be a function of the context’s present state: a reporter that reads a value the model cached at its last step cannot see a thinning, and every cubic metre a removal takes out reappears in the growth column. Required, with no default, for that reason.

  • global_seed – The run’s seed. Each stand’s is derived from it through config.seed_strategy(), so the summary does not depend on the order the stands were evaluated in.

  • n_steps – Number of periods to project.

  • step_years – Period length. Defaults to the model’s declared native_step_years, and to 5 years for a model that declares none.

  • output_dir – Where the artifacts go; created if absent.

  • attrs – Extra model attributes, merged into every stand’s context.

  • valuation – Price list, taper and bucking settings. Required if the configuration declares a "valuation" stage. The price list’s PricelistIdentity goes into the manifest, so give it one: it is what says what currency the summary’s money is in and whose prices earned it.

  • discount_rate – The annual rate the summary’s net present value is discounted at, e.g. 0.03. Required if the configuration declares a "valuation" stage and no DISCOUNT forcing supplies a year-varying one; rejected if it declares no such stage, since then there is nothing to discount. 0.0 states no time preference. It is a decision rather than a finding, and the manifest records it as one.

  • disturbance_rate_per_year – The annual share of the stand a scenario disturbance removes, before ScenarioFactors.disturbance_factor. The caller supplies this and it has no default source. None of these growth models predicts windthrow, fire or bark beetle, and this package ships no disturbance rate for any region – a rate belongs to a risk model or an inventory of observed damage, and there is neither here yet. Zero, the default, makes the stage an exact no-op; anything else is the analyst’s number and is recorded in the manifest as such.

  • thin_at_age – Stand ages at which the management stage thins, as Age.TOTAL(60) or Age.DBH(47). The one to reach for: a thinning prescription is a statement about how old a stand is, and an age says which stand it means even when a run holds stands of several ages. Requires start_age or a per-stand StandUnit.age, since nothing else says how old a stand is – a model’s own clock starts wherever that model starts it.

  • thin_at_year – Calendar years at which it thins instead, read against start_year, for a schedule tied to something outside the stand. Mutually exclusive with thin_at_age.

  • start_age – How old the stands are when the projection begins, for a run that schedules by age. A StandUnit.age overrides it per stand.

  • time_to_breast_height – Years the stands took to reach 1.3 m, needed only to schedule in one age measure a run whose stands are described in the other. There is no default: it depends on the species and the site, and this package will not guess it in a region-agnostic runner.

  • start_year – Calendar year the projection begins in. Every period stamps its own year onto the context, and that is the year a forcing series is read at, so a weather correction given as {2014: 1.04, ...} lines up with the run.

  • forcings – Named values the run reads for the period it is in – a weather correction, a disturbance factor, a price index. Merged after the configuration’s own, so a caller’s forcing composes with rather than replaces it. This package ships none: each is a claim about the world, and every one a run applies is recorded in its manifest with its citation.

  • mean_tree – How to read the representative stem of a stand that holds no individual ones, so an aggregate model’s thinning can still be bucked. Required to value such a run: only the caller knows which height its model predicts, and a model that gives dominant height has no mean height to substitute.

Returns:

The artifacts, the rows, the manifest and the finished contexts.

Raises:

ValueError – If n_steps is not positive, no stands were given, a stage name is unknown, the discounting and the valuation stage disagree, or a guard rejects an input.

pyforestry.simulation.stages module#

Turning a scenario’s stage names into the steps a run executes.

ScenarioConfig.stages() returns ("growth", "disturbance", "valuation") – names, in the order they run. build_pipeline() resolves each to a Step. Until it existed, the names were recorded into a manifest and nothing ran them, which is most of why the whole configuration tier executed nothing.

Two of the four stages are the runtime’s own (GrowthStep, ValuationStep). Two are defined here because they are what a scenario adds on top of a published model:

  • ScenarioDisturbanceStep – an annual disturbance rate, scaled by any DISTURBANCE forcing. None of the growth models in this package predicts windthrow, fire or bark beetle, so this is an addition to the model rather than a distortion of it.

  • ScenarioGrowthStep – growth, with any GROWTH forcing applied to the period’s increment. This one is a distortion of a published model’s prediction, which is why it is a separate named stage, why it is an exact no-op when nothing forces growth, why the forcing is recorded in the run manifest, and why it refuses to run on a representation where “the increment” is not a well-defined thing to scale.

Every step reads its forcings at the period’s calendar year, which CalendarStep stamps onto the context. That is what lets a forcing be a year-by-year series – a weather correction, an inflation index – rather than one number for the run.

pyforestry.simulation.stages.BY_AGE = 'age'#

A thinning schedule counted in stand age – the recommended one.

pyforestry.simulation.stages.BY_CALENDAR = 'calendar'#

A thinning schedule counted in calendar years.

class pyforestry.simulation.stages.CalendarStep(start_year: float, name: str = 'calendar')[source]#

Bases: object

Stamp the period’s calendar year and the stand’s age, then advance both.

A projection’s clock is elapsed years from wherever the model started, and some models start it at the stand’s age. A forcing series is keyed by calendar year, and a silvicultural prescription by stand age. This is the one place all of them are reconciled: the run says which calendar year it begins in and how old each stand is, and every step afterwards reads period_year() or period_age() rather than the raw clock.

Both are stamped at the start of the period, so a step reading either gets the year and age the period begins at, whatever order the stages run in. That is why scheduling no longer depends on whether management is placed before or after growth, which the elapsed clock quietly did.

The stand’s start age comes off the context rather than this step, because one pipeline runs every stand and real inventory is not all one age.

First in the pipeline, and installed by build_pipeline() rather than named in a configuration’s stages(): which years a run covers is a property of the run, not of the scenario’s science.

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

Stamp this period’s year and stand age, then move both on by dt.

class pyforestry.simulation.stages.MeanTree(species: Any, diameter_cm: float, height_m: float, stems_removed: float)[source]#

Bases: object

The representative stem a stand without individual stems can be bucked as.

Variables:
  • species (Any) – What it is.

  • diameter_cm (float) – Its diameter, normally the stand’s quadratic mean diameter.

  • height_m (float) – Its height. Lorey’s mean height where the stand has one – a model that predicts only dominant height does not, and substituting that overstates the stem.

  • stems_removed (float) – How many such stems the removal takes out, per hectare.

pyforestry.simulation.stages.STAGE_BUILDERS: dict[str, Callable[[StageContext], Step]] = {'disturbance': <function _build_disturbance>, 'growth': <function _build_growth>, 'management': <function _build_management>, 'valuation': <function _build_valuation>}#

Stage name -> the step it builds. A configuration naming anything else fails at pipeline construction, with the known names listed.

pyforestry.simulation.stages.STAND_AGE_KEY = 'stand_age'#

The age the stand has reached in the period currently running, stamped by CalendarStep in the same measure its start age was given in.

pyforestry.simulation.stages.STAND_SPECIES_KEY = 'stand_species'#

Where a run records the species a stand’s mean-tree removals are priced as. Set from species.

pyforestry.simulation.stages.STAND_START_AGE_KEY = 'stand_start_age'#

The age the stand starts the projection at, as an AgeMeasurement so it says whether it is counted from the seed or from breast height. Set per stand by run_scenario().

class pyforestry.simulation.stages.ScenarioDisturbanceStep(rate_per_year: float = 0.0, forcings: ForcingSet = <factory>, name: str = 'disturbance')[source]#

Bases: object

Remove a scenario-driven share of the stand each period.

rate_per_year * factor compounded over the period, applied to stems and basal area alike. This is not any published model’s mortality: those are in <region>/mortality/ and run inside the models that own them. This is the windthrow-and-fire term a scenario adds, and it is zero unless a run asks for it.

The rate is the caller’s. This package ships none, for either region: a disturbance rate is a finding – from a risk model, or from an inventory of observed damage – and inventing one here would put a number under a scenario’s name with nothing behind it. The manifest records whatever the run was given.

name: str = 'disturbance'#
rate_in(year: float) → float[source]#

The annual rate applied in year, after any disturbance forcing.

rate_per_year: float = 0.0#
run(ctx: SimulationContext, dt: float) → None[source]#

Remove the period’s disturbance share, recording the volume lost.

class pyforestry.simulation.stages.ScenarioGrowthStep(forcings: ForcingSet = <factory>, name: str = 'growth')[source]#

Bases: object

Advance the model, then apply this period’s growth forcing to the increment.

With no forcing on GROWTH this is exactly GrowthStep: the model’s own prediction, untouched. With one, the period’s increment is scaled and the stand rewritten – a scenario device, not science, which is why it is a distinct named step and why the manifest records the forcing that drove it.

The factor is read per period, so a weather correction given as a series ({2014: 1.04, 2015: 1.02, ...}) scales each period by its own year.

Only aggregate stands can be adjusted this way: scaling “the increment” means scaling one basal-area and one stem number. On a tree list the same idea would mean redistributing growth across trees, which is a modelling decision and belongs to a model.

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

Grow one period, scaling the increment by this year’s growth forcing.

Raises:

ValueError – If a forcing other than 1.0 applies to a stand that is not in aggregate mode.

class pyforestry.simulation.stages.StageContext(volume: ~typing.Callable[[~pyforestry.base.simulation.core.SimulationContext], float], management: ~pyforestry.simulation.policy.ManagementPlan | None = None, forcings: ~pyforestry.simulation.forcing.ForcingSet = <factory>, guard_policy: ~typing.Mapping[str, object] = <factory>, valuation: ~pyforestry.simulation.valuation.volume.ValuationSettings | None = None, records_removals: bool = False, disturbance_rate_per_year: float = 0.0, schedule: ~pyforestry.simulation.stages.ThinningSchedule = <factory>, mean_tree: ~typing.Callable[[~pyforestry.base.simulation.core.SimulationContext, float], ~pyforestry.simulation.stages.MeanTree | None] | None = None)[source]#

Bases: object

Everything a stage builder may consult when constructing its step.

Parameters:
  • management – The scenario’s management plan, if it declares one.

  • forcings – The named multipliers this run applies, read at the period’s calendar year. An empty set – the default – leaves every model’s own prediction exactly as it is.

  • guard_policy – Non-formula guard flags declared by the configuration.

  • valuation – Price list, taper and bucking settings, when the run supplies them. A "valuation" stage without them is an error rather than a silently skipped stage.

  • records_removals – Whether a thinning writes to the valuation ledger. True exactly when the configuration declares a "valuation" stage – which is what reads it. Keying this off the settings instead filled a ledger nobody would price for any run that passed a price list to a configuration that values nothing.

  • volume – How to read the stand’s standing volume, in m³/ha.

  • disturbance_rate_per_year – The base annual disturbance rate, before any DISTURBANCE forcing. Zero – the default – makes the disturbance stage an exact no-op.

  • schedule – When the management stage thins, in stand age or calendar years. Empty – the default – makes it an exact no-op.

  • mean_tree – How to read the representative stem of a stand that holds no individual ones, so an aggregate model’s thinning can be bucked. Only the run knows this: a model that predicts dominant height has no mean height, and guessing one here would put a bias in every price.

disturbance_rate_per_year: float = 0.0#
management: ManagementPlan | None = None#
mean_tree: Callable[[SimulationContext, float], MeanTree | None] | None = None#
records_removals: bool = False#
valuation: ValuationSettings | None = None#
pyforestry.simulation.stages.THINNINGS_FIRED_KEY = 'thinnings_fired'#

Where ThinningStep records the ages or years its thinnings actually fired at, so a run can be asked what it did rather than what it was asked for.

class pyforestry.simulation.stages.ThinningSchedule(basis: str, points: tuple[float, ...] = (), age_measure: Age | None = None)[source]#

Bases: object

When a scenario thins, in a measure that means the same thing everywhere.

A projection’s own clock – ctx.state["t"] – is elapsed years from wherever the model started, and where a model starts it is the model’s business: the Elfving (2010) adapter starts at zero, the Kuehne (2022) one at the stand’s total age. Scheduling against it therefore meant two different things in the two regions under one parameter name and one sentence of documentation, and asking Norway to thin at 15 – fifteen years in – silently did nothing at all, because that clock began at 40.

So a schedule says what its numbers are. Either:

  • by age (BY_AGE) – the recommended one, because a silvicultural prescription is a statement about how old the stand is, not about when the analyst pressed start. The points are AgeMeasurement, so they also say whether they are counted from the seed or from breast height.

  • by calendar year (BY_CALENDAR) – for a schedule tied to something outside the stand, and read against the same calendar a forcing series is.

Variables:
age_measure: Age | None = None#
as_manifest() → Mapping[str, Any][source]#

Return this schedule as a manifest record.

classmethod by_age(ages: Sequence[AgeMeasurement]) → ThinningSchedule[source]#

Build an age schedule from measurements that agree on what age they are.

Raises:
  • TypeError – If a point is a bare number. 40 does not say whether it is counted from the seed or from breast height, and the two differ by the years a stand took to reach 1.3 m.

  • ValueError – If the points mix total and breast-height ages.

classmethod by_calendar(years: Sequence[float]) → ThinningSchedule[source]#

Build a calendar-year schedule, read against the run’s start_year.

points: tuple[float, ...] = ()#
class pyforestry.simulation.stages.ThinningStep(thinning_ratio: float, schedule: ~pyforestry.simulation.stages.ThinningSchedule = <factory>, forcings: ~pyforestry.simulation.forcing.ForcingSet = <factory>, records_removals: bool = False, mean_tree: ~typing.Callable[[~pyforestry.base.simulation.core.SimulationContext, float], ~pyforestry.simulation.stages.MeanTree | None] | None = None, name: str = 'management')[source]#

Bases: object

Remove the scenario’s thinning fraction, once, when the schedule falls due.

ManagementStep is the general mechanism – a policy proposes actions and this is one policy’s worth of it – expressed as a step because a scenario’s management is a fraction and a schedule rather than a callable a caller writes.

A scheduled point falls due in the period whose span contains it, rather than in a period that begins exactly on it. Matching exactly meant that a thinning asked for anywhere off the period grid – age 24 of a run stepping five years from 0 – did nothing whatsoever, wrote a summary reporting no harvest and no revenue, and passed the artifact contract, because a run that harvests nothing legitimately earns nothing. There was no way to tell it from a scenario that had deliberately not thinned.

mean_tree: Callable[[SimulationContext, float], MeanTree | None] | None = None#

How to read the mean stem of a stand that holds no individual ones.

name: str = 'management'#
ratio_in(year: float) → float[source]#

The fraction removed in year, after any thinning forcing.

Clamped to 1.0: a forcing that would take more than the stand holds takes the stand.

records_removals: bool = False#

Whether to record what was removed for pricing. False when the pipeline has no valuation stage: a ledger nobody reads is wasted work.

run(ctx: SimulationContext, dt: float) → None[source]#

Thin if this period’s span covers one of the scheduled points.

pyforestry.simulation.stages.build_pipeline(stages: Sequence[str], stage_context: StageContext, *, start_year: float | None = None) → tuple[Step, ...][source]#

Resolve stage names into the ordered steps a period runs.

Parameters:
  • stages – The configuration’s stages(), in order.

  • stage_context – What the builders may consult.

  • start_year – Calendar year the run begins in. When given, a CalendarStep is prepended so year-by-year forcings can be read; it is not one of the configuration’s stages, because which years a run covers belongs to the run rather than to the scenario.

Returns:

One step per name, in the same order, after the calendar step if any.

Raises:

ValueError – If a name is not a known stage.

pyforestry.simulation.stages.known_stages() → tuple[str, ...][source]#

Return every stage name build_pipeline() can resolve, sorted.

pyforestry.simulation.stages.period_age(ctx: SimulationContext) → AgeMeasurement | None[source]#

Return the stand’s age at the start of the period currently running.

None for a run that never said how old its stands were, which is every run that does not schedule by age.

Module contents#

Scenario presets, harvest valuation, and shared run services.

What was here before – StageRuntime, Stage, StandComposite, StandPart, StandAction and their dispatch types – was a per-part scheduling runtime. Every model in this package steps the whole stand, so its one consumer had to make N-1 of every N stage invocations inert with a latch, and bypassed the dispatch machinery entirely for thinning. The scheduler that replaced it is pyforestry.base.simulation.pipeline, which schedules stands.

CheckpointSerializer went with it: it serialised a composite, and SimulationContext.checkpoint() is the checkpoint mechanism that has a consumer.