Supplier delivery outcomes: delays, shortfalls and order caps¶
By default a Stockcast supplier delivers every order in full, exactly lead_time periods after it was placed. This notebook shows how to change what arrives, and what each change does to the pipeline (the stock on order), the event table and the policy's decisions:
- a
DeliveryOutcomedecides what arrives when a delivery falls due: all of it, part of it, or none of it yet; - delayed units stay on order; undelivered units leave the pipeline and are recorded as
supplier_shortfall_units; - an
OrderingConstraintcaps an order before it is placed, so the excess is never ordered; - a
SupplierAllocationsplits an order between suppliers, so the excess goes to a second supplier.
The mechanisms are added to the same simulation one at a time, and the last section compares all runs. Notebook 05d introduces SupplyModel, Supplier and the order frame; this notebook builds on it. The data are synthetic: two items with Poisson demand, a main supplier and a faster backup supplier.
import numpy as np
import pandas as pd
# Show every number with two decimals.
pd.options.display.float_format = "{:.2f}".format
1. Setup: demand, target and opening stock¶
Two items, compost and seed_trays, with Poisson demand of 8 and 3 units per day over 56 days, drawn once and replayed in every run. Unserved demand is lost (allow_backorders=False).
origin = pd.Timestamp("2026-03-29") # Sunday evening stock count; demand starts Monday
skus = ["compost", "seed_trays"]
rates = {"compost": 8.0, "seed_trays": 3.0} # mean units sold per day
n_days = 56
rng = np.random.default_rng(2026)
dates = pd.date_range(origin + pd.Timedelta(days=1), periods=n_days, freq="D")
demand = pd.DataFrame(
[
{
"unique_id": sku,
"period": period,
"date": date,
"y": float(rng.poisson(rates[sku])),
}
for sku in skus
for period, date in enumerate(dates)
]
)
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
COMPOST_COLOR = "#8c5a2b"
SEED_TRAYS_COLOR = "#54A24B"
RELIABLE_COLOR = "#9CA3AF"
PROBLEM_COLOR = "#E45756"
WHOLESALER_COLOR = "#4C78A8"
EXPRESS_COLOR = "#F58518"
def style_axis(ax):
ax.set_ylim(0, ax.get_ylim()[1] * 1.05)
ax.grid(axis="y", alpha=0.2)
ax.spines[["top", "right"]].set_visible(False)
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d %b"))
fig, ax = plt.subplots(figsize=(10, 3.2), layout="constrained")
for sku, color in [("compost", COMPOST_COLOR), ("seed_trays", SEED_TRAYS_COLOR)]:
rows = demand.loc[demand["unique_id"].eq(sku)]
ax.plot(
rows["date"],
rows["y"],
marker="o",
markersize=2.5,
linewidth=1.2,
color=color,
label=sku,
)
ax.set(title="Daily demand, replayed in every run", ylabel="Units")
style_axis(ax)
ax.legend(frameon=False, ncol=2, loc="upper right")
plt.show()
The policy is order-up-to with daily review (R = 1) and a lead time L = 2. Its level S must cover the demand of the protection window, L + R = 3 days. The sum of three independent Poisson days is Poisson with mean 3 × rate, so S is the exact 95% quantile of that distribution.
S assumes that every delivery arrives in full after exactly two days. In the sections below the deliveries change but S does not, so each run measures the effect of one delivery mechanism on a policy that does not account for it.
import math
def poisson_quantile(probability, mean):
"""Smallest k with P(N <= k) >= probability, for N ~ Poisson(mean)."""
k, pmf = 0, math.exp(-mean)
cdf = pmf
while cdf < probability:
k += 1
pmf *= mean / k
cdf += pmf
return float(k)
lead_time, review_period = 2, 1
service_level = 0.95
horizon = lead_time + review_period
targets = pd.DataFrame(
{
"unique_id": skus,
"S": [poisson_quantile(service_level, horizon * rates[sku]) for sku in skus],
"target_end": origin + pd.Timedelta(days=horizon),
}
)
targets
| unique_id | S | target_end | |
|---|---|---|---|
| 0 | compost | 32.00 | 2026-04-01 |
| 1 | seed_trays | 14.00 | 2026-04-01 |
S is 32 units for compost and 14 for seed trays. The opening state holds 36 and 14 units on hand and nothing on order, enough to cover demand until the first orders arrive.
max_lead_time=4 sets the length of the pipeline: the number of future periods in which stock can be due. Two periods suffice for on-time deliveries. The delayed deliveries of section 3 arrive up to 2 + 2 = 4 periods after ordering, and a delay beyond the pipeline length is refused.
from stockcast.core import InventoryStateDataFrame
from stockcast.policies import OrderUpToPolicy
policy = OrderUpToPolicy(
lead_time=lead_time,
review_period=review_period,
freq="D",
service_level=service_level,
allow_backorders=False,
date_column="target_end",
)
policy.fit(
targets,
target_column="S",
forecast_origin=origin,
)
opening = InventoryStateDataFrame.from_observed(
pd.DataFrame({"unique_id": skus, "on_hand": [36.0, 14.0]}),
opening_date=origin,
max_lead_time=4, # 2-day lead time + up to 2 days late
)
2. Baseline: one supplier with a fixed lead time¶
A SupplyModel lists the suppliers. Here it holds one Supplier with lead_time=2 and no delivery outcome, so every order arrives in full two periods later. This run is the baseline for every mechanism below.
from stockcast.core import SimulationEngine, Supplier, SupplyModel
reliable = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
supply=SupplyModel([Supplier("wholesaler", lead_time=lead_time)]),
)
Each section compares the end-of-period stock of compost with this baseline, so the comparison figure is written once. The legend reports lost sales over both items.
def lost_units(result):
return result.to_event_frame()["lost_sales_units"].sum()
def compare_compost(result, label, marked_dates, marker_label):
"""Compost end-of-period stock: the baseline run against `result`."""
fig, ax = plt.subplots(figsize=(10, 3.4), layout="constrained")
for run, color, name, width in [
(reliable, RELIABLE_COLOR, "Baseline", 2.5),
(result, PROBLEM_COLOR, label, 1.5),
]:
events = run.to_event_frame()
compost = events.loc[events["unique_id"].eq("compost")]
ax.step(
compost["date"],
compost["ending_on_hand"],
where="mid",
color=color,
linewidth=width,
label=f"{name}: {lost_units(run):.0f} units of lost sales",
)
ax.scatter(
marked_dates,
np.zeros(len(marked_dates)),
marker="v",
s=40,
color="black",
clip_on=False,
zorder=3,
label=marker_label,
)
ax.set(title=f"Compost: end-of-day stock, {label.lower()}", ylabel="Bags")
style_axis(ax)
ax.legend(
frameon=False,
fontsize=8,
loc="upper center",
bbox_to_anchor=(0.5, -0.12),
ncol=3,
)
plt.show()
3. DeliveryOutcome: delayed deliveries¶
A Supplier accepts a delivery= object, a subclass of DeliveryOutcome. In every period in which deliveries of that supplier fall due, the engine calls its resolve(due, context) method before receipts and before the order decision, so the policy learns about a problem only in the period it happens. due holds one row per due delivery, and resolve returns it with, per row:
received_quantity: units that arrive now;delayed_quantityanddelay_periods: units that stay on order and fall due againdelay_periodsperiods later, whenresolveis asked about them again;- the rest,
quantity − received − delayed, never arrives.
context.rng is a random generator seeded from SupplyModel(random_seed=...), so the outcomes are reproducible. The class below implements two random outcomes with one draw per delivery: a delay (probability p_late) and a partial delivery (probability p_short, of which a fraction short_fill arrives). A delivery that was already delayed once arrives in full.
from stockcast.core import DeliveryOutcome
class RandomDeliveryOutcome(DeliveryOutcome):
"""Random delays and partial deliveries, one draw per due delivery.
With probability p_late a delivery slips late_by mornings; with probability
p_short only short_fill of it arrives (whole units) and the rest never comes.
A delivery that already slipped once arrives in full.
"""
def __init__(self, p_late=0.0, late_by=2, p_short=0.0, short_fill=0.6):
self.p_late = p_late
self.late_by = late_by
self.p_short = p_short
self.short_fill = short_fill
def resolve(self, due, context):
draw = context.rng.random(len(due))
first_try = (due["due_period"] == due["scheduled_due_period"]).to_numpy()
late = first_try & (draw < self.p_late)
short = first_try & ~late & (draw < self.p_late + self.p_short)
quantity = due["quantity"].to_numpy()
received = np.where(short, np.floor(self.short_fill * quantity), quantity)
return due.assign(
received_quantity=np.where(late, 0.0, received),
delayed_quantity=np.where(late, quantity, 0.0),
delay_periods=self.late_by,
)
This run uses delays only: 15% of deliveries arrive two periods late. Deliveries now arrive at times other than the policy's lead_time, and the engine raises a UserWarning once per run to say so: the policy's targets were computed for the fixed lead time and are not adjusted. Here that is the point of the experiment, so the warning is filtered with one line. A target that accounts for random delays would be computed over the random protection window and passed to fit as usual.
import warnings
# Expected: the target was computed for on-time deliveries (see above).
warnings.filterwarnings("ignore", message="supplier deliveries arrive at times other")
late = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
supply=SupplyModel(
[
Supplier(
"wholesaler",
lead_time=lead_time,
delivery=RandomDeliveryOutcome(p_late=0.15, late_by=2),
)
],
random_seed=7,
),
)
late_orders = late.to_order_frame()
first_late = late_orders.loc[late_orders["status"].eq("disrupted"), "order_id"].iloc[0]
late_orders.loc[
late_orders["order_id"].eq(first_late),
[
"order_id",
"unique_id",
"source",
"order_date",
"due_date",
"delivery_quantity",
"received_quantity",
"delayed_quantity",
"status",
],
]
| order_id | unique_id | source | order_date | due_date | delivery_quantity | received_quantity | delayed_quantity | status | |
|---|---|---|---|---|---|---|---|---|---|
| 10 | 10 | compost | placed | 2026-04-05 | 2026-04-07 | 8.00 | 0.00 | 8.00 | disrupted |
| 11 | 10 | compost | delayed | 2026-04-05 | 2026-04-09 | 8.00 | 8.00 | 0.00 | received |
to_order_frame() has one row per delivery. The first delayed order appears twice: on its scheduled due date with status disrupted (nothing received, everything in delayed_quantity), and as a follow-up row with source == "delayed", due two periods later, where it is received. Both rows share the order_id, so ordered units are never counted twice.
slipped = late_orders.query("status == 'disrupted' and unique_id == 'compost'")
compare_compost(
late,
"15% of deliveries delayed",
slipped["due_date"],
"A compost delivery was delayed",
)
What to notice: delayed units stay in the pipeline, so they still count in the inventory position. The policy sees no gap and orders nothing extra while the units are late, and the stock on hand runs down. After several delays (black triangles) compost stock reaches zero, for example on 8 and 12 April, and it jumps above the baseline when the delayed units arrive together with the next delivery. Every unit eventually arrives, yet lost sales rise from 18 to 56 units.
4. DeliveryOutcome: partial deliveries¶
Now delays are off, and 25% of deliveries arrive 60% full, rounded down to whole units. The remaining 40% never arrives: these units leave the pipeline without reaching the stock on hand, and the event table records them in a new column, supplier_shortfall_units.
short = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
supply=SupplyModel(
[
Supplier(
"wholesaler",
lead_time=lead_time,
delivery=RandomDeliveryOutcome(p_short=0.25, short_fill=0.6),
)
],
random_seed=7,
),
)
short_events = short.to_event_frame()
short_events.loc[
short_events["supplier_shortfall_units"].gt(0),
[
"date",
"unique_id",
"starting_on_order",
"received_units",
"supplier_shortfall_units",
"order_quantity",
"on_order_end",
],
].head(3)
| date | unique_id | starting_on_order | received_units | supplier_shortfall_units | order_quantity | on_order_end | |
|---|---|---|---|---|---|---|---|
| 16 | 2026-04-07 | compost | 15.00 | 4.00 | 4.00 | 15.00 | 22.00 |
| 20 | 2026-04-09 | compost | 20.00 | 9.00 | 6.00 | 18.00 | 23.00 |
| 21 | 2026-04-09 | seed_trays | 6.00 | 1.00 | 2.00 | 7.00 | 10.00 |
The pipeline balance of every row includes the shortfall:
on_order_end = starting_on_order − received_units − supplier_shortfall_units + order_quantity
The engine checks it in every period, and the next cell checks it once more by hand. Because the shortfall leaves the pipeline before the order decision, the policy sees the lower inventory position in the same period and orders the missing units at once.
on_order_end = (
short_events["starting_on_order"]
- short_events["received_units"]
- short_events["supplier_shortfall_units"]
+ short_events["order_quantity"]
)
assert np.allclose(on_order_end, short_events["on_order_end"])
shortfall_days = short_events.query(
"supplier_shortfall_units > 0 and unique_id == 'compost'"
)
compare_compost(
short,
"25% of deliveries partial",
shortfall_days["date"],
"A compost delivery arrived partial",
)
What to notice: more deliveries are affected than in section 3, yet fewer sales are lost (33 units against 56). The difference is the pipeline: undelivered units leave it at once and are reordered, while delayed units stay in it and are not. The reordered units still arrive two periods later than planned, so several partial deliveries in a row, as in mid-April, still empty the stock.
5. OrderingConstraint: capping the order¶
From here on the supplier has both outcomes, 15% delayed and 25% partial. In addition, it accepts at most 14 units per order, both items together.
There are two ways to handle the excess, and they use different mechanisms. The first is an OrderingConstraint: a rule applied to the policy's order before it is placed (Notebook 05c introduces constraints). The excess is simply not ordered. apply(order, context) returns a ConstraintResult with the new order and one row per item that describes the change: the requested and final quantities, the adjustment, and whether and which constraint was binding. The engine copies these columns into the event table. Here an order above the cap is scaled down proportionally and rounded down to whole units.
from stockcast.core import (
ConstraintResult,
OrderDecision,
OrderingConstraint,
OrderingConstraints,
)
class OrderCap(OrderingConstraint):
"""At most `limit` units per order, all items together."""
name = "order_cap"
def __init__(self, limit):
self.limit = float(limit)
def apply(self, order, context):
frame = order.get_dataframe()
requested = frame["order_quantity"].to_numpy(dtype=float)
total = requested.sum()
if total > self.limit:
final = np.floor(requested * self.limit / total)
else:
final = requested
frame["order_quantity"] = final
cut = final < requested
changes = pd.DataFrame(
{
"unique_id": frame[order.sku_column],
"requested_order_quantity": requested,
"constrained_order_quantity": final,
"constraint_adjustment_units": final - requested,
"constraint_binding_flag": cut,
"capacity_violation_flag": cut,
"binding_constraints": np.where(cut, self.name, ""),
}
)
return ConstraintResult(
OrderDecision(frame, lead_time=order.lead_time), changes
)
Two runs with the same delivery outcomes, without and with the constraint.
late_and_short = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
supply=SupplyModel(
[
Supplier(
"wholesaler",
lead_time=lead_time,
delivery=RandomDeliveryOutcome(p_late=0.15, p_short=0.25),
)
],
random_seed=7,
),
)
limited = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
supply=SupplyModel(
[
Supplier(
"wholesaler",
lead_time=lead_time,
delivery=RandomDeliveryOutcome(p_late=0.15, p_short=0.25),
)
],
random_seed=7,
),
order_constraints=OrderingConstraints([OrderCap(14)]),
)
limited_orders = (
limited.to_event_frame()
.groupby("date")[["requested_order_quantity", "order_quantity"]]
.sum()
)
fig, ax = plt.subplots(figsize=(10, 3.4), layout="constrained")
ax.bar(
limited_orders.index,
limited_orders["requested_order_quantity"],
width=0.8,
color=PROBLEM_COLOR,
alpha=0.35,
label="Requested by the policy",
)
ax.bar(
limited_orders.index,
limited_orders["order_quantity"],
width=0.8,
color=WHOLESALER_COLOR,
label="Placed after the constraint",
)
ax.axhline(14, color="black", linestyle="--", linewidth=1, label="Cap: 14 units")
ax.set(title="Requested and placed orders, both items together", ylabel="Units")
style_axis(ax)
ax.legend(frameon=False, fontsize=8, ncol=3, loc="upper left")
plt.show()
What to notice: the pale bars are the orders the policy requested (requested_order_quantity), the blue bars the orders placed (order_quantity). Wherever they differ, the constraint was binding. Rounding each item down makes a capped order 13 units rather than 14. The cut units are never ordered, so at the next decision the inventory position is still below S and the policy requests them again; this is why the constraint binds on several consecutive days in early April. Section 7 compares the lost sales.
6. SupplierAllocation: sending the excess to a second supplier¶
The second way to handle the cap keeps the full order and splits it between suppliers. That split is the job of a SupplierAllocation. allocate(orders, context) receives the accepted order, one row per item, and returns one row per item and supplier with supplier_id and order_quantity; each item's rows must add up to its order. An allocation never changes the stock, it only decides which supplier delivers each part.
The allocation below gives the main supplier at most 14 units, shared proportionally across items and rounded down, and the backup supplier the rest. The backup, express, has lead_time=1 and no delivery outcome. No ordering constraint is used, so the full order is placed.
from stockcast.core import SupplierAllocation
class CapThenBackup(SupplierAllocation):
"""The main supplier ships at most `limit` units; the backup takes the rest."""
def __init__(self, main, backup, limit):
self.main = main
self.backup = backup
self.limit = float(limit)
def allocate(self, orders, context):
quantity = orders["order_quantity"].to_numpy(dtype=float)
total = quantity.sum()
if total > self.limit:
main_part = np.floor(quantity * self.limit / total)
else:
main_part = quantity
main_rows = pd.DataFrame(
{
context.sku_column: orders[context.sku_column],
"supplier_id": self.main,
"order_quantity": main_part,
}
)
backup_rows = main_rows.assign(
supplier_id=self.backup,
order_quantity=quantity - main_part,
)
rows = pd.concat([main_rows, backup_rows], ignore_index=True)
return rows[rows["order_quantity"] > 0]
with_backup = SimulationEngine().run(
policy=policy,
demand_source=demand,
inventory=opening,
supply=SupplyModel(
[
Supplier(
"wholesaler",
lead_time=lead_time,
delivery=RandomDeliveryOutcome(p_late=0.15, p_short=0.25),
),
Supplier("express", lead_time=1),
],
allocation=CapThenBackup("wholesaler", "express", limit=14),
random_seed=7,
),
)
The event table keeps one order_quantity per item and period, the total accepted order. The order frame has one row per supplier line, so it shows the split.
backup_orders = with_backup.to_order_frame().query("source == 'placed'")
split = backup_orders.pivot_table(
index="order_date",
columns="supplier_id",
values="delivery_quantity",
aggfunc="sum",
fill_value=0.0,
)
fig, ax = plt.subplots(figsize=(10, 3.4), layout="constrained")
ax.bar(
split.index,
split["wholesaler"],
width=0.8,
color=WHOLESALER_COLOR,
label="Main supplier, wholesaler (L = 2)",
)
ax.bar(
split.index,
split["express"],
bottom=split["wholesaler"],
width=0.8,
color=EXPRESS_COLOR,
label="Backup supplier, express (L = 1)",
)
ax.axhline(
14, color="black", linestyle="--", linewidth=1, label="Cap on the main supplier"
)
ax.set(title="Daily order split by the allocation", ylabel="Units")
style_axis(ax)
ax.legend(frameon=False, fontsize=8, ncol=3, loc="upper left")
plt.show()
What to notice: the main supplier's part (blue) never exceeds the cap, and the backup receives the rest (orange). The backup's lead time of one period is also shorter than the policy's L = 2, so those units arrive earlier than the target assumes; the engine reports this mismatch with the same warning as for delays.
7. Comparing the runs¶
All runs are scored with InventoryEvaluator from stockcast.evaluation:
- fill rate: fulfilled demand as a share of demand;
- lost sales: units of demand not served;
- average stock: mean units on hand per item and period;
- supplier fill rate: the share of due units that arrived,
received / (received + shortfall). There is no built-in metric for it, but the evaluator accepts any function of the event table, so it takes a few lines.
Purchase cost is computed from the order frame under one stated pricing rule: each received unit is paid at its supplier's price (4.00 for the main supplier, 5.50 for the backup), and undelivered units are not paid.
from stockcast.evaluation import (
InventoryEvaluator,
avg_on_hand,
fill_rate,
lost_sales_units,
)
prices = {"wholesaler": 4.0, "express": 5.5}
def supplier_fill_rate(events, context=None):
"""Share of the units that fell due and actually arrived."""
received = events["received_units"].sum()
missing = events.get("supplier_shortfall_units", pd.Series(0.0)).sum()
return float(received / (received + missing))
def purchase_cost(result):
"""Each supplier's price for every unit that arrived."""
orders = result.to_order_frame()
arrived = orders[orders["status"] != "open"]
# Without a DeliveryOutcome every delivery arrives in full.
received = arrived.get("received_quantity", arrived["delivery_quantity"])
return float((received * arrived["supplier_id"].map(prices)).sum())
runs = {
"Baseline": reliable,
"Delayed": late,
"Partial": short,
"Delayed + partial": late_and_short,
"Delayed + partial, capped": limited,
"Delayed + partial, backup": with_backup,
}
score_rows = []
for name, result in runs.items():
evaluator = InventoryEvaluator()
evaluator.fit(result)
scores = evaluator.evaluate(
[fill_rate, lost_sales_units, avg_on_hand, supplier_fill_rate],
groupby=[],
)
score_rows.append(scores.assign(run=name, purchase_cost=purchase_cost(result)))
comparison = pd.concat(score_rows).set_index("run")
comparison
| fill_rate | lost_sales_units | avg_on_hand | supplier_fill_rate | purchase_cost | |
|---|---|---|---|---|---|
| run | |||||
| Baseline | 0.97 | 18.00 | 6.30 | 1.00 | 2404.00 |
| Delayed | 0.91 | 56.00 | 6.00 | 1.00 | 2252.00 |
| Partial | 0.95 | 33.00 | 5.33 | 0.88 | 2344.00 |
| Delayed + partial | 0.91 | 61.00 | 5.35 | 0.91 | 2208.00 |
| Delayed + partial, capped | 0.90 | 66.00 | 5.04 | 0.90 | 2172.00 |
| Delayed + partial, backup | 0.91 | 57.00 | 5.68 | 0.91 | 2282.00 |
RUN_COLORS = ["#9CA3AF", "#F58518", "#B279A2", "#E45756", "#8c5a2b", "#4C78A8"]
fig, ax = plt.subplots(figsize=(10, 3.8), layout="constrained")
for (name, result), color in zip(runs.items(), RUN_COLORS):
lost = result.to_event_frame().groupby("date")["lost_sales_units"].sum().cumsum()
ax.step(lost.index, lost.to_numpy(), where="post", color=color, label=name)
ax.set(
title="Cumulative lost sales, both items together",
ylabel="Units",
)
style_axis(ax)
ax.legend(frameon=False, fontsize=8, loc="upper left")
plt.show()
What to notice:
- Every delivery mechanism costs sales, because
Swas computed for full, on-time deliveries. The baseline loses 18 units. - Delays alone lose 56 units with a supplier fill rate of 1.00: no unit is lost, but delayed units stay in the inventory position and are not reordered.
- Partial deliveries lose 33 units with a supplier fill rate of 0.88: the shortfall leaves the pipeline and is reordered at once.
- Both outcomes together lose 61 units, and capping the order with the constraint raises this to 66.
- Allocating the excess to the backup supplier lowers lost sales to 57, below even the uncapped run: the excess is still ordered, and it arrives one period sooner. Purchase cost rises from 2,172 to 2,282, because backup units cost more and more demand is served.
These results come from one demand path and one seeded set of supplier outcomes, so they illustrate the mechanisms rather than rank them in general. A natural next step is a target that accounts for random delays, computed outside Stockcast and passed to fit.