Buying from more than one supplier: delays, split deliveries and a café chain¶
A small café chain sells three coffees: a house blend, an espresso roast and a decaf. Every morning, before the doors open, the buyer checks the beans on the shelf and on order, and may order more. Two companies can supply the beans:
- a local roaster: delivers the next morning, but charges more per kilogram;
- an importer: cheaper, but ships from far away. Its lead time varies, and large orders arrive in two parts.
So far, every Stockcast example tracked stock on order as one number per future day and SKU (in_transit), with one fixed lead time. That per-SKU view is still the default and still what the accounting uses. This notebook adds the order-level view: individual open orders, each with its supplier, order day, due day and remaining quantity. With it we can simulate several suppliers, random lead times, deliveries that overtake each other, and partial deliveries.
We add one idea at a time:
- the familiar per-SKU run with one supplier;
- the same run, read order by order;
- the same run, written with the order-level API (results are identical);
- one day placed by hand, with two suppliers;
- the importer with a fixed lead time;
- the importer's random lead times and a planning choice;
- dual sourcing, partial deliveries and a custom allocation rule;
- a comparison of all the options.
Demand rates, prices and every operating assumption are declared below. This is a teaching example with artificial data, not evidence about any real café or supplier.
0. Setup: demand, prices and a target helper¶
We simulate nine weeks of daily demand in kilograms. The first week is warm-up (the system settles from its opening stock) and the next eight weeks are scored. Cafés cannot backorder coffee: a customer who cannot get a house blend buys something else, so unmet demand is a lost sale (allow_backorders=False).
import math
import numpy as np
import pandas as pd
# Show every number with two decimals.
pd.options.display.float_format = "{:.2f}".format
origin = pd.Timestamp("2026-04-05") # Sunday evening stock count; demand starts Monday
skus = ["house_blend", "espresso", "decaf"]
rate = {"house_blend": 6.0, "espresso": 4.0, "decaf": 1.0} # mean kg per day
opening_stock = {"house_blend": 14.0, "espresso": 9.0, "decaf": 3.0}
price = {"roaster": 14.0, "importer": 9.5} # purchase price, EUR per kg
holding_cost = 0.04 # EUR per kg on the shelf per day
alpha = 0.95 # declared target probability
warmup, scoring = 7, 56
n_periods = warmup + scoring
rng = np.random.default_rng(2026)
y = np.column_stack([rng.poisson(rate[sku], n_periods) for sku in skus]).astype(float)
demand = pd.DataFrame(
{
"unique_id": np.tile(skus, n_periods),
"period": np.repeat(np.arange(n_periods), len(skus)),
"date": np.repeat(
pd.date_range(origin + pd.Timedelta(days=1), periods=n_periods),
len(skus),
),
"y": y.reshape(-1),
}
)
demand.head(6)
| unique_id | period | date | y | |
|---|---|---|---|---|
| 0 | house_blend | 0 | 2026-04-06 | 7.00 |
| 1 | espresso | 0 | 2026-04-06 | 3.00 |
| 2 | decaf | 0 | 2026-04-06 | 1.00 |
| 3 | house_blend | 1 | 2026-04-07 | 10.00 |
| 4 | espresso | 1 | 2026-04-07 | 5.00 |
| 5 | decaf | 1 | 2026-04-07 | 3.00 |
Each morning the buyer can order (review_period=1). An order-up-to policy raises the inventory position (on hand + on order) to a target S. An order placed today arrives L mornings later and then has to last until the next order arrives, so the target covers the demand of L + R = L + 1 days.
Demand is Poisson with a known rate here, so the total over H days is Poisson with mean rate × H. We use a direct quantile of that cumulative demand; we never add daily quantiles together.
from stockcast.core import SimulationEngine
from stockcast.policies import OrderUpToPolicy
def poisson_cdf(k, mean):
# P(N <= k) for N ~ Poisson(mean), by the recurrence p(j) = p(j-1) * mean / j.
pmf = cdf = math.exp(-mean)
for j in range(1, int(k) + 1):
pmf *= mean / j
cdf += pmf
return cdf
def poisson_quantile(probability, mean):
# Smallest k with P(N <= k) >= probability.
k = 0
while poisson_cdf(k, mean) < probability:
k += 1
return float(k)
def order_up_to(lead_time):
# Daily-review order-up-to policy with an explicit cumulative target over L + 1 days.
horizon = lead_time + 1
targets = pd.DataFrame(
{
"unique_id": skus,
"S": [poisson_quantile(alpha, rate[sku] * horizon) for sku in skus],
"target_end": origin + pd.Timedelta(days=horizon),
}
)
policy = OrderUpToPolicy(
lead_time=lead_time,
review_period=1,
freq="D",
service_level=alpha,
allow_backorders=False,
date_column="target_end",
)
policy.fit(targets, forecast_origin=origin, target_column="S")
return policy
def run(policy, state, **options):
# One engine run on the shared demand path; the first week is warm-up.
return SimulationEngine().run(
policy,
demand,
state,
warmup_periods=warmup,
**options,
)
# Targets S (kg) for the lead times used in this notebook: longer lead time, longer window.
pd.DataFrame(
{
f"L={lead} (H={lead + 1} days)": {
sku: poisson_quantile(alpha, rate[sku] * (lead + 1)) for sku in skus
}
for lead in (1, 5, 6, 8)
}
)
| L=1 (H=2 days) | L=5 (H=6 days) | L=6 (H=7 days) | L=8 (H=9 days) | |
|---|---|---|---|---|
| house_blend | 18.00 | 46.00 | 53.00 | 66.00 |
| espresso | 13.00 | 32.00 | 37.00 | 46.00 |
| decaf | 5.00 | 10.00 | 12.00 | 14.00 |
The roaster delivers the next morning (L = 1). On Sunday evening one roaster delivery is already on its way for each blend. In the per-SKU API that opening pipeline is an in_transit array per SKU: slot 0 arrives before Monday's demand.
max_lead_time is the number of future days the state can hold on order. We give the state room for eight days now, because the importer will need it later; it does not change anything for the roaster.
from stockcast.core import InventoryStateDataFrame
def opening_state(max_lead_time=8):
empty_state = InventoryStateDataFrame(
skus,
max_lead_time=max_lead_time,
allow_backorders=False,
)
return empty_state.initialize_from_observed(
pd.DataFrame({"unique_id": skus, "kg": [opening_stock[sku] for sku in skus]}),
on_hand_column="kg",
opening_date=origin,
)
legacy_state = opening_state()
# Per-SKU opening pipeline: kilograms due on Monday (slot 0) from yesterday's roaster order.
legacy_state.data["in_transit"] = [
np.array([6.0, 0, 0, 0, 0, 0, 0, 0]),
np.array([4.0, 0, 0, 0, 0, 0, 0, 0]),
np.array([1.0, 0, 0, 0, 0, 0, 0, 0]),
]
legacy_state.get_dataframe()[["unique_id", "on_hand", "in_transit"]]
| unique_id | on_hand | in_transit | |
|---|---|---|---|
| 0 | house_blend | 14.00 | [6.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] |
| 1 | espresso | 9.00 | [4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] |
| 2 | decaf | 3.00 | [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] |
roaster_only = run(order_up_to(1), legacy_state)
events = roaster_only.to_event_frame()
events[
[
"date",
"unique_id",
"starting_on_hand",
"received_units",
"demand",
"lost_sales_units",
"order_quantity",
"on_order_end",
]
].head(6)
| date | unique_id | starting_on_hand | received_units | demand | lost_sales_units | order_quantity | on_order_end | |
|---|---|---|---|---|---|---|---|---|
| 0 | 2026-04-06 | house_blend | 14.00 | 6.00 | 7.00 | 0.00 | 0.00 | 0.00 |
| 1 | 2026-04-06 | espresso | 9.00 | 4.00 | 3.00 | 0.00 | 0.00 | 0.00 |
| 2 | 2026-04-06 | decaf | 3.00 | 1.00 | 1.00 | 0.00 | 1.00 | 1.00 |
| 3 | 2026-04-07 | house_blend | 13.00 | 0.00 | 10.00 | 0.00 | 5.00 | 5.00 |
| 4 | 2026-04-07 | espresso | 10.00 | 0.00 | 5.00 | 0.00 | 3.00 | 3.00 |
| 5 | 2026-04-07 | decaf | 3.00 | 1.00 | 3.00 | 0.00 | 1.00 | 1.00 |
This is the event table you already know: one row per SKU and day. It tells us how much was received and how much is still on order, but not which order it was, or who shipped it.
2. The same run, read order by order¶
Every state can now list its open orders. The Sunday pipeline was given only as in_transit numbers, so Stockcast shows each positive slot as an opening order. It does not know the supplier or the order day, so those stay empty: it does not invent them.
legacy_state.open_orders()
| order_id | unique_id | supplier_id | source | order_period | ordered_quantity | remaining_quantity | due_period | final_due_period | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | house_blend | None | opening | NaN | 6.00 | 6.00 | 1 | 1 |
| 1 | 1 | espresso | None | opening | NaN | 4.00 | 4.00 | 1 | 1 |
| 2 | 2 | decaf | None | opening | NaN | 1.00 | 1.00 | 1 | 1 |
Every run also returns an order frame: one row per scheduled delivery, covering the opening pipeline and every order placed during the run. It shows when each order was placed, when it is due, its realized lead time and whether it has arrived.
orders = roaster_only.to_order_frame()
orders[
[
"order_id",
"unique_id",
"supplier_id",
"source",
"order_date",
"due_date",
"lead_time",
"delivery_quantity",
"status",
]
].head(8)
| order_id | unique_id | supplier_id | source | order_date | due_date | lead_time | delivery_quantity | status | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | house_blend | None | opening | NaT | 2026-04-06 | NaN | 6.00 | received |
| 1 | 1 | espresso | None | opening | NaT | 2026-04-06 | NaN | 4.00 | received |
| 2 | 2 | decaf | None | opening | NaT | 2026-04-06 | NaN | 1.00 | received |
| 3 | 3 | decaf | None | placed | 2026-04-06 | 2026-04-07 | 1.00 | 1.00 | received |
| 4 | 4 | house_blend | None | placed | 2026-04-07 | 2026-04-08 | 1.00 | 5.00 | received |
| 5 | 5 | espresso | None | placed | 2026-04-07 | 2026-04-08 | 1.00 | 3.00 | received |
| 6 | 6 | decaf | None | placed | 2026-04-07 | 2026-04-08 | 1.00 | 1.00 | received |
| 7 | 7 | house_blend | None | placed | 2026-04-08 | 2026-04-09 | 1.00 | 10.00 | received |
The order frame and the event table describe the same stock. Received deliveries add up to received_units for each SKU and day, and the deliveries still open at the end add up to the final on_order_end. The helper below checks both, and its asserts pass quietly.
def check_orders_match_events(result):
"""The order frame and the event table must describe the same stock."""
events = result.to_event_frame()
orders = result.to_order_frame()
received = (
orders[orders["status"] == "received"]
.groupby(["unique_id", "due_date"])["delivery_quantity"]
.sum()
)
event_received = events.set_index(["unique_id", "date"])["received_units"]
event_received = event_received[event_received > 0]
assert np.allclose(
received.reindex(event_received.index).to_numpy(),
event_received.to_numpy(),
)
last_day = events[events["date"] == events["date"].max()].set_index("unique_id")
still_open = (
orders[orders["status"] == "open"]
.groupby("unique_id")["delivery_quantity"]
.sum()
)
assert np.allclose(
still_open.reindex(last_day.index).fillna(0.0),
last_day["on_order_end"],
)
print("order frame matches the event table")
check_orders_match_events(roaster_only)
order frame matches the event table
3. The same run, written with the order-level API¶
Now we describe the same situation at order level, in two places:
- Opening pipeline:
with_open_orders(...)declares Sunday's orders with their purchase-order number, supplier and order day. Each row is one delivery;due_periodcounts state periods, and period1is Monday. - Ordering:
supply=SupplyModel([Supplier("roaster", lead_time=1)])says that every accepted order goes to the roaster, which delivers after one day.
This is the per-SKU run written with the order-level API, so the accounting must come out identical.
sunday_orders = pd.DataFrame(
{
"unique_id": skus,
"supplier_id": ["roaster"] * 3,
"order_id": ["PO-0101", "PO-0102", "PO-0103"],
"order_period": [0, 0, 0], # ordered on Sunday (period 0)
"due_period": [1, 1, 1], # arrives Monday morning
"quantity": [6.0, 4.0, 1.0],
}
)
declared_state = opening_state().with_open_orders(sunday_orders)
declared_state.open_orders()
| order_id | unique_id | supplier_id | source | order_period | ordered_quantity | remaining_quantity | due_period | final_due_period | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | house_blend | roaster | opening | 0.00 | 6.00 | 6.00 | 1 | 1 |
| 1 | 1 | espresso | roaster | opening | 0.00 | 4.00 | 4.00 | 1 | 1 |
| 2 | 2 | decaf | roaster | opening | 0.00 | 1.00 | 1.00 | 1 | 1 |
from stockcast.core import Supplier, SupplyModel
roaster_supply = SupplyModel([Supplier("roaster", lead_time=1)])
roaster_new_api = run(order_up_to(1), declared_state, supply=roaster_supply)
# Identical accounting: every event column, the daily history and the final state.
pd.testing.assert_frame_equal(
roaster_new_api.to_event_frame(),
roaster_only.to_event_frame(),
)
pd.testing.assert_frame_equal(
roaster_new_api.history.drop(columns="in_transit"),
roaster_only.history.drop(columns="in_transit"),
)
print("Per-SKU and order-level runs give the same event table and history.")
roaster_new_api.to_order_frame()[
[
"order_id",
"unique_id",
"supplier_id",
"order_date",
"due_date",
"delivery_quantity",
]
].head(5)
Per-SKU and order-level runs give the same event table and history.
| order_id | unique_id | supplier_id | order_date | due_date | delivery_quantity | |
|---|---|---|---|---|---|---|
| 0 | 0 | house_blend | roaster | 2026-04-05 | 2026-04-06 | 6.00 |
| 1 | 1 | espresso | roaster | 2026-04-05 | 2026-04-06 | 4.00 |
| 2 | 2 | decaf | roaster | 2026-04-05 | 2026-04-06 | 1.00 |
| 3 | 3 | decaf | roaster | 2026-04-06 | 2026-04-07 | 1.00 |
| 4 | 4 | house_blend | roaster | 2026-04-07 | 2026-04-08 | 5.00 |
The only difference is the extra information: the order frame now names the supplier of every order. The two APIs are two descriptions of one model, so you can switch between them without changing a result. The manifest records what was declared:
roaster_new_api.run_settings["supply"]
{'suppliers': [{'supplier_id': 'roaster',
'lead_time': {'fixed': 1},
'partial_deliveries': [[0, 1.0]]}],
'allocation': None,
'random_seed': None,
'lead_time_draws': 'per supplier stream, one draw per decision period and SKU'}
4. One morning by hand: two suppliers, two arrival days¶
Before the engine takes over again, here is a single morning in a manual loop, using the order-level primitive place_order_lines. OrderLines has one row per scheduled delivery, so one SKU can be ordered from several suppliers:
- 5 kg of house blend from the roaster, due today (lead time 0, as if a courier dropped it off with the order), so it can serve today's customers;
- 30 kg from the importer, due in six days, delivered in two parts: 20 kg on arrival and 10 kg two days later (
order_linegroups the two parts into one order line).
from stockcast.core import OrderLines
from stockcast.utils import place_order_lines
state = declared_state.advance_period(freq="D", is_review_period=True) # Monday opens
today = int(state.data["period"].iloc[0])
order_lines = OrderLines(
pd.DataFrame(
{
"unique_id": ["house_blend", "house_blend", "house_blend"],
"supplier_id": ["roaster", "importer", "importer"],
"order_quantity": [5.0, 20.0, 10.0],
"order_period": [today, today, today],
"due_period": [today, today + 6, today + 8],
"order_line": ["courier", "IMP-7", "IMP-7"],
}
)
)
state = place_order_lines(state, order_lines)
state.get_dataframe()[
["unique_id", "on_hand", "latest_received", "latest_order", "in_transit"]
]
| unique_id | on_hand | latest_received | latest_order | in_transit | |
|---|---|---|---|---|---|
| 0 | house_blend | 25.00 | 11.00 | 35.00 | [0.0, 0.0, 0.0, 0.0, 0.0, 20.0, 0.0, 10.0] |
| 1 | espresso | 13.00 | 4.00 | 0.00 | [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] |
| 2 | decaf | 4.00 | 1.00 | 0.00 | [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] |
# The same pipeline, order by order: the importer's two parts share one order line.
state.open_orders()
| order_id | unique_id | supplier_id | source | order_period | ordered_quantity | remaining_quantity | due_period | final_due_period | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 4 | house_blend | importer | placed | 1.00 | 30.00 | 30.00 | 7 | 9 |
The per-SKU in_transit view and the open orders always agree: slot i is the total due i + 1 mornings from now. The rest of the day works as before:
monday = demand[demand["period"] == 0]
state = state.fulfill_demand(monday)
state.get_dataframe()[
[
"unique_id",
"latest_incoming_demand",
"latest_fulfilled",
"latest_shortage",
"on_hand",
]
]
| unique_id | latest_incoming_demand | latest_fulfilled | latest_shortage | on_hand | |
|---|---|---|---|---|---|
| 0 | house_blend | 7.00 | 7.00 | 0.00 | 18.00 |
| 1 | espresso | 3.00 | 3.00 | 0.00 | 10.00 |
| 2 | decaf | 1.00 | 1.00 | 0.00 | 3.00 |
5. Switch to the importer, with a fixed lead time¶
The importer charges EUR 9.50 instead of EUR 14 per kg. Suppose first that it always delivers after five days. The policy now plans with L = 5, so its target covers L + 1 = 6 days of demand. The supply model says where the goods actually come from.
The Sunday roaster order is still on its way, so we reuse declared_state as the opening state.
importer_fixed = SupplyModel([Supplier("importer", lead_time=5)])
importer_5 = run(order_up_to(5), declared_state, supply=importer_fixed)
check_orders_match_events(importer_5)
importer_5.to_order_frame().query("source == 'placed'")[
["unique_id", "order_date", "due_date", "lead_time", "delivery_quantity"]
].head(4)
order frame matches the event table
| unique_id | order_date | due_date | lead_time | delivery_quantity | |
|---|---|---|---|---|---|
| 3 | house_blend | 2026-04-06 | 2026-04-11 | 5.00 | 26.00 |
| 4 | espresso | 2026-04-06 | 2026-04-11 | 5.00 | 19.00 |
| 5 | decaf | 2026-04-06 | 2026-04-11 | 5.00 | 6.00 |
| 6 | house_blend | 2026-04-07 | 2026-04-12 | 5.00 | 7.00 |
A longer lead time means more stock on order: to protect six days instead of two, the inventory position has to be higher. We will compare costs at the end.
6. Random lead times: arrivals out of sequence¶
In practice the importer usually delivers after 5 days, sometimes after 6, and occasionally after 8 (customs checks). We describe that as a discrete distribution. Random lead times need an explicit random_seed: the engine draws every lead time once per run, one draw for each order day, SKU and supplier.
With random lead times, a later order can arrive before an earlier one. The per-SKU slots handle this without any change, because each delivery sits in the slot of its own due day.
importer_lead = {5: 0.60, 6: 0.25, 8: 0.15}
importer_random = SupplyModel(
[Supplier("importer", lead_time=importer_lead)],
random_seed=11,
)
importer_random.max_delivery_offset # the state needs max_lead_time >= 8
8
What lead time should the buyer plan with? The policy's lead_time is a planning choice; the supply model decides the real arrivals. We compare three declared choices on the same demand and the same lead-time draws. run_comparison gives every branch the same random draws, so the comparison stays paired.
from stockcast.evaluation import avg_on_hand, fill_rate
import warnings
# Stockcast warns that each target assumes one fixed lead time. That is the
# point of this comparison, and the paragraph below explains it.
warnings.filterwarnings(
"ignore",
message="supplier deliveries arrive",
category=UserWarning,
)
planning = SimulationEngine().run_comparison(
[order_up_to(5), order_up_to(6), order_up_to(8)],
demand,
declared_state,
warmup_periods=warmup,
labels=["plan L=5 (usual)", "plan L=6", "plan L=8 (worst case)"],
supply=importer_random,
)
pd.DataFrame(
{
label: {
"fill_rate": fill_rate(result.to_event_frame(window="scoring")),
"avg_on_hand_kg": avg_on_hand(result.to_event_frame(window="scoring")),
}
for label, result in planning.results.items()
}
).T
| fill_rate | avg_on_hand_kg | |
|---|---|---|
| plan L=5 (usual) | 0.92 | 5.72 |
| plan L=6 | 0.98 | 9.08 |
| plan L=8 (worst case) | 1.00 | 16.44 |
About the warning. Stockcast warns, once per run, when deliveries arrive at a time other than the policy's lead_time: "supplier deliveries arrive at times other than the policy's lead_time (5 periods)". That is exactly what happens here, so the cell above silences it. Each policy's target was computed for one fixed lead time (5, 6 or 8 days), while the importer delivers after 5, 6 or 8 days at random. The target is not adjusted: every run is correct accounting of that planning choice against that supply, which is the comparison we want. What the warning flags is interpretation: a target labelled as a 95% quantile of 6-day demand is no longer a 95% design when orders can take 8 days. See the lead-time assumption section of the guide, and Notebook 05f for suppliers that deliver late or short.
Planning with the usual 5 days leaves the cafés short whenever the importer is late: the fill rate is 0.92, against 0.98 when planning for 6 days and 1.00 when planning for the worst case, 8 days. Protecting service keeps much more coffee on the shelf, since average on-hand stock rises from 5.72 kg to 9.08 kg and then 16.44 kg. The order frame shows the realized lead times, and the orders that arrived before an earlier order:
placed = planning["plan L=6"].to_order_frame().query("source == 'placed'")
house = placed[placed["unique_id"] == "house_blend"].sort_values("order_period")
overtaken = int((house["due_period"].cummax().shift() > house["due_period"]).sum())
print(f"house-blend orders that arrived before an earlier order: {overtaken}")
# Share of orders by realized lead time (days).
placed.groupby("lead_time")["order_id"].nunique().div(
placed["order_id"].nunique()
).to_frame("share_of_orders")
house-blend orders that arrived before an earlier order: 7
| share_of_orders | |
|---|---|
| lead_time | |
| 5.00 | 0.62 |
| 6.00 | 0.27 |
| 8.00 | 0.12 |
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
ON_TIME_COLOR = "#167d9a"
LATE_COLOR = "#c0392b"
fig, ax = plt.subplots(figsize=(10, 3.6), layout="constrained")
first_weeks = house[house["order_date"] < origin + pd.Timedelta(days=29)]
for row in first_weeks.itertuples():
color = LATE_COLOR if row.lead_time > 5 else ON_TIME_COLOR
ax.plot(
[row.order_date, row.due_date], [row.order_id] * 2, color=color, linewidth=2
)
ax.plot(row.due_date, row.order_id, "o", color=color)
ax.xaxis.set_major_locator(mdates.DayLocator(interval=4))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d %b"))
ax.set(
title="House-blend orders from the importer: order day to arrival (red = later than 5 days)",
xlabel="Date",
ylabel="Order id",
)
ax.spines[["top", "right"]].set_visible(False)
plt.show()
Each line runs from an order day to its arrival. Red lines are the orders that took longer than five days. When a red line ends later than a blue line that started after it, a later order overtook an earlier one, which the per-SKU slots handle without any change.
7. Dual sourcing, partial deliveries and a custom rule¶
Now the buyer uses both suppliers. Two changes:
- The importer ships large orders in two parts: 70% on arrival and 30% two days later (
partial_deliveries). SupplierSharessends 30% of every order to the roaster and 70% to the importer. Decaf sells slowly, so it is always bought from the roaster (by_sku).
The longest possible delivery is 8 + 2 = 10 days, so this model needs a state with room for at least ten days on order.
from stockcast.core import SupplierShares
importer_split = Supplier(
"importer",
lead_time=importer_lead,
partial_deliveries=[(0, 0.7), (2, 0.3)],
)
roaster = Supplier("roaster", lead_time=1)
dual = SupplyModel(
[roaster, importer_split],
allocation=SupplierShares(
{"roaster": 0.3, "importer": 0.7},
by_sku={"decaf": {"roaster": 1.0}},
),
random_seed=11,
)
print("longest delivery offset:", dual.max_delivery_offset)
# Same Sunday orders, with room for the longest delivery.
wide_state = opening_state(max_lead_time=10).with_open_orders(sunday_orders)
wide_state.open_orders()
longest delivery offset: 10
| order_id | unique_id | supplier_id | source | order_period | ordered_quantity | remaining_quantity | due_period | final_due_period | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | house_blend | roaster | opening | 0.00 | 6.00 | 6.00 | 1 | 1 |
| 1 | 1 | espresso | roaster | opening | 0.00 | 4.00 | 4.00 | 1 | 1 |
| 2 | 2 | decaf | roaster | opening | 0.00 | 1.00 | 1.00 | 1 | 1 |
With two suppliers, what lead time should the policy plan with? We declare a simple rule: plan with 6 days, the importer's typical arrival plus one day of margin, because most volume comes from the importer. It is a declared assumption to test, not an optimum.
dual_run = run(order_up_to(6), wide_state, supply=dual)
dual_events = dual_run.to_event_frame()
check_orders_match_events(dual_run)
dual_orders = dual_run.to_order_frame().query("source == 'placed'")
dual_orders[
[
"order_id",
"unique_id",
"supplier_id",
"order_date",
"due_date",
"ordered_quantity",
"delivery_quantity",
]
].head(8)
order frame matches the event table
| order_id | unique_id | supplier_id | order_date | due_date | ordered_quantity | delivery_quantity | |
|---|---|---|---|---|---|---|---|
| 3 | 3 | house_blend | roaster | 2026-04-06 | 2026-04-07 | 9.90 | 9.90 |
| 4 | 4 | house_blend | importer | 2026-04-06 | 2026-04-11 | 23.10 | 16.17 |
| 5 | 4 | house_blend | importer | 2026-04-06 | 2026-04-13 | 23.10 | 6.93 |
| 6 | 5 | espresso | roaster | 2026-04-06 | 2026-04-07 | 7.20 | 7.20 |
| 7 | 6 | espresso | importer | 2026-04-06 | 2026-04-14 | 16.80 | 11.76 |
| 8 | 6 | espresso | importer | 2026-04-06 | 2026-04-16 | 16.80 | 5.04 |
| 9 | 7 | decaf | roaster | 2026-04-06 | 2026-04-07 | 8.00 | 8.00 |
| 10 | 8 | house_blend | roaster | 2026-04-07 | 2026-04-08 | 2.10 | 2.10 |
In the event table, order_quantity is still the accepted total for each SKU and day, the quantity the policy asked for. sku_order_line_count now counts supplier lines: an order split between two suppliers counts as two lines. A partial delivery is still one line.
dual_events.loc[
dual_events["order_quantity"] > 0,
["date", "unique_id", "order_quantity", "sku_order_line_count", "received_units"],
].head(6)
| date | unique_id | order_quantity | sku_order_line_count | received_units | |
|---|---|---|---|---|---|
| 0 | 2026-04-06 | house_blend | 33.00 | 2 | 6.00 |
| 1 | 2026-04-06 | espresso | 24.00 | 2 | 4.00 |
| 2 | 2026-04-06 | decaf | 8.00 | 1 | 1.00 |
| 3 | 2026-04-07 | house_blend | 7.00 | 2 | 9.90 |
| 4 | 2026-04-07 | espresso | 3.00 | 2 | 7.20 |
| 5 | 2026-04-07 | decaf | 1.00 | 1 | 8.00 |
A custom allocation rule: expedite when stock is low¶
A fixed split ignores the situation on the day. A custom SupplierAllocation can use the state the policy saw, together with its open orders. Our rule: if a blend's shelf stock is below 1.5 days of mean demand, buy everything from the roaster (it arrives tomorrow); otherwise buy everything from the importer.
The allocation must split each SKU's full accepted quantity. The engine validates the result, and the settings returned by get_config() go into the run manifest.
from stockcast.core import SupplierAllocation
class ExpediteWhenLow(SupplierAllocation):
# Send an order to the fast supplier when on-hand stock is below a cover threshold.
def __init__(self, cover_days):
self.cover_days = cover_days
def allocate(self, orders, context):
on_hand = context.inventory.set_index(context.sku_column)["on_hand"]
low = [
on_hand[sku] < self.cover_days * rate[sku]
for sku in orders[context.sku_column]
]
return pd.DataFrame(
{
context.sku_column: orders[context.sku_column].to_numpy(),
"supplier_id": ["roaster" if is_low else "importer" for is_low in low],
"order_quantity": orders["order_quantity"].to_numpy(),
}
)
def get_config(self):
return {"cover_days": self.cover_days}
expedite = SupplyModel(
[roaster, importer_split],
allocation=ExpediteWhenLow(1.5),
random_seed=11,
)
expedite_run = run(order_up_to(6), wide_state, supply=expedite)
check_orders_match_events(expedite_run)
# Number of orders sent to each supplier.
expedite_run.to_order_frame().query("source == 'placed'").groupby("supplier_id")[
"order_id"
].nunique()
order frame matches the event table
supplier_id importer 153 roaster 16 Name: order_id, dtype: int64
8. Compare the options¶
All runs share the same demand. Runs that use the importer share the same lead-time draws (seed 11). The event table gives service and stock; the order frame gives purchases per supplier. Built-in metrics do not include supplier prices, so we compute purchase cost from the order frame with the declared prices. We count what was ordered during the scored weeks, whether or not it has arrived yet.
scored_days = set(
demand.loc[demand["period"].between(warmup, warmup + scoring - 1), "date"]
)
def summarize(result):
events = result.to_event_frame(window="scoring")
orders = result.to_order_frame()
bought = orders[
(orders["source"] == "placed") & orders["order_date"].isin(scored_days)
]
# Without a supply model, orders have no supplier id: they are the roaster's in step 1.
supplier = bought["supplier_id"].fillna("roaster")
purchase = (bought["delivery_quantity"] * supplier.map(price)).sum()
holding = holding_cost * events["ending_on_hand"].sum()
return {
"fill_rate": fill_rate(events),
"lost_kg": events["lost_sales_units"].sum(),
"avg_on_hand_kg": avg_on_hand(events),
"kg_from_importer": bought.loc[
supplier == "importer", "delivery_quantity"
].sum(),
"purchase_eur": purchase,
"holding_eur": holding,
}
scenarios = {
"1. roaster only (per-SKU API)": roaster_only,
"3. roaster only (order-level)": roaster_new_api,
"5. importer, fixed 5 days": importer_5,
"6. importer, random, plan L=6": planning["plan L=6"],
"7a. dual sourcing, 30/70": dual_run,
"7b. dual, expedite when low": expedite_run,
}
comparison = pd.DataFrame(
{name: summarize(result) for name, result in scenarios.items()}
).T
comparison
| fill_rate | lost_kg | avg_on_hand_kg | kg_from_importer | purchase_eur | holding_eur | |
|---|---|---|---|---|---|---|
| 1. roaster only (per-SKU API) | 0.97 | 23.00 | 4.40 | 0.00 | 8946.00 | 29.56 |
| 3. roaster only (order-level) | 0.97 | 23.00 | 4.40 | 0.00 | 8946.00 | 29.56 |
| 5. importer, fixed 5 days | 0.97 | 17.00 | 6.57 | 645.00 | 6127.50 | 44.16 |
| 6. importer, random, plan L=6 | 0.98 | 15.00 | 9.08 | 647.00 | 6146.50 | 61.00 |
| 7a. dual sourcing, 30/70 | 1.00 | 3.20 | 12.84 | 416.50 | 7323.71 | 86.25 |
| 7b. dual, expedite when low | 0.98 | 13.40 | 8.06 | 597.10 | 6393.45 | 54.14 |
labels = [name.split(". ", 1)[1] for name in comparison.index]
fig, axes = plt.subplots(1, 2, figsize=(11, 3.6), layout="constrained")
axes[0].barh(labels, comparison["fill_rate"], color=ON_TIME_COLOR)
axes[0].set(xlim=(0.85, 1.0), title="Fill rate (scored weeks)")
axes[1].barh(
labels,
comparison["purchase_eur"] + comparison["holding_eur"],
color="#7f8c8d",
)
axes[1].set(title="Purchase + holding cost, EUR (declared prices)")
for ax in axes:
ax.invert_yaxis()
ax.spines[["top", "right"]].set_visible(False)
plt.show()
How to read this, and what it does not show:
- Rows 1 and 3 are the same model written in the two APIs, so their numbers are identical.
- The importer is cheaper per kilogram (EUR 6,127.50 against EUR 8,946.00 in purchases), but it needs a higher inventory position, so average stock rises from 4.40 kg to 6.57 kg even with a fixed lead time.
- Random lead times cost service unless the plan allows for late arrivals: the planning table above gave a fill rate of 0.92 when planning with the usual 5 days and 0.98 with 6. Rows 5 and 6 buy nearly the same importer volume (645 and 647 kg) because an order-up-to policy buys back what was sold, but row 6 holds more stock (9.08 kg against 6.57 kg on average).
- Dual sourcing and the expedite rule trade purchase price against stock and lost sales. Dual sourcing loses the least coffee (3.2 kg) with the most stock (12.84 kg) and a purchase bill of EUR 7,323.71. The expedite rule loses 13.4 kg and holds 8.06 kg for EUR 6,393.45. Which option is best depends on the declared prices and rates, and on the value of a lost sale, which we did not price. Nine weeks of one artificial demand path is an illustration, not a general ranking.
See the guide Open orders and suppliers for the full contract and the current scope. Notebook 05f continues with an unreliable supplier: late and short deliveries found out on the due day (DeliveryOutcome), supplier capacity built from an ordering constraint or an allocation, and a backup supplier chosen by the policy. Multi-echelon networks are not modelled.