Skip to content

The event table

Every run produces an event table: one row per SKU and period, recording every unit that entered or left the shelf, the pipeline, or the backorder list. result.to_event_frame() returns it, and every metric and plot is computed from it. Output tables lists its columns; this page gives the identities each row satisfies and shows how to check them.

The four stocks

Each SKU carries four quantities from one period to the next:

flowchart LR
    ORD((order q)) --> PIPE[Pipeline P]
    PIPE -- received r --> OH[On hand OH]
    OH -- fulfilled --> OUT((customers))
    OH -- backorders served --> OUT
    OH -- expired --> WASTE((waste))
    DEM((demand D)) --> OH
    DEM -- shortage, backorder mode --> BO[Backorders B]
    DEM -- shortage, lost-sales mode --> LOST((lost sales))
    BO -- served from receipts --> OUT

and the inventory position combines them: \(\mathit{IP} = \mathit{OH} + P - B\).

The identities

For every SKU and period, the event table satisfies:

Demand is either served or short.

\[ D_t = \text{fulfilled}_t + \text{shortage}_t \]

On-hand stock. What is on the shelf at the end equals what was there at the start, plus receipts, minus everything that left:

\[ \mathit{OH}^{\text{end}}_t = \mathit{OH}^{\text{start}}_t + r_t - \text{backorders served}_t - \text{fulfilled}_t - \text{expired}_t + \text{adjustment}_t \]

Pipeline. Orders go in, receipts come out:

\[ P^{\text{end}}_t = P^{\text{start}}_t + q_t - r_t \]

Backorders. New shortages are added (backorder mode), served backorders are removed:

\[ B^{\text{end}}_t = B^{\text{start}}_t + \text{new backorders}_t - \text{backorders served}_t \]

Inventory position.

\[ \mathit{IP}^{\text{end}}_t = \mathit{OH}^{\text{end}}_t + P^{\text{end}}_t - B^{\text{end}}_t \]

The order trail. The requested, adjusted, constrained, and accepted quantities are linked:

\[ \begin{aligned} \text{callback adjusted} &= \text{requested} + \text{callback adjustment} \\ \text{constrained} &= \text{callback adjusted} + \text{constraint adjustment} \\ q_t &= \text{constrained} \end{aligned} \]

Shortage rules

  • Lost sales (allow_backorders=False): \(\text{shortage} = \text{lost sales}\), and backorders stay at zero.
  • Backorders (allow_backorders=True): \(\text{shortage} = \text{new backorders}\), and lost sales stay at zero.
  • Deliveries serve old backorders first, then today's demand.
  • A SKU never has positive on-hand stock and positive backorders at the same time.

When extensions add flows

Optional building blocks add their own columns, and the identities grow with them:

Building block Extra columns Enters
Inventory processes with general flows process_inflow_units, process_outflow_units on-hand: \(+\,\text{inflow} - \text{outflow}\)
Processes with expiry flows, ShelfLife expired_units (standard column) on-hand: \(-\,\text{expired}\)
on_after_demand callbacks inventory_adjustment_units (standard column) on-hand: \(+\,\text{adjustment}\)
Suppliers with a DeliveryOutcome supplier_shortfall_units pipeline: \(-\,\text{shortfall}\)

For example, with an unreliable supplier the pipeline identity becomes \(P^{\text{end}} = P^{\text{start}} + q - r - \text{shortfall}\): units that will never arrive leave the pipeline without entering the shelf.

Runtime checks

The engine checks the identities for every row as it is written. The tolerance is \(10^{-9}\) plus \(10^{-12}\) times the size of the row's flows, so event tables in grams or millilitres balance just as well as event tables in units.

validate_event_frame applies the same checks to any event table, and more:

  • Rows chain. For each SKU, a period starts with the on-hand, backorders, and pipeline the previous period ended with.
  • Demand uses the shelf first. A row cannot record a shortage while stock was still available for that demand.
  • Stock or backorders, not both. No row ends with both.
  • Types and labels. Flags are booleans, window labels are known, order_arrival_flag is set only on rows that received stock, and target_missing_flag only on decision rows with no order and no target.
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,
)
from stockcast.evaluation import validate_event_frame

events = result.to_event_frame()
validate_event_frame(events)          # returns a validated copy

broken = events.copy()
broken.loc[3, "ending_on_hand"] += 1  # one unit appears from nowhere
try:
    validate_event_frame(broken)
except ValueError as error:
    print(error)
event_frame violates physical inventory balance for SKU tea_250g at period 4

InventoryEvaluator.fit validates its input the same way, so metrics are always computed from balanced books.

Uses

  • Trust. A policy, a callback, or a custom process cannot create or destroy stock by accident. Any bug shows up as a failed identity with the SKU and period.
  • Additivity. Balanced rows can be summed over any grouping (weeks, stores, categories) and the totals still balance.
  • Explanations. When a metric surprises you, the event table tells you which flow caused it.

See also: Walkthrough step 6 · Output tables