From a demand path to inventory updates¶
Generate a small daily demand path, then follow an (R, S) policy as physical stock falls, replenishment orders are placed, and deliveries arrive after lead time.
The plan. We generate 28 days of demand, walk through one ordering decision step by step, and then repeat those same steps for all 28 days and plot the result.
1. Generate a daily demand path¶
DemandGenerator creates a complete demand calendar in the format used by the inventory APIs: one row per SKU and period. The columns are unique_id (the SKU), date, y (units demanded that day) and period (a counter that starts at 0). The fixed seed makes this seasonal teaching scenario reproducible; it is not a forecast or a claim about a real SKU.
The inventory state opens on 2026-01-05. That is the stock we hold at the end of that day, before any of the demand below has happened, so generated demand begins on the next day, 2026-01-06 (period 0). We use 28 days—four seven-day cycles—so several replenishment cycles are visible. base, amplitude, and std respectively set typical demand, the seasonal swing, and small random variation.
# Section 1: tools to generate the demand path and plot it.
import matplotlib.pyplot as plt
import pandas as pd
from stockcast.utils import DemandGenerator
# Create an artificial example SKU.
sku = "tea_250g"
# This is the state date before the first demand period.
opening_date = pd.Timestamp("2026-01-05")
# Stockcast needs an explicit forward frequency for both demand and state updates.
period_frequency = "D"
# Demand period 0 occurs on the day after the opening state.
demand_generator = DemandGenerator(
[sku],
first_date=opening_date + pd.Timedelta(days=1),
freq=period_frequency,
random_seed=7,
)
# Four weekly cycles show several replenishment cycles; the seed keeps values repeatable.
demand = demand_generator.seasonal(
n_periods=28,
base=5.0,
amplitude=1.5,
season_length=7,
std=0.35,
)
demand.head()
| unique_id | y | period | date | |
|---|---|---|---|---|
| 0 | tea_250g | 5.000431 | 0 | 2026-01-06 |
| 1 | tea_250g | 6.277308 | 1 | 2026-01-07 |
| 2 | tea_250g | 6.366444 | 2 | 2026-01-08 |
| 3 | tea_250g | 5.339118 | 3 | 2026-01-09 |
| 4 | tea_250g | 4.190040 | 4 | 2026-01-10 |
DEMAND_COLOR = "#4C78A8"
# Plot the complete generated path before inventory decisions change the state.
fig, ax = plt.subplots(figsize=(10, 3))
ax.plot(
demand["date"],
demand["y"],
color=DEMAND_COLOR,
marker="o",
markersize=3.5,
linewidth=1.8,
label="Simulated demand",
)
ax.set(title="Generated daily demand", xlabel="Date", ylabel="Units")
ax.legend(frameon=False)
ax.grid(axis="y", alpha=0.25)
plt.tight_layout()
plt.show()
2. Start inventory and process the first review cycle¶
InventoryStateDataFrame holds the current state for each SKU. We explicitly supply 40 opening units rather than deriving stock from the generated demand. initialize_from_observed sets backlog and pipeline to zero.
This example reviews stock every four days, and deliveries take two days. process_demand moves one day forward: it receives due stock, then serves that day's demand. allow_backorders=False means unmet demand is lost rather than carried into a later period.
We first consume four observed days, which is one full review cycle. Then we open the next decision epoch: the moment just before a day's demand is seen, when the policy is allowed to place an order. At that moment the forecast origin is the last day of observed data, here 2026-01-09.
The later complete loop takes these steps for every day, using the periodic schedule from its first demand period.
# Opening stock is a declared scenario input, not an estimate from the demand path.
opening_stock = pd.DataFrame({"unique_id": [sku], "observed_on_hand": [40.0]})
opening_stock
| unique_id | observed_on_hand | |
|---|---|---|
| 0 | tea_250g | 40.0 |
# Section 2: state holder for on-hand, pipeline, and backlog.
from stockcast.core import InventoryStateDataFrame
# Orders take two days to arrive; stock is reviewed every four days.
lead_time = 2
review_period = 4
# The maximum lead time sets the length of the state pipeline array.
empty_state = InventoryStateDataFrame(
[sku],
max_lead_time=lead_time,
allow_backorders=False,
)
# Fill it with the declared opening stock; backlog and pipeline start at zero.
opening_state = empty_state.initialize_from_observed(
opening_stock,
on_hand_column="observed_on_hand",
opening_date=opening_date,
)
opening_state
InventoryStateDataFrame(n_skus=1, total_on_hand=40, total_backorders=0, has_stockout=False, has_backorder=False)
# The opening state: 40 units on hand and an empty pipeline (in_transit).
opening_columns = ["unique_id", "on_hand", "backorders", "in_transit", "period", "date"]
opening_state.get_dataframe()[opening_columns]
| unique_id | on_hand | backorders | in_transit | period | date | |
|---|---|---|---|---|---|---|
| 0 | tea_250g | 40.0 | 0.0 | [0.0, 0.0] | 0.0 | 2026-01-05 |
# The demand of the first complete review cycle: one SKU-period row for each day.
first_cycle = demand.loc[
demand["period"] < review_period, ["unique_id", "date", "y", "period"]
]
first_cycle
| unique_id | date | y | period | |
|---|---|---|---|---|
| 0 | tea_250g | 2026-01-06 | 5.000431 | 0 |
| 1 | tea_250g | 2026-01-07 | 6.277308 | 1 |
| 2 | tea_250g | 2026-01-08 | 6.366444 | 2 |
| 3 | tea_250g | 2026-01-09 | 5.339118 | 3 |
state_after_demand = opening_state
for period in range(review_period):
# Each call returns a new state after receiving due stock and serving one day.
daily_demand = first_cycle.loc[
first_cycle["period"] == period, ["unique_id", "date", "y"]
]
state_after_demand = state_after_demand.process_demand(
daily_demand,
freq=period_frequency,
)
state_after_demand = state_after_demand.advance_period(
freq=period_frequency,
is_review_period=True,
)
position_columns = [
"unique_id",
"on_hand",
"backorders",
"total_in_transit",
"inventory_position",
"period",
"date",
]
# The state at the next decision epoch, before that day's demand.
state_after_demand.inventory_position()[position_columns]
| unique_id | on_hand | backorders | total_in_transit | inventory_position | period | date | |
|---|---|---|---|---|---|---|---|
| 0 | tea_250g | 17.016699 | 0.0 | 0.0 | 17.016699 | 5.0 | 2026-01-10 |
3. Give the policy its target¶
OrderUpToPolicy needs a target for its protection horizon: the number of days one order has to cover. An order placed now arrives after the lead time (2 days) and must then last until the next order can arrive, one review period (4 days) later. The horizon is therefore lead time plus review period: 2 + 4 = 6 days.
This notebook does not fit a forecast. Instead, it supplies a 42-unit cumulative target calculated outside Stockcast. It is a number we chose for the demonstration: the total demand we want to be protected against over those six days. The policy reads it as the 95% quantile, meaning that we accept roughly a 1-in-20 chance that six-day demand exceeds 42. Its end date, six days after the forecast origin, is the last day it covers, and Stockcast checks it against the policy's timing.
# One direct cumulative target per SKU for the six-day protection horizon.
protection_target_table = pd.DataFrame(
{
"unique_id": [sku],
"protection_target": [42.0],
"target_end_date": [pd.Timestamp("2026-01-15")],
}
)
protection_target_table
| unique_id | protection_target | target_end_date | |
|---|---|---|---|
| 0 | tea_250g | 42.0 | 2026-01-15 |
# Section 3: order-up-to policy and its protection target.
from stockcast.policies import OrderUpToPolicy
# Info date 2026-01-09: the last observed day, one day before the decision epoch.
forecast_origin = state_after_demand.get_dataframe()["date"].iloc[0] - pd.Timedelta(
days=1
)
# The policy requests enough stock to raise inventory position to the target.
policy = OrderUpToPolicy(
lead_time=lead_time, # 2-day delivery delay; order enters pipeline, not on-hand.
review_period=review_period, # Review every 4 days; only those periods may order.
freq="D", # Daily steps; must match the demand and state frequency.
service_level=0.95, # Declared 95% protection; the target is read as this quantile.
allow_backorders=False, # Lost sales; matches the opening state, unmet demand is lost.
date_column="target_end_date", # Must equal origin + H (2026-01-15); validated.
)
# Fit: read the externally calculated target and check it against the timing above.
policy.fit(
protection_target_table, # One externally calculated cumulative target row for this SKU.
target_column="protection_target", # S value column: 42 units for the 6-day horizon.
forecast_origin=forecast_origin, # Anchors the end-date check.
)
# The fitted target level for each SKU.
policy.get_target_levels()
| unique_id | target_level | |
|---|---|---|
| 0 | tea_250g | 42.0 |
# What Stockcast recorded about the target: its source, probability and timing.
pd.DataFrame([policy.get_target_metadata()])
| representation | target_probability | protection_horizon | target_source | forecast_origin | forecast_frequency | target_end_date | |
|---|---|---|---|---|---|---|---|
| 0 | direct_protection_period_target | 0.95 | 6 | external_direct | 2026-01-09T00:00:00 | D | 2026-01-15T00:00:00 |
4. Ask for an order¶
At the review period, predict reads the current inventory position and returns an OrderDecision for the amount needed to reach the target: the order equals the target minus the inventory position. Here the target is 42 and the position is about 17.0 (40 opening units minus the 22.98 units sold over four days, with nothing in the pipeline), so the order is about 25.0 units. We pass the current state period so the decision records both its order period and the expected delivery period.
The decision is only a request. Calling predict does not change stock or the pipeline.
epoch = state_after_demand.get_dataframe().iloc[0]
# current_period anchors the order and expected-delivery timing in the decision.
decision = policy.predict(state_after_demand, current_period=int(epoch["period"]))
# The decision is a request: the amount needed to reach the target.
decision.get_dataframe()
| unique_id | order_quantity | target_level | inventory_position | reorder_point | order_period | expected_delivery_period | |
|---|---|---|---|---|---|---|---|
| 0 | tea_250g | 24.983301 | 42.0 | 17.016699 | NaN | 5 | 7 |
5. Put the order in the pipeline¶
update_inventory_with_orders validates the decision timing and adds the accepted quantity to the pipeline slot for the configured lead time. The order therefore raises inventory position immediately, but it does not increase on-hand stock until the update at its declared delivery period receives it.
The assertions below make those timing rules executable: expected delivery is order period plus lead time, on-hand is unchanged at placement, and the pipeline contains exactly the requested quantity.
# Section 5: pipeline primitive; placing an order raises position, not on-hand.
from stockcast.utils import update_inventory_with_orders
# Apply the request: this changes pipeline state, not current physical stock.
state_after_order = update_inventory_with_orders(
state_after_demand,
decision,
policy=policy,
)
state_after_order
InventoryStateDataFrame(n_skus=1, total_on_hand=17, total_backorders=0, has_stockout=False, has_backorder=False)
order = decision.get_dataframe().iloc[0]
before = state_after_demand.get_dataframe().iloc[0]
after = state_after_order.get_dataframe().iloc[0]
assert order["expected_delivery_period"] == order["order_period"] + lead_time
assert after["on_hand"] == before["on_hand"]
assert after["in_transit"].sum() == order["order_quantity"]
# On-hand is unchanged; the order sits in the pipeline and raises the position.
after_order_columns = [
"unique_id",
"on_hand",
"latest_order",
"in_transit",
"total_in_transit",
"inventory_position",
"period",
"date",
]
state_after_order.inventory_position()[after_order_columns]
| unique_id | on_hand | latest_order | in_transit | total_in_transit | inventory_position | period | date | |
|---|---|---|---|---|---|---|---|---|
| 0 | tea_250g | 17.016699 | 24.983301 | [0.0, 24.983300799512378] | 24.983301 | 42.0 | 5.0 | 2026-01-10 |
6. Follow the daily updates¶
Now apply the same primitives to every row in the generated demand path. Each iteration advances and receives due stock, consults the schedule, applies any order, then fulfills demand. history_rows is a small caller-owned record so we can plot what changed after each day.
The final figure combines the demand path (top panel) with a direct Matplotlib view of the caller-owned history (bottom panel). In the bottom panel:
- The blue step line is end-of-day on-hand stock: receipts arrive first, and then the same day's demand is served.
- Orange markers show order dates and quantities.
- Green triangles identify receipt days. Their vertical position is the resulting end-of-day stock, not the quantity received.
- The pale orange region spans the two-day lead time from an order to its receipt.
What to notice: an order appears every four days, and stock jumps up two days after each one. For each receipt day, the marker equals previous closing stock plus receipts minus fulfilled demand. The executable balance check below verifies the first such event; it is not plotting receipt quantity as a stock level.
This introduction keeps the visual focused on on-hand stock. Notebook 02 uses SimulationEngine to produce the canonical event table and the full set of package visualizations.
# Start a fresh state so the timeline includes every generated demand period.
empty_state = InventoryStateDataFrame(
[sku],
max_lead_time=lead_time,
allow_backorders=False,
)
timeline_state = empty_state.initialize_from_observed(
opening_stock,
on_hand_column="observed_on_hand",
opening_date=opening_date,
)
timeline_state
InventoryStateDataFrame(n_skus=1, total_on_hand=40, total_backorders=0, has_stockout=False, has_backorder=False)
# Keep a simple reader-facing history; it is not the engine event table.
# Include the opening state so the step chart has a real starting point.
history_rows = [
{
"date": opening_date,
"demand": float("nan"),
"received": 0.0,
"ordered": 0.0,
"on_hand": 40.0,
"pipeline": 0.0,
"inventory_position": 40.0,
}
]
for period in demand["period"]:
# process_demand expects the complete demand frame for one date.
daily_demand = demand.loc[demand["period"] == period, ["unique_id", "date", "y"]]
# Open the epoch and receive due stock without seeing its demand.
timeline_state = timeline_state.advance_period(
freq=period_frequency,
is_review_period=policy.schedule.should_decide(int(period)),
)
epoch = timeline_state.get_dataframe().iloc[0]
# The caller controls review timing in this component-level walk-through.
if epoch["is_review_period"]:
# Calculate a request from the updated inventory position.
daily_decision = policy.predict(
timeline_state,
current_period=int(epoch["period"]),
)
# Schedule that request into the positive-lead-time pipeline.
timeline_state = update_inventory_with_orders(
timeline_state,
daily_decision,
policy=policy,
)
timeline_state = timeline_state.fulfill_demand(daily_demand)
# Capture the completed demand epoch for the tutorial time series.
state_row = timeline_state.inventory_position().iloc[0]
history_rows.append(
{
"date": state_row["date"],
"demand": daily_demand["y"].iloc[0],
"received": state_row["latest_received"],
"ordered": state_row["latest_order"],
"on_hand": state_row["on_hand"],
"pipeline": state_row["total_in_transit"],
"inventory_position": state_row["inventory_position"],
}
)
history = pd.DataFrame(history_rows)
# Round the quantities for display; the date column is left as it is.
history.head().round(dict.fromkeys(history.columns.drop("date"), 2))
| date | demand | received | ordered | on_hand | pipeline | inventory_position | |
|---|---|---|---|---|---|---|---|
| 0 | 2026-01-05 | NaN | 0.0 | 0.0 | 40.00 | 0.0 | 40.00 |
| 1 | 2026-01-06 | 5.00 | 0.0 | 2.0 | 35.00 | 2.0 | 37.00 |
| 2 | 2026-01-07 | 6.28 | 0.0 | 0.0 | 28.72 | 2.0 | 30.72 |
| 3 | 2026-01-08 | 6.37 | 2.0 | 0.0 | 24.36 | 0.0 | 24.36 |
| 4 | 2026-01-09 | 5.34 | 0.0 | 0.0 | 19.02 | 0.0 | 19.02 |
ON_HAND_COLOR = "#2C7FB8"
ORDER_COLOR = "#F58518"
ORDER_TEXT_COLOR = "#B95F00"
RECEIPT_COLOR = "#54A24B"
# Caller-owned history for the tutorial chart; the engine event table arrives in Notebook 02.
plot_history = history.assign(period=range(len(history)))
# A small demand panel preserves context without competing with inventory state.
fig, (demand_ax, inventory_ax) = plt.subplots(
2,
1,
figsize=(11, 6),
sharex=True,
gridspec_kw={"height_ratios": [1, 3], "hspace": 0.08},
)
(demand_line,) = demand_ax.plot(
plot_history["period"],
plot_history["demand"],
color=DEMAND_COLOR,
marker="o",
markersize=3,
linewidth=1.5,
label="Daily demand",
)
demand_ax.set_ylabel("Demand")
demand_ax.grid(axis="y", alpha=0.25)
demand_ax.spines[["top", "right"]].set_visible(False)
# Draw end-of-day on-hand directly; no engine result exists yet in this notebook.
(on_hand_line,) = inventory_ax.plot(
plot_history["period"],
plot_history["on_hand"],
drawstyle="steps-post",
color=ON_HAND_COLOR,
linewidth=2.0,
zorder=3,
label="End-of-day on hand",
)
inventory_ax.fill_between(
plot_history["period"],
0,
plot_history["on_hand"],
step="post",
color=ON_HAND_COLOR,
alpha=0.08,
zorder=0,
)
order_events = plot_history.loc[plot_history["ordered"] > 0]
receipt_events = plot_history.loc[plot_history["received"] > 0]
order_marker_y = plot_history["on_hand"].max() + 4
# Shade each order-to-delivery interval; the last one remains open beyond the horizon.
# Reuse the first span as the legend key so no extra Patch import is needed.
lead_time_key = None
for event in order_events.itertuples(index=False):
span = inventory_ax.axvspan(
event.period,
event.period + lead_time,
color=ORDER_COLOR,
alpha=0.14,
zorder=0,
)
if lead_time_key is None:
span.set_label("Two-day lead time")
lead_time_key = span
order_points = inventory_ax.scatter(
order_events["period"],
[order_marker_y] * len(order_events),
marker="v",
s=58,
color=ORDER_COLOR,
zorder=4,
label="Order placed (Q label is quantity)",
)
for event in order_events.itertuples(index=False):
inventory_ax.text(
event.period,
order_marker_y + 0.8,
f"Q={event.ordered:.0f}",
ha="center",
va="bottom",
color=ORDER_TEXT_COLOR,
fontsize=8,
)
# Receipt markers sit on closing stock because process_demand receives, then serves demand.
receipt_points = inventory_ax.scatter(
receipt_events["period"],
receipt_events["on_hand"],
marker="^",
s=48,
color=RECEIPT_COLOR,
edgecolor="white",
linewidth=0.7,
zorder=5,
label="Receipt day (at end-of-day stock)",
)
# One shared, explicit key for both panels.
fig.legend(
handles=[demand_line, on_hand_line, order_points, receipt_points, lead_time_key],
loc="upper center",
bbox_to_anchor=(0.5, 0.945),
ncol=3,
frameon=False,
fontsize=8.5,
)
fig.suptitle("Demand and replenishment flow", y=0.995)
inventory_ax.set(title="", xlabel="Date", ylabel="Units")
tick_rows = plot_history.iloc[::4]
inventory_ax.set_xticks(tick_rows["period"])
inventory_ax.set_xticklabels(tick_rows["date"].dt.strftime("%b %d"))
inventory_ax.set_ylim(0, order_marker_y + 5)
inventory_ax.spines[["top", "right"]].set_visible(False)
fig.subplots_adjust(top=0.84)
plt.show()
# Verify the first receipt-day balance shown in the explanation above.
first_receipt_index = history.index[history["received"] > 0][0]
first_receipt_close = (
history.loc[first_receipt_index - 1, "on_hand"]
+ history.loc[first_receipt_index, "received"]
- history.loc[first_receipt_index, "demand"]
)
assert abs(history.loc[first_receipt_index, "on_hand"] - first_receipt_close) < 1e-9
assert (history["pipeline"] >= 0).all()
assert (history["on_hand"] >= 0).all()
assert (history["inventory_position"] >= history["on_hand"]).all()