pyforestry.simulation package#
Subpackages#
- pyforestry.simulation.services package
- pyforestry.simulation.valuation package
- Submodules
- pyforestry.simulation.valuation.cashflow module
- pyforestry.simulation.valuation.removals module
CohortRemovalMeanTreeRemovalStandRemovalLedgerStandRemovalLedger.add_cohort()StandRemovalLedger.extend()StandRemovalLedger.is_emptyStandRemovalLedger.iter_cohorts()StandRemovalLedger.iter_mean_tree_removals()StandRemovalLedger.iter_tree_removals()StandRemovalLedger.record_mean_tree()StandRemovalLedger.record_tree()StandRemovalLedger.stand_idStandRemovalLedger.total_volume_m3StandRemovalLedger.total_weightStandRemovalLedger.tree_count
TreeRemoval
- pyforestry.simulation.valuation.step module
- pyforestry.simulation.valuation.volume module
- Module contents
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_appliedandguard_policyare 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.valuationis 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 thevaluationblock 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"addedvaluation.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"replacedthin_at_yearswithmanagement_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:
objectResolved 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.
valuedis 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_m3does not equalinitial + 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:
ProtocolContract for a regional scenario configuration.
Implemented by
pyforestry.simulation.presets.ScenarioConfigand executed bypyforestry.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, andrequired_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()andguard_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/andpyforestry.project().
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 —
ConstantForcingholds one value for the whole run;AnnualForcinga 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 optionalkeyand 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 withForcingSet.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:
objectA 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_seriesis not one of the three.
- 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
yearnor the calendar year containing it, andoutside_seriesis"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:
objectOne 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.0for a multiplier,0.0for one of theRATE_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.
- 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 withstated_choice().
- class pyforestry.simulation.forcing.Forcing(*args, **kwargs)[source]#
Bases:
ProtocolA named value a run can read for the period it is in.
- class pyforestry.simulation.forcing.ForcingSet(forcings: tuple[~pyforestry.simulation.forcing.Forcing, ...]=<factory>)[source]#
Bases:
objectThe 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 by1.0, so a run that declares none gets exactly what the models predict.- 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
nameinyear.- 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.0if 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.
- value(name: str, year: float, *, default: Any = None) Any[source]#
Return the last value declared for
nameinyear, ordefault.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
nameevaluated atyear, 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.0instead of1.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:DISCOUNTat0.0– no time preference, the answer this package documents as legitimate – was refused as uncited, while1.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
valueleaves 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
namethat changes nothing:0.0or1.0.- Parameters:
name – The forcing’s name. Anything not in
RATE_FORCINGSis 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, whichbuild_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")raisedValueErrorin Sweden and silently returned the baseline in Norway;scenario_factors("bogus")likewise;and
thinning_ratiowas0.20in Sweden – a fraction of stems removed – against1.0in 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:
objectWhat 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.
- 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_ratioonce 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, andplan()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_*returningtuple(sorted(table))and a lookup delegating tolookup_scenario(). The two copies were identical apart from the table and the string inwhat, 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").
- pyforestry.simulation.policy.lookup_scenario(table: Mapping[str, _T], scenario_id: str, *, what: str) _T[source]#
Look
scenario_idup intable, 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_idis not intable.
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:
SimulationPresetA 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; andseed_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).
- 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 onScenarioConfignow.
- 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
Nonefor 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
componentand its components carry, by id.componentsisSequence[Describable]: each entry declares its owncomponent_idandsource, and each is descended into, so a composition of compositions reports the papers at the bottom.- Raises:
TypeError – If an entry of
componentsdoes 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:
objectWhat 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_valuecolumn was discounted at.Nonewhen aDISCOUNTforcing 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
PRICEforcing 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_valuecolumn. Pass one to ask what the same run would be worth at a different rate. Ignored if the run applied aDISCOUNTforcing, 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:
objectOne 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_metricsdrops 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(...)orAge.DBH(...). Overrides the run’sstart_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
configoverstandsand write the three artifacts.- Parameters:
config – The scenario configuration. Its
stages(),rulesets(),guard_policy()andseed_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’sPricelistIdentitygoes 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 noDISCOUNTforcing supplies a year-varying one; rejected if it declares no such stage, since then there is nothing to discount.0.0states 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)orAge.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. Requiresstart_ageor a per-standStandUnit.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 withthin_at_age.start_age – How old the stands are when the projection begins, for a run that schedules by age. A
StandUnit.ageoverrides 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_stepsis 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 anyDISTURBANCEforcing. 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 anyGROWTHforcing 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:
objectStamp 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()orperiod_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’sstages(): 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:
objectThe 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
CalendarStepin 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
AgeMeasurementso it says whether it is counted from the seed or from breast height. Set per stand byrun_scenario().
- class pyforestry.simulation.stages.ScenarioDisturbanceStep(rate_per_year: float = 0.0, forcings: ForcingSet = <factory>, name: str = 'disturbance')[source]#
Bases:
objectRemove a scenario-driven share of the stand each period.
rate_per_year * factorcompounded 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_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:
objectAdvance the model, then apply this period’s growth forcing to the increment.
With no forcing on
GROWTHthis is exactlyGrowthStep: 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:
objectEverything 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
DISTURBANCEforcing. 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
ThinningSteprecords 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:
objectWhen 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 at15– 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 areAgeMeasurement, 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 aforcingseries is.
- Variables:
basis (str) –
BY_AGEorBY_CALENDAR.points (tuple[float, ...]) – The ages or years to thin at, sorted.
age_measure (pyforestry.base.helpers.primitives.age.Age | None) –
Age.TOTALorAge.DBHfor an age schedule;Nonefor a calendar one.
- 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.
40does 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:
objectRemove the scenario’s thinning fraction, once, when the schedule falls due.
ManagementStepis 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
CalendarStepis 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.
Nonefor 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.