The simulation engine¶
SimulationEngine runs the clock. It validates every input, plays the
system forward one period at a time, applies every change to stock, and
returns a SimulationResult with the complete record of what happened.
run¶
result = SimulationEngine(verbose=0).run(
policy, demand_source, inventory, n_periods=None,
*, freq=None, warmup_periods=0, scoring_periods=None,
settlement_periods=0, order_during_settlement=None,
demand_source_name=None, random_seed=None,
policy_schedule=None, order_constraints=None, callbacks=None,
supply=None, processes=None,
allow_target_reuse=True, missing_targets="raise",
)
| Argument | Meaning |
|---|---|
policy |
A fitted policy. |
demand_source |
A demand table, or a function period -> DataFrame. |
inventory |
The opening InventoryStateDataFrame. |
n_periods |
Number of demand periods to simulate. Read from a demand table; needed for a demand function. |
freq |
Length of one period, a pandas frequency such as "D". Read from the policy's freq; needed for a policy that has none. If given, it must match the policy's. |
warmup_periods, scoring_periods, settlement_periods |
Consecutive windows that add up to n_periods. Warm-up and settlement default to 0; scoring defaults to the rest and must be at least 1. |
order_during_settlement |
Whether the policy may still order in the settlement window. Needed when settlement_periods > 0. |
demand_source_name |
Optional label for the demand, stored in the manifest. |
random_seed |
Optional seed behind the demand, stored in the manifest. |
policy_schedule |
{decision_period: fitted_policy}: fresh targets at later decisions, prepared before the run. To refit a forecast during the run, see Refresh targets as forecasts roll. |
order_constraints |
A list of ordering constraints, applied in order. |
callbacks |
A list of callbacks, applied in order. |
supply |
A SupplyModel: suppliers, lead times, split deliveries. |
processes |
A list of inventory processes, such as ShelfLife. |
allow_target_reuse |
True (default): a policy keeps deciding until policy_schedule replaces it, so one target can serve many decisions. False: every decision needs a target fitted for it (details). |
missing_targets |
"raise" (default): every SKU needs a target at every decision. "no_order": a SKU without a target at a decision gets no order there, flagged in target_missing_flag; a callback or constraint that gives it an order stops the run (details). |
Only the policy, the demand, and the opening state are required. The run length and the period length come from them, and every period is scored unless you set windows. The last block switches on extra building blocks; leave them out and the run uses none of that code.
SimulationEngine().run(policy=policy, demand_source=demand, inventory=inventory)
SimulationEngine().run(policy=policy, demand_source=demand, inventory=inventory,
warmup_periods=14) # score after a burn-in
SimulationEngine().run(policy=policy, demand_source=demand_fn, inventory=inventory,
n_periods=56) # a demand function
Input checks¶
run checks the whole experiment before the first period and stops with an
explanation when something is missing:
- the policy is fitted, and the state's pipeline covers the policy's lead
time and the supply model's longest delivery (a state created without
max_lead_timeis sized to them; an explicit one must be long enough); - the demand table is a complete, dated grid (details);
- the policy's forecast origin and frequency match the simulation calendar, and every scheduled decision has a target for its own window;
- the decision schedule decides at least once in the run, and not only in a settlement window where ordering is off;
- every scheduled callback has at least one row inside the run;
- the policy has a fitted target for every SKU of the inventory (extra SKUs
are fine), unless
missing_targets="no_order"; - every
policy_schedulesnapshot has the same configuration aspolicy, a target for every SKU, and the right forecast origin (whenpolicyis dated, so is every snapshot); - with
allow_target_reuse=False, every decision has a target fitted for it; - window lengths add up to
n_periods.
The engine then copies the state and the policy. Your objects are not modified and can be reused.
Each period¶
Each period follows receive, decide, then meet demand; to match a timing convention from a paper or another tool, see Work with any timing convention. After each period, the engine writes one event table row per SKU and checks its balance identities. The core loop runs on NumPy arrays, so thousands of SKUs over long horizons simulate quickly.
Run windows¶
| Window | Purpose | Orders allowed |
|---|---|---|
| Warm-up | Let the system move away from the opening state | yes |
| Scoring | The periods your metrics describe | yes |
| Settlement | Let late orders arrive and tails play out | only if order_during_settlement=True |
Every window moves stock; only the scoring window counts by default. Each
event table row carries its window in run_window.
Results¶
Setup: the tea shop from the Walkthrough
import numpy as np
import pandas as pd
from stockcast.core import InventoryStateDataFrame, SimulationEngine
from stockcast.policies import OrderUpToPolicy
from stockcast.utils import DemandGenerator
sku = "tea_250g"
opening_date = pd.Timestamp("2026-01-05")
lead_time, review_period = 2, 4
horizon = lead_time + review_period
demand = DemandGenerator(
[sku], first_date=opening_date + pd.Timedelta(days=1), freq="D",
random_seed=3, negative_demand_handling="clip_zero",
).seasonal(n_periods=56, base=6.0, amplitude=2.0, season_length=7, std=2.0)
demand["y"] = demand["y"].round()
inventory = InventoryStateDataFrame.from_observed(
pd.DataFrame({"unique_id": [sku], "date": [opening_date], "on_hand": [30.0]}),
allow_backorders=False, # some pages step this state by hand
)
paths = np.random.default_rng(42).poisson(6.0, size=(10_000, horizon))
def tea_policy(probability):
"""Order-up-to policy whose target is the given quantile of 6-day demand."""
target = pd.DataFrame({
"unique_id": [sku],
"target": [np.quantile(paths.sum(axis=1), probability)],
"date": [opening_date + pd.Timedelta(days=horizon)], # the last day it covers
})
return OrderUpToPolicy(
lead_time=lead_time, review_period=review_period, freq="D",
service_level=probability, allow_backorders=False,
).fit(target, target_column="target")
policy = tea_policy(0.95)
run_settings = dict(random_seed=3)
result = SimulationEngine().run(
policy=policy, demand_source=demand, inventory=inventory, **run_settings,
)
| Member | Returns |
|---|---|
to_event_frame(window=None) |
The event table, optionally one window ("warmup", "scoring", "settlement", "all") |
to_order_frame() |
One row per scheduled delivery: supplier, order and due periods, status |
to_callback_audit_frame() |
One row per accepted callback effect, with reason and source |
to_process_flow_frame() |
One row per inventory-process flow |
summary() |
A dict of headline numbers for the scoring window |
run_manifest |
The record of inputs, settings, and versions |
inventory |
The final state |
history |
State snapshots after each period |
{'fill_rate': 1.0, 'demand_period_service_level': 1.0, 'mean_ending_on_hand_per_sku_period': 18.607, 'stockout_periods': 0.0, 'total_order_units': 333.0, 'order_event_count': 14.0, 'sku_order_line_count': 14.0}
The run manifest¶
Every result carries a manifest, a JSON-friendly dictionary with eight sections:
['run_id', 'created_at_utc', 'demand_source', 'package', 'policy', 'opening_inventory', 'run_settings', 'dependencies']
| Section | Records |
|---|---|
run_id, created_at_utc |
A unique id and timestamp |
demand_source |
Name, fingerprint, row count, seed, and generator settings of the demand |
package, dependencies |
Stockcast version (and source commit when available); Python, NumPy, pandas, and Matplotlib versions |
policy |
Policy class, configuration, target metadata, a fingerprint of the target table, and the fitted levels (fitted_levels: plain values when one value applies to every SKU, otherwise a fingerprint of the per-SKU table) |
opening_inventory |
A fingerprint of the opening state and open orders |
run_settings |
Frequency, windows, schedule, constraints, callbacks, supply, processes, timing convention, and demand_period_offset: the shift removed from a demand table's own period numbers (0 when none) |
Store the manifest next to the event table and any result can be traced back to its inputs.
run_comparison¶
run_comparison runs several fitted policies on the same experiment. Name
each one with a dict:
comparison = SimulationEngine().run_comparison(
policies={"80%": tea_policy(0.80), "95%": tea_policy(0.95)},
demand_source=demand,
inventory=inventory,
**run_settings,
)
comparison.summary()[["fill_rate", "mean_ending_on_hand_per_sku_period"]]
A list works too: policies=[a, b] with labels=["80%", "95%"], or
without labels to use the policies' names.
- The demand is built once and shared by every branch.
- Each branch starts from its own copy of the opening state.
- Constraints, callbacks, suppliers, and processes apply to every branch; callbacks and processes are reset between branches. Random supplier lead times use the same seeded draws per period and SKU. Seeded delivery outcomes follow each branch's own deliveries.
policy_schedulesgives each branch its own refits: one per policy in list order, or{label: schedule}with a dict of policies.allow_target_reuseandmissing_targetsapply to every branch.
comparison[label] is a normal SimulationResult; iterate over comparison
for the labels.
Shelf life¶
FIFO shelf life is an inventory process:
SimulationEngine().run(..., processes=[ShelfLife(shelf_life_days, opening_lots)]).
See Shelf life and inventory processes.
Logging¶
SimulationEngine(verbose=1) prints start, end, and milestones;
verbose=2 prints a line for every period.
See also: Walkthrough step 5 · API: engine · Notebook 02 · Notebook 03: your own loop