Skip to content

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:

\[ \text{lot expires on day } \tau \iff \tau - d \ge S . \]

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()
np.float64(18.0)

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()
expired_units            17.0
process_outflow_units    12.0
dtype: float64

The inspection's discards come from the oldest lots (FIFO), which is why one fewer pack expires.

Flows

Flow(name, direction, category="general")

direction is "inflow" (adds stock) or "outflow" (removes stock). category="expiry" (outflows only) records the flow as expiry, in expired_units. Every other flow is recorded in process_inflow_units or process_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_dates gives inflows a lot date (one date per flow, or per SKU as a dict or Series), which ShelfLife needs 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 ShelfLife first so expiry happens before other before_demand flows.

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 pair process_inflow_units and process_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:
inspected.to_process_flow_frame()[["date", "process", "flow", "phase", "quantity"]].head(4)
        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, and get_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