pyforestry.base.simulation package#

Submodules#

pyforestry.base.simulation.adapters module#

Convert a stand into an inventory representation a model can step.

An angle-count stand carries tallies, not stems; a model that needs a tree list or diameter classes cannot read it directly. These adapters bridge that gap, and GrowthModel.build_context() picks one automatically and records which ran in ctx.attrs["inventory_adapter"], because a reconstructed inventory is not the same evidence as a measured one.

The pseudo-tree adapters are the strongest claim here: they place every stem of a species at the stand QMD, which reproduces the basal area and stem count exactly and the diameter distribution not at all.

class pyforestry.base.simulation.adapters.Adapter[source]#

Bases: object

Protocol for inventory adapters that construct runnable inventories from a Stand.

adapt(stand, **kwargs) → Dict[str, Any][source]#

Build the inventory payload for SimulationContext.

Returns:

A single-key mapping – {"plots": ...}, {"dclass": ...} or {"metrics": ...} – matching this adapter’s target_mode.

can_adapt(stand) → bool[source]#

Whether this adapter can produce its target mode from stand.

class pyforestry.base.simulation.adapters.AdapterRegistry[source]#

Bases: object

Named lookup for the inventory adapters build_context chooses from.

static default() → AdapterRegistry[source]#

Default.

find_for(target_mode: str) → List[Adapter][source]#

Find for.

get(name: str) → Adapter | None[source]#

Get.

register(adapter: Adapter) → None[source]#

Add adapter under its own name, replacing any previous one.

class pyforestry.base.simulation.adapters.AngleCountToDiameterClassAdapter(name: str = 'angle_count_to_diameter_class', target_mode: str = 'diameter_class')[source]#

Bases: Adapter

Build a diameter-class inventory from Angle-Count BA/N (single bin at QMD by default).

adapt(stand, **kwargs) → Dict[str, Any][source]#

Bin each species into a single class at its own QMD.

A relascope tally supports one class per species and no more; the width of the real distribution is not in the data.

can_adapt(stand) → bool[source]#

Whether the stand carries angle-count basal-area and stem estimates.

name: str = 'angle_count_to_diameter_class'#
target_mode: str = 'diameter_class'#
class pyforestry.base.simulation.adapters.AngleCountToPseudoTreesAdapter(name: str = 'angle_count_pseudo_tree_list', target_mode: str = 'tree_list', replicas_per_species: int = 32)[source]#

Bases: Adapter

Build a surrogate tree list from Angle-Count BA/N to enable tree-list actions.

adapt(stand, **kwargs) → Dict[str, Any][source]#

Replicate each species’ stems as identical trees at the stand QMD.

Reproduces basal area and stem count exactly and the diameter distribution not at all: every surrogate stem of a species carries the same diameter. Splitting the stems over replicas_per_species records exists so per-tree operations (thinning a fraction, removing the smallest) have something to bite on, not to represent variation.

can_adapt(stand) → bool[source]#

Whether the stand has the angle-count basal-area and stem estimates.

name: str = 'angle_count_pseudo_tree_list'#
replicas_per_species: int = 32#
target_mode: str = 'tree_list'#
class pyforestry.base.simulation.adapters.AngleCountToSpatialPseudoTreesAdapter(name: str = 'angle_count_spatial_pseudo_tree_list', target_mode: str = 'spatial', replicas_per_species: int = 32)[source]#

Bases: AngleCountToPseudoTreesAdapter

As above but assigns random XY within a 1-ha plot.

adapt(stand, **kwargs) → Dict[str, Any][source]#

Place the surrogate stems at uniform random positions in a 1 ha plot.

The coordinates are drawn, not observed: they support neighbourhood operations that need some geometry, and say nothing about where the tallied trees actually stood.

name: str = 'angle_count_spatial_pseudo_tree_list'#
target_mode: str = 'spatial'#
class pyforestry.base.simulation.adapters.TreeListToDiameterClassAdapter(name: str = 'tree_list_to_diameter_class', target_mode: str = 'diameter_class', bin_width_cm: float = 2.0)[source]#

Bases: Adapter

Histogram from a real tree list.

adapt(stand, **kwargs) → Dict[str, Any][source]#

Histogram the measured stems into fixed-width diameter classes.

Each tree contributes weight_n stems per hectare of its plot’s effective (post-occlusion) area to the class its diameter falls in.

bin_width_cm: float = 2.0#
can_adapt(stand) → bool[source]#

