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.
On-hand stock. What is on the shelf at the end equals what was there at the start, plus receipts, minus everything that left:
Pipeline. Orders go in, receipts come out:
Backorders. New shortages are added (backorder mode), served backorders are removed:
Inventory position.
The order trail. The requested, adjusted, constrained, and accepted quantities are linked:
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_flagis set only on rows that received stock, andtarget_missing_flagonly 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)
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