Unreliable deliveries¶
Suppliers do not always deliver what was ordered, when it was promised. A
truck is late, a shipment arrives short, a line is cancelled. A
DeliveryOutcome attached to a supplier decides, when each delivery falls
due, how much arrives now, how much comes later, and how much never comes.
How it works¶
At the start of each period, before receipts and before the policy decides, the engine collects the deliveries of that supplier that are due now and asks the outcome:
flowchart LR
D["Deliveries due now<br/>(quantity)"] --> R{"DeliveryOutcome.resolve"}
R -->|received_quantity| N[Arrives now]
R -->|delayed_quantity,<br/>delay_periods| L[Stays on order,<br/>due again later]
R -->|the rest| X[Never arrives:<br/>supplier shortfall]
For every due delivery:
The retailer learns about a problem only on the due date: until then the order is on order at its full quantity. A delayed part is resolved again when it next falls due, so it can slip more than once.
With this one shape you can model:
- short deliveries: receive part, cancel the rest;
- late deliveries: delay everything;
- back-ordered supply: receive part now and delay the remainder;
- disruptions: decide from the date, the state, or the SKU.
Custom outcomes¶
Subclass DeliveryOutcome and implement resolve(due, context). due has one
row per due delivery (order_id, the SKU column, supplier_id, source,
order_period, scheduled_due_period, due_period, quantity). Return
None to let everything arrive, or a DataFrame with the same index and the
columns:
| Column | Meaning |
|---|---|
received_quantity (required) |
arrives now |
delayed_quantity (optional, default 0) |
stays on order |
delay_periods (required if something is delayed) |
integer \(\ge 1\) |
context is a DeliveryContext with a copy of the state, the SKU column,
period, date, supplier_id, and rng: a NumPy generator seeded from the
supply model's random_seed, one stream per supplier. The stream is used in
the order deliveries fall due. With the same seed and sequence of calls,
its draws repeat. Policies that place different orders can use those draws
for different deliveries: unlike
random lead times, which are drawn per period and SKU,
outcomes are not paired delivery by delivery across run_comparison branches.
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,
)
import warnings
import numpy as np
from stockcast.core import (
DeliveryOutcome, InventoryStateDataFrame, SimulationEngine, Supplier, SupplyModel,
)
class ShortOrLate(DeliveryOutcome):
"""15% of deliveries arrive 2 periods late; the rest arrive 90% filled."""
def resolve(self, due, context):
late = context.rng.random(len(due)) < 0.15
return due.assign(
received_quantity=np.where(late, 0.0, 0.9 * due["quantity"]),
delayed_quantity=np.where(late, due["quantity"], 0.0),
delay_periods=2,
)
supply = SupplyModel(
[Supplier("wholesaler", lead_time=lead_time, delivery=ShortOrLate())],
random_seed=7,
)
inventory_4 = InventoryStateDataFrame.from_observed(
pd.DataFrame({"unique_id": [sku], "on_hand": [30.0]}), opening_date=opening_date,
max_lead_time=4, # room for a 2-period delay
)
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
result = SimulationEngine().run(
policy=policy, demand_source=demand, inventory=inventory_4,
supply=supply, **run_settings,
)
events = result.to_event_frame()
events[["order_quantity", "received_units", "supplier_shortfall_units"]].sum().round(1)
The shop ordered 366.8 packs. 303.9 arrived, 33.8 never will, and the rest was still on its way when the run ended. Because the policy sees every shortfall at its next review, it ordered more than the 333 packs of the reliable run, and still served 98.8% of demand.
What the event table records¶
Runs with a delivery outcome add one event table column,
supplier_shortfall_units: units that were due in the period and will never
arrive. It closes the pipeline balance:
order_quantity stays what the retailer ordered, so ordering and purchase
costs are charged on the ordered quantity. Delays need no column: a delayed
quantity simply stays on order. The policy sees the lower inventory position
at its next review and can re-order.
The order frame gains four columns, and its status can be "disrupted":
| Column | Meaning |
|---|---|
scheduled_due_period |
the due period set when the order was placed |
received_quantity |
received from this delivery on its due_period |
delayed_quantity |
moved to a later period: a new row with the same order_id, source "delayed", and that quantity as its delivery_quantity (so sum received_quantity, not delivery_quantity, to total what arrived) |
undelivered_quantity |
never arrived |
Every delivery in this run was short or late, so all but the last (still in
transit) are marked "disrupted":
Supplier metrics¶
Supplier-level measures are custom metrics:
from stockcast.evaluation import InventoryEvaluator, fill_rate
def supplier_fill_rate(events, context=None):
received = events["received_units"].sum()
return received / (received + events["supplier_shortfall_units"].sum())
InventoryEvaluator().fit(result, window="scoring").evaluate(
[fill_rate, supplier_fill_rate], groupby=[])
Notes¶
DeliveryOutcome()itself lets everything arrive, soSupplier(id, lead_time, delivery=DeliveryOutcome())gives the same event table as a supplier without an outcome (plus an all-zerosupplier_shortfall_unitscolumn).- A supplier with an outcome needs a lead time of at least one period.
- Delays must fit in the pipeline: a delayed delivery must still be due within
max_lead_timeperiods of the current state. - The engine checks every returned row: finite, non-negative quantities, and
received + delayedno larger than the due quantity. Any other problem stops the run with an error. - Only deliveries of suppliers with an outcome are passed to
resolve, including their declared opening orders. Opening stock given only asin_transithas no supplier and arrives as scheduled. reset()runs before every run;get_config()returns settings for the manifest.
See also: Notebook 05f: unreliable supplier