Whether the stand holds measured trees rather than angle-count tallies.

name: str = 'tree_list_to_diameter_class'#
target_mode: str = 'diameter_class'#
class pyforestry.base.simulation.adapters.TreeListToSpatialAdapter(name: str = 'tree_list_to_spatial', target_mode: str = 'spatial')[source]#

Bases: Adapter

Ensure every tree has a position; fill missing uniformly within its plot.

adapt(stand, **kwargs) → Dict[str, Any][source]#

Fill in a position for every tree that lacks one.

Missing coordinates are drawn uniformly within the tree’s own plot. Trees that already carry a position keep it.

can_adapt(stand) → bool[source]#

Whether the stand holds measured trees rather than angle-count tallies.

name: str = 'tree_list_to_spatial'#
target_mode: str = 'spatial'#

pyforestry.base.simulation.core module#

Core simulation context, actions, and metric utilities.

class pyforestry.base.simulation.core.ActionSpec(name: str, fn: ~typing.Callable[[...], None], description: str = '', params: ~typing.Dict[str, ~typing.Any] = <factory>, requires_modes: ~typing.List[str] | None = None)[source]#

Bases: object

Declarative action descriptor with mode gating.

requires_modes is the gate: the inventory modes this action can run in, or an empty list for one that works in all of them. A second field, requires_tree_list, used to mean ["tree_list", "spatial"] and was unioned with this one – two spellings of the same gate, with nothing in the package setting the older.

description: str = ''#
requires_modes: List[str] | None = None#

Inventory modes this action may run in, e.g. ["tree_list", "spatial"]. None or [] means every mode.

class pyforestry.base.simulation.core.HistoryEntry(t: float, op: str, details: Dict[str, Any], model_state: Dict[str, Any], metrics: Mapping[str, Mapping[TreeName | str, StandBasalArea | Stems | QuadraticMeanDiameter]], pre_snapshot: Dict[str, Any], post_snapshot: Dict[str, Any])[source]#

Bases: object

History record capturing a single operation and its snapshots.

class pyforestry.base.simulation.core.MetricMap[source]#

Bases: TypedDict

Mutable metrics mapping keyed by metric name and species.

class pyforestry.base.simulation.core.SimulationContext(*, mode: str, area_ha: float | None, site: Any | None, origin_ref: Any, inventory: Dict[str, Any], initial_state: Dict[str, Any], model: Any, initial_attrs: Dict[str, Any] | None = None, random_bundle: Any | None = None)[source]#

Bases: object

The context of one run: a stand, a clock, an audit trail.

The stand is the state. This class used to own a second copy of it – its own metric store, its own diameter-class inventory, its own plot-to-stand estimator – and the two disagreed by a factor that grew with the number of species in the stand. Now stand holds every representation and every metric, and this class holds what is genuinely about the run: which inventory mode the model asked for, where time stands, the model itself, the RNG bundle, and the history.

mode (∈ {"spatial", "tree_list", "diameter_class", "aggregate"}) is the model’s requirement, not the stand’s storage: spatial and tree_list are the same representation, differing in whether the model needs tree positions.

property area_ha: float | None#

The stand’s area in hectares, if known.

checkpoint(*, include_history: bool = False, history_tail: int | None = None) → Dict[str, Any][source]#

Return a serialisable snapshot of the context state and inventory.

The checkpoint includes mode, area/site provenance, state/attrs, and the current inventory representation (plots for tree_list/spatial, diameter classes for diameter_class, aggregate metrics otherwise). History is not captured unless explicitly requested.

property diameter_classes: Dict[Any, Dict[str, List[float]]]#

The stand’s diameter-class inventory, keyed by species.

Each entry holds bin_mids_cm and a matching n_per_ha. This is a copy: mutate it freely and hand it back through set_diameter_class(), which revalidates and refreshes the metrics.

Raises:

RuntimeError – If the context is not in diameter-class mode.

do(action: str, **kwargs: Any) → None[source]#

Execute a capability the model declares, gated on the inventory mode.

classmethod from_checkpoint(model: Any, checkpoint: Mapping[str, Any], *, random_bundle: Any | None = None) → SimulationContext[source]#

Restore a context from checkpoint produced by checkpoint().

model must be the growth model instance that will drive the context. random_bundle receives any RNG state the checkpoint carries. Pass one to restore into a bundle you already hold; otherwise a checkpoint that records its root seed rebuilds an equivalent bundle here, which is what lets a checkpoint cross a process boundary – run_parallel() hands one to a worker that has no bundle to give.

