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:
objectProtocol for inventory adapters that construct runnable inventories from a Stand.
- class pyforestry.base.simulation.adapters.AdapterRegistry[source]#
Bases:
objectNamed lookup for the inventory adapters
build_contextchooses from.- static default() AdapterRegistry[source]#
Default.
- class pyforestry.base.simulation.adapters.AngleCountToDiameterClassAdapter(name: str = 'angle_count_to_diameter_class', target_mode: str = 'diameter_class')[source]#
Bases:
AdapterBuild 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.
- 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:
AdapterBuild 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_speciesrecords exists so per-tree operations (thinning a fraction, removing the smallest) have something to bite on, not to represent variation.
- 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:
AngleCountToPseudoTreesAdapterAs 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:
AdapterHistogram 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_nstems 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:
AdapterEnsure 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:
objectDeclarative action descriptor with mode gating.
requires_modesis 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"].Noneor[]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:
objectHistory record capturing a single operation and its snapshots.
- class pyforestry.base.simulation.core.MetricMap[source]#
Bases:
TypedDictMutable 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:
objectThe 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
standholds 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:spatialandtree_listare 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_cmand a matchingn_per_ha. This is a copy: mutate it freely and hand it back throughset_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
checkpointproduced bycheckpoint().modelmust be the growth model instance that will drive the context.random_bundlereceives 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.
Trueformode"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.
modeis the model’s requirement andstand.representationis 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 whilecheckpoint()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._metricsdirectly 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.
- 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 aManagementStepplaced 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:
objectProtocol for batch engines.
- class pyforestry.base.simulation.ensemble.ContextEnsemble(contexts: List[SimulationContext], model: Any, engine: BatchEngine | str | None = None)[source]#
Bases:
objectBundle multiple contexts and advance them in batches when possible.
- engine: BatchEngine | str | None = None#
- 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:
BatchEngineNumPy baseline.
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:
GrowthModelA 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
ctxholds.- Parameters:
ctx – The working copy to advance.
dt – Length of the step in years.
- class pyforestry.base.simulation.growth_model.GrowthModel[source]#
Bases:
ABCAbstract base for growth models with a factory interface.
requirements()andupdate_step()are abstract, so a model that forgets one fails at instantiation rather than partway through a projection.sourceis not given a default. It used to returnSourceReference(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
Nonefor a model that readsctx.attrsdirectly. 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 concreteinventorygets that mode, because running it on a representation it was not written for is a silent error rather than a graceful degradation.mode_hintis the caller’s override for the"either"case, and subclasses should not pass it from an overriddenbuild_context– declare the mode inrequirements()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 ontoctx.attrsafterwards:resolve_inputs()runs during this call, so anything set later is too late to be resolved or checked.inputs – An already-built
Inputsinstance. 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
SimulationContextholding a working copy of the inventory, the model’s initial state, the model’s resolvedinputs, and aninventory_originattribute recording how the representation was obtained.- Raises:
ValueError – If
use_adapternames an adapter that cannot adaptstand, 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
standcan supply what this model needs.- Parameters:
stand – The inventory to test.
allow_adapters – Whether angle-count tallies may be converted to the required representation.
Falsetests the stand as it stands.mode_hint – Test against this mode instead of the declared requirement.
- Returns:
(ok, missing), wheremissingnames each prerequisite the stand cannot meet –"site","top_height", or a description of the inventory representation it lacks. Call this beforebuild_context()to get a list rather than an exception. The mode it tests is the onebuild_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.attrswhen a context is built.- Returns:
Initial attributes. Empty by default; override to declare the site and history inputs your
update_stepreads.
- init_state_stub() Dict[str, Any][source]#
Initial run state seeded into
ctx.state.- Returns:
t(elapsed years) andyears_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
inventoryis 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 typedSitewhere it has them and out ofctx.attrswhere 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 atbuild_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
Noneif 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
ctxbydtyears. This is the model.Write results into the representation
ctxholds – 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:
objectDeclares model prerequisites and preferred/required inventory mode.
native_step_yearsis 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 unlessdtis a whole multiple of 5, Ekö steps 5, Elfving scales linearly from 5 with a warning.Nonemeans 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:
objectOne management operation, proposed by a policy and applied by a step.
Name a capability the model declares in
available_actions()and it is dispatched throughSimulationContext.do(), which gates it on the inventory mode and records it in the history. Supplyapplyinstead 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:
objectAdvance the model by one period.
- name: str = 'growth'#
- run(ctx: SimulationContext, dt: float) None[source]#
Run the context’s model for
dtyears.
- class pyforestry.base.simulation.pipeline.ManagementStep(policy: Callable[[SimulationContext], Sequence[Action]], name: str = 'management')[source]#
Bases:
objectAsk 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:
ProtocolOne 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
ctxthrough this phase of adt-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
actionwhenever the clock reaches one oftimes.This is what a
ScheduledOpwas. The comparison is againstctx.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
policiespropose, 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
pipelineoverctxforyears, onestep-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 addingdtto 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
stepis run at its true length rather than rounded up, so a model that scales linearly withdtdoes 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
stepis not positive oryearsis 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
actionon each step wherepredicateholds.This is what a
TriggerSpecwas, minus thecheck_phasefield (a policy runs where you put it in the pipeline) and minus the exception swallowing. Withonce=Trueit 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.