Step 6 of 9
The event table¶
Every simulation writes an event table: one row per SKU and period that records what the shop started with, what arrived, what was ordered, what was sold, and what was left. It is the complete, checked record of the run. Metrics, plots, and your own analysis all read from it.
Get the event table¶
We continue with the result from step 5.
Setup: the tea shop from steps 1 to 5
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,
)
56 days × 1 SKU = 56 rows. The columns fall into a few groups:
| Group | Columns |
|---|---|
| When and what | unique_id, date, period, demand_period, run_window |
| Opening of the day | starting_on_hand, starting_on_order, starting_backorders |
| Flows during the day | received_units, demand, fulfilled_units, shortage_units, lost_sales_units, backorder_increment, expired_units, … |
| The decision | decision_flag, decision_inventory_position, order_quantity, target_level |
| Close of the day | ending_on_hand, on_order_end, backorders_end, inventory_position_end |
| How the order was shaped | requested_order_quantity, callback_adjusted_order_quantity, constrained_order_quantity, binding_constraints, … |
The full list, with meanings, is in Output tables.
Reading one day¶
Here is Saturday 10 January, a review day:
day = events.loc[events["date"] == "2026-01-10"].iloc[0]
day[["starting_on_hand", "starting_on_order", "received_units",
"decision_inventory_position", "order_quantity", "demand",
"fulfilled_units", "ending_on_hand", "on_order_end",
"inventory_position_end"]]
starting_on_hand 19.0
starting_on_order 0.0
received_units 0.0
decision_inventory_position 19.0
order_quantity 27.0
demand 4.0
fulfilled_units 4.0
ending_on_hand 15.0
on_order_end 27.0
inventory_position_end 42.0
Name: 4, dtype: object
In the order of events:
- Receive. Nothing was due. The shelf holds 19 packs.
- Decide. The policy saw an inventory position of 19 and ordered \(46 - 19 = 27\).
- Meet demand. 4 packs sold, 15 left. The 27 packs are now on order, so the inventory position closes at \(15 + 27 = 42\).
decision_inventory_position is the position the policy saw before demand.
It is empty on days without a decision.
Balance identities¶
Each row satisfies a small set of accounting identities. For every SKU and period:
The engine checks these on every row as it runs. You can check any event table,
including one you saved or edited, with validate_event_frame:
from stockcast.evaluation import validate_event_frame
checked = validate_event_frame(events) # returns the event table, or raises
len(checked)
Because every row balances, you can add rows up in any grouping (by week, by SKU, by store) and the totals stay consistent.
Shortages in the event table¶
With lost sales, unmet demand appears as shortage_units and
lost_sales_units. With backorders, it appears as backorder_increment and
carries forward in backorders_end until a delivery serves it.
stockout_flag marks every SKU-period with a shortage. Step 8 shows a
scenario with shortages.
Other views of a run¶
| Method | One row per | Use it for |
|---|---|---|
to_event_frame(window=...) |
SKU and period | Metrics and plots. The main record. |
to_order_frame() |
Scheduled delivery | Which order arrived when, and from which supplier |
to_callback_audit_frame() |
Callback effect | Every manual intervention and its reason |
to_process_flow_frame() |
Process flow | Expiry, inspections, returns |
summary() |
– | A quick dictionary of headline numbers |
Summary¶
- The event table has one row per SKU and period, with every flow of units.
- Its rows satisfy stock, pipeline, and backorder balance identities, checked as the engine runs.
- Everything downstream (metrics, plots, your analysis) reads the event table.
See also: The event table in the Guide · Output tables