A checkpoint carrying RNG state but no seed still raises: resuming that one without a bundle would silently continue on a fresh stream.

Raises:

ValueError – If the checkpoint carries RNG state that cannot be restored – no bundle given, and no root seed recorded.

holds_tree_list() → bool[source]#

Whether this run’s stand stores individual trees.

True for mode "tree_list" and "spatial", which are the same storage and differ only in whether the model needs tree positions.

This is the one place the two vocabularies are reconciled. mode is the model’s requirement and stand.representation is the stand’s storage; they answer different questions but overlap in their values, and methods here keyed off whichever came to hand – snapshot() branched on the representation while checkpoint() branched on the mode, so the two would have described different stands the first time they disagreed.

property metrics: Mapping[str, Mapping[TreeName | str, StandBasalArea | Stems | QuadraticMeanDiameter]]#

Return a read-only copy of the current metrics.

property plots: List[CircularPlot]#

The working copy’s plots.

Raises:

AttributeError – If the run’s stand holds no individual trees.

property rng: Any#

The run’s root random stream. Derive sub-streams with .child(...).

Every stochastic kernel in this package takes its generator as a parameter and none constructs one, because a generator built where it is used cannot be seeded by the run, cannot be checkpointed, and – when two of them are seeded from the same scalar, as the Elfving composite once did – makes the interleaving of draws across them an unwritten part of the result. Ask for a keyed stream instead:

rng = ctx.rng.child("mortality").child(str(species))
Raises:

RuntimeError – If the context was built without a random bundle. A run that draws random numbers needs a seed, and defaulting to an unseeded generator would make it silently irreproducible.

scale_stems(factor: float) → None[source]#

Scale aggregate stems and basal area by factor.

Raises:

RuntimeError – If the context is not in aggregate mode.

set_aggregate_metrics(*, ba_total: float, stems_total: float) → None[source]#

Set aggregate basal area and stems, then recompute QMD.

Raises:

RuntimeError – If the context is not in aggregate mode.

set_diameter_class(dclass: Dict[Any, Dict[str, List[float]]]) → None[source]#

Replace diameter-class inventory and recompute metrics.

Raises:

RuntimeError – If the context is not in diameter-class mode.

set_species_metrics(*, basal_area: Mapping[Any, Any], stems: Mapping[Any, Any]) → None[source]#

Publish a per-species aggregate result, deriving totals and QMD.

This is the public path for a model that steps species cohorts and knows the breakdown. Writing ctx._metrics directly used to be the only way, which put two modules’ arithmetic in charge of the same invariants.

Raises:

RuntimeError – If the context is not in aggregate mode.

property site: Any | None#

The stand’s site reference, if any.

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

Return a lightweight snapshot of state and aggregate totals.

to_pandas() → DataFrame[source]#

Return the history log as a pandas DataFrame.

update_step(years: float) → None[source]#

Advance the model by years, refresh the metrics, record the step.

This is the atom, not the schedule. Management used to be threaded through here as a dict of {"pre": [...], "mid": [...], "post": [...]} action names – one of the three schedulers this package carried. It is now a ManagementStep placed where you want it in a pipeline, which is the same capability with an ordering you can read.

pyforestry.base.simulation.ensemble module#

Batch execution utilities for running ensembles of contexts.

class pyforestry.base.simulation.ensemble.BatchEngine[source]#

Bases: object

Protocol for batch engines.

grow(model: Any, vec: Dict[str, ndarray], dt: float, extra: Mapping[str, Any] | None = None) → Dict[str, ndarray][source]#

Advance a batch of aggregate metrics by dt.

class pyforestry.base.simulation.ensemble.ContextEnsemble(contexts: List[SimulationContext], model: Any, engine: BatchEngine | str | None = None)[source]#

Bases: object

Bundle multiple contexts and advance them in batches when possible.

do(name: str, **kwargs: Any) → None[source]#

Execute an action on each context.

engine: BatchEngine | str | None = None#
to_pandas()[source]#

Concatenate history tables from all contexts.

update_step(dt: float) → None[source]#

Advance all contexts by one step, batching aggregate contexts.

Management is not threaded through here. An ensemble steps many contexts together for speed; deciding what to do to each is a per-context policy, so run a run_pipeline() per context when the runs need managing, and use the ensemble when they only need growing.

class pyforestry.base.simulation.ensemble.PythonEngine[source]#

