Shelf life and inventory processes¶
Stock does not only leave through sales. Food expires, quality checks discard units, customers return goods. Stockcast models these physical flows as inventory processes: small objects that add or remove on-hand stock at defined moments of each period, with every unit recorded in the event table.
Shelf life, the most common case, comes first.
Shelf life with FIFO¶
The ShelfLife process tracks stock in dated lots and sells the oldest
first (FIFO). Pass it to any run with processes=[...].
A lot received on date \(d\) with shelf life \(S\) days can be sold on dates \(d, \dots, d + S - 1\) and expires at the start of \(d + S\), before that day's receipts, decision, and demand:
Shelf life is measured in calendar days, whatever the period length.
Demand and backorders consume the oldest lots first. Expired units appear in
the event table as expired_units and are priced by the waste_cost metric.
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,
)
For this example, suppose each pack stays sellable for 6 days, and the 30 opening packs arrived two days before the run:
from stockcast.core import ShelfLife, SimulationEngine
opening_lots = pd.DataFrame({
"unique_id": [sku],
"received_date": [opening_date - pd.Timedelta(days=2)],
"quantity": [30.0], # must equal opening on-hand stock
})
shelf_life = ShelfLife(shelf_life_days=6, opening_lots=opening_lots)
fresh = SimulationEngine().run(
policy=policy, demand_source=demand, inventory=inventory,
processes=[shelf_life], **run_settings,
)
fresh.to_event_frame()["expired_units"].sum()
The 95% target that served every sale for a long-life product now discards 18 packs over eight weeks.
Opening lots must add up to each SKU's opening on-hand stock.
opening_expiry_handling says what to do with opening lots already expired
at the start: "reject" (default) stops with an error, and "write_off"
removes that stock before the run and records it in the manifest.
After the run, shelf_life.ledger holds the lots still on the shelf, and the
manifest records the shelf-life settings under run_settings["processes"].
Shelf life combines with any other physical flow: list the processes in the
order they should act.
Custom processes¶
A process subclasses InventoryProcess, declares a unique name and the
flows it may report, and overrides the hooks it needs. This one models a
Monday quality check that discards 10% of the stock on the shelf:
import numpy as np
from stockcast.core import Flow, InventoryProcess, ProcessFlows
class MondayInspection(InventoryProcess):
"""Each Monday a quality check discards a share of the stock on hand."""
name = "inspection"
flows = (Flow("discarded", "outflow"),)
def __init__(self, share):
self.share = share
def after_demand(self, context):
if context.date.day_name() != "Monday":
return None
return ProcessFlows({"discarded": np.floor(self.share * context.on_hand)})
def get_config(self):
return {"share": self.share}
inspected = SimulationEngine().run(
policy=policy, demand_source=demand, inventory=inventory,
processes=[ShelfLife(6, opening_lots), MondayInspection(0.10)],
**run_settings,
)
inspected.to_event_frame()[["expired_units", "process_outflow_units"]].sum()
The inspection's discards come from the oldest lots (FIFO), which is why one fewer pack expires.
Flows¶
Flow(name, direction, category="general")-
directionis"inflow"(adds stock) or"outflow"(removes stock).category="expiry"(outflows only) records the flow as expiry, inexpired_units. Every other flow is recorded inprocess_inflow_unitsorprocess_outflow_units. ProcessFlows(quantities, received_dates=None)-
Maps each declared flow name to non-negative per-SKU quantities (a dict or a Series indexed by SKU). The direction comes from the declaration, never from the sign.
received_datesgives inflows a lot date (one date per flow, or per SKU as a dict or Series), whichShelfLifeneeds to know the age of returned goods.
Hooks¶
| Hook | When | Returns |
|---|---|---|
reset(context) |
before each run | – |
before_demand(context) |
the period has opened; before receipts, the decision, and demand | ProcessFlows or None |
on_receipt(context) |
after deliveries are received (context.received) |
– |
after_demand(context) |
demand has been served; before on_after_demand callbacks |
ProcessFlows or None |
check(context) |
after after_demand and after callback adjustments |
raise if an internal mirror disagrees |
Stock removed in before_demand is gone before the policy decides and before
customers arrive. Stock changed in after_demand affects the following
periods. context is a read-only ProcessContext with the phase, period,
date, window, and per-SKU Series for on_hand, backorders, and on_order
(plus received, demand, fulfilled where the phase has them).
Rules the engine enforces¶
- Only declared flows, known SKUs, and finite non-negative quantities are accepted.
- An outflow cannot exceed on-hand stock; an inflow is not accepted while the SKU has backorders.
- Processes change on-hand stock only, never the pipeline, backorders, or demand.
- Processes run in list order, each seeing the stock left by the ones before
it. Put
ShelfLifefirst so expiry happens before otherbefore_demandflows.
Processes that mirror stock¶
A process that keeps its own record of stock, like ShelfLife's lots, is told
about every other process's flows and every callback adjustment through
validate_stock_change(change, context) (raise to reject) and
on_stock_change(change, context) (update the mirror). That is how the
inspection above consumed the oldest lots, and how a callback that adds stock
must say when it was received.
Outputs¶
- Event table: expiry adds into
expired_units; general flows add the pairprocess_inflow_unitsandprocess_outflow_units, and the on-hand identity becomes \(\mathit{OH}^{\text{end}} = \mathit{OH}^{\text{start}} + r - \text{served} - \text{expired} + \text{adjustment} + \text{inflow} - \text{outflow}\). - Flow table:
result.to_process_flow_frame()has one row per non-zero flow, SKU, and period, with the process, flow, direction, category, and phase:
date process flow phase quantity
0 2026-01-09 shelf_life expired before_demand 9.0
1 2026-01-12 inspection discarded after_demand 3.0
2 2026-01-26 shelf_life expired before_demand 3.0
3 2026-01-26 inspection discarded after_demand 1.0
- Manifest:
run_settings["processes"]lists each process's name, class, flows, overridden hooks, andget_config().
Process or callback?
A one-off correction on a known date (a stock count) is simplest as a
ScheduledInventoryAdjustment callback. Use a process for
a recurring physical mechanism.
See also: Notebook 05e: inventory processes · Notebook 07: FIFO shelf life on M5 demand