Skip to content

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,
)
events = result.to_event_frame()
events.shape
(56, 43)

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:

  1. Receive. Nothing was due. The shelf holds 19 packs.
  2. Decide. The policy saw an inventory position of 19 and ordered \(46 - 19 = 27\).
  3. 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:

\[ \begin{aligned} \text{demand} &= \text{fulfilled} + \text{shortage} \\[2pt] \text{on hand}_{\text{end}} &= \text{on hand}_{\text{start}} + \text{received} - \text{backorders served} - \text{fulfilled} - \text{expired} + \text{adjustments} \\[2pt] \text{on order}_{\text{end}} &= \text{on order}_{\text{start}} + \text{ordered} - \text{received} \\[2pt] \text{backorders}_{\text{end}} &= \text{backorders}_{\text{start}} + \text{new backorders} - \text{backorders served} \\[2pt] \mathit{IP}_{\text{end}} &= \text{on hand}_{\text{end}} + \text{on order}_{\text{end}} - \text{backorders}_{\text{end}} \end{aligned} \]

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)
56

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

Next: Evaluate a run