Bases: BatchEngine

NumPy baseline.

grow(model: Any, vec: Dict[str, ndarray], dt: float, extra: Mapping[str, Any] | None = None) → Dict[str, ndarray][source]#

Run the Python batch growth routine for aggregate metrics.

pyforestry.base.simulation.growth_model module#

The growth-model interface every scientific model adapter implements.

A GrowthModel binds a published model to the runtime: it declares what inventory it needs (Requirements), turns a Stand into the working copy it will step (GrowthModel.build_context()), advances that copy (GrowthModel.update_step()), and optionally exposes management actions.

Implementers override requirements, update_step, component_id and source; everything else has a usable default. ExampleStandGeneralModel at the bottom of this module is a minimal working reference, not a scientific model.

class pyforestry.base.simulation.growth_model.ExampleStandGeneralModel(ba_rel_per_year: float = 0.03, mortality_rate_per_year: float = 0.005, diam_cm_inc_per_year: float = 0.2, fertilization_boost: float = 0.01, fertilization_years: float = 5.0)[source]#

Bases: GrowthModel

A minimal working GrowthModel, for tests and as a template.

Not a scientific model: growth is a fixed relative rate and mortality a fixed annual fraction. It exists to show the shape of an implementation – declaring requirements, stepping each inventory mode, exposing actions with mode gates – and to give the runtime something to exercise.

available_actions() → Dict[str, ActionSpec][source]#

Fertilisation, mortality and two thinnings, each gated on the modes it needs.

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

Seed the fertilisation countdown that update_step() decrements.

requirements() → Requirements[source]#

Accept any representation; this model can step all four.

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

Grow, and kill, at fixed rates in whichever mode ctx holds.

Parameters:
  • ctx – The working copy to advance.

  • dt – Length of the step in years.

class pyforestry.base.simulation.growth_model.GrowthModel[source]#

Bases: ABC

Abstract base for growth models with a factory interface.

requirements() and update_step() are abstract, so a model that forgets one fails at instantiation rather than partway through a projection.

source is not given a default. It used to return SourceReference(author="unknown", year=0, title="unknown"), which meant a model that forgot its provenance produced a citation-shaped object that read like a real one in a catalog listing and a provenance report – in a package whose stated value is traceability. Not overriding it now raises, and says what to write.

Inputs: ClassVar[type | None] = None#

The frozen dataclass this model resolves its run inputs into, or None for a model that reads ctx.attrs directly. Declaring it is what lets a caller ask “which models can I run on the data I have?” without running one.

available_actions() → Dict[str, ActionSpec][source]#

Management actions this model exposes to SimulationContext.do().

Returns:

Action specs by name. Empty by default; a model that supports thinning or fertilisation declares it here, along with the inventory modes each action needs.

build_context(stand: Stand, *, mode_hint: str | None = None, use_adapter: str | None = None, adapter_kwargs: Dict[str, Any] | None = None, attrs: Dict[str, Any] | None = None, inputs: Any | None = None, seed: int | None = None, random_bundle: Any | None = None) → SimulationContext[source]#

Build the working context this model will step.

The inventory mode comes from requirements(): a model that declares a concrete inventory gets that mode, because running it on a representation it was not written for is a silent error rather than a graceful degradation. mode_hint is the caller’s override for the "either" case, and subclasses should not pass it from an overridden build_context – declare the mode in requirements() instead.

Parameters:
  • stand – The inventory to build from. Not mutated; the context works on its own plot containers.

  • mode_hint – Override the inventory mode. Only meaningful for models whose requirements say "either".

  • use_adapter – Name of a specific registered adapter to convert angle-count tallies with, instead of the preferred-order default.

  • adapter_kwargs – Extra keyword arguments forwarded to the adapter.

  • attrs – Site and history values the stand does not carry typed, merged over default_attrs(). Pass them here rather than writing them onto ctx.attrs afterwards: resolve_inputs() runs during this call, so anything set later is too late to be resolved or checked.

  • inputs – An already-built Inputs instance. Supplying it skips resolution entirely, which is what a caller that constructed the stand and knows its site should do – the values are then typed at the call site instead of round-tripping through a string-keyed dict.

  • seed – Root seed for the run’s random streams. Every stochastic kernel draws from a stream keyed off this one, so the whole run is reproducible from this single number. A run that draws without a seed raises rather than quietly using an unseeded generator.

  • random_bundle – An existing bundle to use instead of building one from seed. Mutually exclusive with it.

Returns:

A SimulationContext holding a working copy of the inventory, the model’s initial state, the model’s resolved inputs, and an inventory_origin attribute recording how the representation was obtained.

Raises:

ValueError – If use_adapter names an adapter that cannot adapt stand, or if a required model input cannot be resolved.

can_build(stand: Stand, *, allow_adapters: bool = True, mode_hint: str | None = None) → Tuple[bool, List[str]][source]#

Check whether stand can supply what this model needs.

Parameters:
  • stand – The inventory to test.

  • allow_adapters – Whether angle-count tallies may be converted to the required representation. False tests the stand as it stands.

  • mode_hint – Test against this mode instead of the declared requirement.

Returns:

(ok, missing), where missing names each prerequisite the stand cannot meet – "site", "top_height", or a description of the inventory representation it lacks. Call this before build_context() to get a list rather than an exception. The mode it tests is the one build_context() would choose, because both ask _select_mode().

property component_id: str#

Stable identifier for this model. Override in subclasses.

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

Model-specific values seeded into ctx.attrs when a context is built.

Returns:

Initial attributes. Empty by default; override to declare the site and history inputs your update_step reads.

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

Initial run state seeded into ctx.state.

Returns:

t (elapsed years) and years_since_thin, which the runtime and the thinning-response terms of several models both read.

abstractmethod requirements() → Requirements[source]#

Declare the inventory and site data this model needs.

Returns:

build_context() builds that representation rather than guessing from what the stand happens to carry.

Return type:

The model’s prerequisites. The declared inventory is authoritative

resolve_inputs(ctx: SimulationContext) → Any | None[source]#

Resolve this model’s run inputs, once, while the context is being built.

Return an instance of Inputs – the site and plot facts that hold for the whole run, read out of the stand’s typed Site where it has them and out of ctx.attrs where it does not. Raise for anything required and absent.

The point is where it raises. Reading inputs with attrs.get(key, default) at the moment a kernel needs them means a missing input either surfaces twenty frames into a step or, worse, silently becomes its default; and a mistyped key is indistinguishable from an absent one. Doing it here turns both into a named error at build_context, before any time has been simulated.

Values that a step can change – whether a thinning has been simulated, how many fertilised years remain – are run state and do not belong here. Freeze those and the model stops responding to its own management.

Returns:

The resolved inputs, or None if this model declares none.

property source: SourceReference#

Bibliographic provenance for the published model this implements.

Raises:

NotImplementedError – Always; every model must cite its own source.

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

Advance ctx by dt years. This is the model.

Write results into the representation ctx holds – its plots, its diameter classes, or its aggregate metrics – because the context rebuilds its metrics from that representation after every step.

Parameters:
  • ctx – The working copy to advance, in the mode this model requires.

  • dt – Length of the step in years.

class pyforestry.base.simulation.growth_model.Requirements(inventory: Literal['spatial', 'tree_list', 'diameter_class', 'aggregate', 'either'] = 'either', require_site: bool = False, require_top_height: bool = False, native_step_years: float | None = None)[source]#

Bases: object

Declares model prerequisites and preferred/required inventory mode.

native_step_years is the period the model’s functions were fitted for. Declaring it is how a caller learns a model’s step length without triggering its exception: Bollandsås raises unless dt is a whole multiple of 5, Ekö steps 5, Elfving scales linearly from 5 with a warning. None means the model is genuinely step-agnostic, not that nobody filled it in – a model with a native period should say so.

inventory: Literal['spatial', 'tree_list', 'diameter_class', 'aggregate', 'either'] = 'either'#
native_step_years: float | None = None#
require_site: bool = False#
require_top_height: bool = False#

pyforestry.base.simulation.pipeline module#

One scheduler: an ordered pipeline of steps over the whole stand.

There used to be three, and none knew the others existed: ctx.update_step(years, management={"pre": [...], "post": [...]}), SimulationSetup(triggers=..., schedule=...).run(ctx), and StageRuntime with ManagementStage and rulesets. The flagship composite used none of them and hand-coded its ordering in step(), which was the honest admission that none of the three fit.

Two things were wrong with the ones that went.

They scheduled the wrong unit. StageRuntime handed a stage one part at a time, but every real model here steps the whole stand: Ekö 1985 couples every cohort to every other, and Elfving calibrates against whole-stand basal area. Its only consumer therefore carried an _armed latch so that the work happened on the first of N invocations and the remaining N-1 were inert. Here the unit of work is the stand, and a step that wants cohorts loops over them itself.

Management was three mechanisms. A phase dict of action names, a list of trigger objects, and a ruleset tier that its own consumer never used. All three are the same thing – decide what to do, then do it – so management is now one type:

Policy = Callable[[SimulationContext], Sequence[Action]]

A trigger is a policy with an if (when()); a schedule is a policy with a clock comparison (at_times()); a ruleset is a policy that reads a config. combine() puts several together.

A policy that raises, raises. SimulationSetup caught every exception from a trigger and wrote it into the history as a trigger_error row, so a management rule that was broken from the first step produced a full run of plausible numbers and a note nobody read.

class pyforestry.base.simulation.pipeline.Action(name: str, params: ~typing.Mapping[str, ~typing.Any] = <factory>, apply: ~typing.Callable[[~pyforestry.base.simulation.core.SimulationContext], None] | None = None)[source]#

Bases: object

One management operation, proposed by a policy and applied by a step.

Name a capability the model declares in available_actions() and it is dispatched through SimulationContext.do(), which gates it on the inventory mode and records it in the history. Supply apply instead for an operation the model does not declare; the recording is the same, the gating is yours.

apply: Callable[[SimulationContext], None] | None = None#
class pyforestry.base.simulation.pipeline.GrowthStep(name: str = 'growth')[source]#

Bases: object

Advance the model by one period.

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

Run the context’s model for dt years.

class pyforestry.base.simulation.pipeline.ManagementStep(policy: Callable[[SimulationContext], Sequence[Action]], name: str = 'management')[source]#

Bases: object

Ask a policy what to do, then do it.

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

Apply every action the policy proposes for this step.

class pyforestry.base.simulation.pipeline.Step(*args, **kwargs)[source]#

Bases: Protocol

One phase of a period, applied to the whole stand.

A step that needs to work cohort by cohort loops over them itself. That is the inversion: the runtime schedules stands, not parts, because every model in this package couples its parts to each other.

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

Advance ctx through this phase of a dt-year period.

pyforestry.base.simulation.pipeline.at_times(times: Iterable[float], action: Callable[[SimulationContext], Sequence[Action]] | Action, *, tolerance: float = 1e-09) → Callable[[SimulationContext], Sequence[Action]][source]#

Propose action whenever the clock reaches one of times.

This is what a ScheduledOp was. The comparison is against ctx.state["t"] with a tolerance, because a schedule written in whole years and a clock accumulated by repeated addition do not land on the same float.

pyforestry.base.simulation.pipeline.combine(*policies: Callable[[SimulationContext], Sequence[Action]]) → Callable[[SimulationContext], Sequence[Action]][source]#

Return a policy proposing everything policies propose, in order.

pyforestry.base.simulation.pipeline.run_pipeline(ctx: SimulationContext, pipeline: Sequence[Step], *, years: float, step: float, start_t: float | None = None) → SimulationContext[source]#

Run pipeline over ctx for years, one step-year period at a time.

The runner owns the clock. It decides the period lengths up front and stamps ctx.state["t"] after each one, rather than looping until the clock passes the end: a pipeline with no growth step – management only, or a pipeline under construction – would never advance the clock and would run forever. Stamping also removes the drift that accumulates from adding dt to itself twenty times.

Parameters:
  • ctx – The run’s context. Stepped in place and returned.

  • pipeline – Steps in the order they run within each period.

  • years – Total length of the projection.

  • step – Length of one period. A trailing remainder shorter than step is run at its true length rather than rounded up, so a model that scales linearly with dt does not grow the stand for years that did not happen.

  • start_t – Clock value to start from. Defaults to the context’s current t, so a projection can be continued.

Returns:

ctx, advanced.

Raises:

ValueError – If step is not positive or years is negative.

pyforestry.base.simulation.pipeline.when(predicate: Callable[[SimulationContext], bool], action: Callable[[SimulationContext], Sequence[Action]] | Action, *, once: bool = False) → Callable[[SimulationContext], Sequence[Action]][source]#

Propose action on each step where predicate holds.

This is what a TriggerSpec was, minus the check_phase field (a policy runs where you put it in the pipeline) and minus the exception swallowing. With once=True it disarms after firing, which needs mutable state, so the returned closure is not reusable across runs – build a fresh one per run.

Module contents#

The simulation runtime: contexts, growth models, adapters and the pipeline.