From inventory flow to a validated simulation¶
Reuse the small daily tea scenario from Notebook 01, then let SimulationEngine own the complete run: demand validation, copied state, timing, a day-by-day record of what happened, and the settings needed to reproduce the run.
Why simulate: backtest a policy on one fixed demand path and read what it would have served, ordered, and held from the event table, the engine's day-by-day record of the run. This notebook runs one policy; Notebook 06 compares two on identical demand; Notebook 10 takes the same calls into a daily close.
1. Build one complete, explicit scenario¶
The demand path is seeded and mechanical. Opening stock, lead time, review period, shortage mode, windows, and the demand-source name are all declared rather than inferred. This is a teaching scenario: Stockcast accounts for its inventory consequences; it does not claim that these assumptions describe a real tea operation.
The policy makes its first decision before the first demand row. The 28 demand days are then split into two consecutive run windows (4 + 24 = 28):
- Warm-up (4 days): the simulation runs but is not scored, so scoring does not start from the artificial opening stock.
- Scoring (24 days): the days that supply the summary and the evaluation metrics.
# 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
# Shared coordinates and policy timing for this one-SKU daily scenario.
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"
# Orders take two days to arrive; stock is reviewed every four days.
lead_time = 2
review_period = 4
# 28 demand days split into two run windows: 4 + 24.
n_periods = 28
warmup_periods = 4
scoring_periods = n_periods - warmup_periods
# Fixed seed keeps the synthetic teaching path reproducible.
random_seed = 7
Generate and inspect the demand calendar¶
This cell makes the same 28-day seeded path used in Notebook 01. The first demand date is one day after the declared opening state.
# Create the same 28-day seeded path used in Notebook 01.
# 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=random_seed,
)
demand = demand_generator.seasonal(
n_periods=n_periods,
base=5.0,
amplitude=1.5,
season_length=7,
std=0.35,
)
demand.head(7)
| 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 |
| 5 | tea_250g | 3.190532 | 5 | 2026-01-11 |
| 6 | tea_250g | 3.848303 | 6 | 2026-01-12 |
See the demand before inventory decisions¶
This is the only input the simulator will consume as demand. The line makes the four seven-day demand cycles visible before orders or receipts affect inventory.
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="Seeded daily demand before inventory decisions",
xlabel="Date",
ylabel="Demand (units)",
)
ax.legend(frameon=False)
ax.grid(axis="y", alpha=0.25)
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
plt.show()
Declare the opening state and run windows¶
Opening on-hand stock is an observed scenario input, not an estimate from demand. The settings table makes the engine timing visible before the run.
# 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 1 continued: state holder for on-hand, pipeline, and backlog.
from stockcast.core import InventoryStateDataFrame
# 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.
inventory = empty_state.initialize_from_observed(
opening_stock,
on_hand_column="observed_on_hand",
opening_date=opening_date,
)
inventory
InventoryStateDataFrame(n_skus=1, total_on_hand=40, total_backorders=0, has_stockout=False, has_backorder=False)
# Four warm-up days, then twenty-four scoring days.
scenario = pd.DataFrame(
[
{
"SKU": sku,
"opening_on_hand": 40.0,
"lead_time_days": lead_time,
"review_period_days": review_period,
"warmup_days": warmup_periods,
"scoring_days": scoring_periods,
"demand_source_name": "seeded_tea_tutorial",
"random_seed": random_seed,
}
]
)
scenario
| SKU | opening_on_hand | lead_time_days | review_period_days | warmup_days | scoring_days | demand_source_name | random_seed | |
|---|---|---|---|---|---|---|---|---|
| 0 | tea_250g | 40.0 | 2 | 4 | 4 | 24 | seeded_tea_tutorial | 7 |
# The explicit opening state: 40 units on hand and an empty pipeline.
state_columns = ["unique_id", "on_hand", "backorders", "in_transit", "period", "date"]
inventory.get_dataframe()[state_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 |
2. Give the policy a dated protection target¶
The (R, S) policy still needs an externally calculated cumulative target for its six-day protection horizon. The 42-unit value is the same declared target used in Notebook 01. In Notebook 01 the forecast origin was 2026-01-09, the day of its first order. Here the first decision happens before demand period 0, so the forecast origin is the opening date, 2026-01-05, and the end date is six days later. Stockcast checks this timing; it does not fit or invent the target.
# One direct cumulative target per SKU for the six-day protection horizon.
target = pd.DataFrame(
{
"unique_id": [sku],
"protection_target": [42.0],
"target_end_date": [
opening_date + pd.Timedelta(days=lead_time + review_period)
],
}
)
target
| unique_id | protection_target | target_end_date | |
|---|---|---|---|
| 0 | tea_250g | 42.0 | 2026-01-11 |
Fit the policy from that target¶
The policy reads the target as the quantile its service_level asks for, over its six-day protection horizon, and checks that the end date is exactly six days after the forecast origin.
# Section 2: order-up-to policy and its protection target.
from stockcast.policies import OrderUpToPolicy
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=period_frequency, # Daily steps; must match demand and state frequency.
service_level=0.95, # 95% protection; the target is read as this quantile.
allow_backorders=False, # Lost sales; matches the opening state.
date_column="target_end_date", # Must equal origin + H (2026-01-11); validated.
)
# Fit: read the externally calculated target and check it against the timing above.
policy.fit(
target, # One externally calculated cumulative target row for this SKU.
target_column="protection_target", # S value column: 42 units for H = L + R = 6 days.
forecast_origin=opening_date, # Opening date; the end date must be six days later.
)
assert policy.get_target_levels().loc[0, "target_level"] == 42.0
# 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-05T00:00:00 | D | 2026-01-11T00:00:00 |
3. Run the engine¶
This single call replaces the daily loop we wrote by hand in Notebook 01 (advance the day, receive due stock, decide, order, serve demand). The default periodic schedule first decides before demand period 0; the policy's schedule controls when it may order. Every demand row advances state. The engine works on copies of the policy and the state, so your own objects stay unchanged.
# Section 3: engine owns timing, copied state, events, and manifest.
from stockcast.core import SimulationEngine
# The run length (28 rows) and the day length come from the demand and the policy.
engine = SimulationEngine()
result = engine.run(
policy=policy, # Fitted (R,S) policy; deep-copied, so your object is unchanged.
demand_source=demand, # Complete 28-row demand calendar, used in every window.
inventory=inventory, # Explicit opening state at 2026-01-05; also deep-copied.
warmup_periods=warmup_periods, # Runs but is not scored.
demand_source_name="seeded_tea_tutorial", # Optional provenance label (manifest).
random_seed=random_seed, # Optional: the seed behind the synthetic demand.
)
Read the resulting state and scoring summary¶
These outputs come from the engine-owned run. summary() deliberately uses the scoring days only.
# The engine owns this state; the caller-owned `inventory` above is unchanged.
result.inventory
InventoryStateDataFrame(n_skus=1, total_on_hand=26, total_backorders=0, has_stockout=False, has_backorder=False)
final_columns = [
"unique_id",
"on_hand",
"backorders",
"total_in_transit",
"inventory_position",
"period",
"date",
]
# The inventory state after the last demand period.
result.inventory.inventory_position()[final_columns]
| unique_id | on_hand | backorders | total_in_transit | inventory_position | period | date | |
|---|---|---|---|---|---|---|---|
| 0 | tea_250g | 25.71511 | 0.0 | 0.0 | 25.71511 | 28.0 | 2026-02-02 |
# The scoring-window summary of the run.
pd.DataFrame([result.summary()]).round(3)
| fill_rate | demand_period_service_level | mean_ending_on_hand_per_sku_period | stockout_periods | total_order_units | order_event_count | sku_order_line_count | |
|---|---|---|---|---|---|---|---|
| 0 | 1.0 | 1.0 | 20.364 | 0 | 119.345 | 6 | 6 |
See which days are warm-up and scoring¶
The engine consumes demand in both windows. The coloured bands show why only the second segment supplies the summary and evaluation.
window_colors = {"warmup": "#B8CCE4", "scoring": "#B7E1CD"}
# One row per demand day, tagged with the run window it belongs to.
period_rows = result.to_event_frame(window="all").query("event_type == 'period'")
half_day = pd.Timedelta(hours=12)
fig, ax = plt.subplots(figsize=(10, 3.2))
ax.plot(
demand["date"],
demand["y"],
color=DEMAND_COLOR,
marker="o",
markersize=3,
linewidth=1.5,
zorder=2,
)
for window, rows in period_rows.groupby("run_window", sort=False):
ax.axvspan(
rows["date"].min() - half_day,
rows["date"].max() + half_day,
color=window_colors[window],
alpha=0.55,
label=window.title(),
zorder=0,
)
ax.set(
title="Demand advances through every engine window",
xlabel="Demand date",
ylabel="Demand (units)",
)
ax.margins(y=0.15) # headroom for the legend
ax.legend(frameon=False, ncol=2, loc="upper center")
ax.grid(axis="y", alpha=0.2)
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
plt.show()
4. Read the event table¶
The event table is the engine's day-by-day record of the run: one row per SKU and day, with that day's demand, the units received, the units served from stock, any shortage, any order placed, and the stock left at the end of the day. It is called an event table because every row has to balance, like a bookkeeping entry: stock at the start, plus what arrived, minus what was sold, equals what is left. You get it from result.to_event_frame(). The scoring rows below are the ones summary() uses; the full event table also includes the warm-up days.
quantity_columns = [
"demand",
"received_units",
"fulfilled_units",
"shortage_units",
"order_quantity",
"ending_on_hand",
"on_order_end",
"inventory_position_end",
]
event_columns = ["event_type", "run_window", "period", "date"] + quantity_columns
# Round the quantities for display; text and date columns are left as they are.
display_decimals = dict.fromkeys(quantity_columns, 2)
scoring_events = result.to_event_frame(window="scoring")
all_events = result.to_event_frame(window="all")
period_events = all_events.loc[all_events["event_type"] == "period"].copy()
print("Event table size by view")
print(f"scoring period rows: {len(scoring_events)}")
print(f"all period rows: {len(all_events)}")
Event table size by view scoring period rows: 24 all period rows: 28
Inspect the scoring rows¶
These are the 24 rows that contribute to the default summary and the evaluation below.
assert set(scoring_events["run_window"]) == {"scoring"}
assert len(scoring_events) == scoring_periods
# The first eight scoring days.
scoring_events[event_columns].head(8).round(display_decimals)
| event_type | run_window | period | date | demand | received_units | fulfilled_units | shortage_units | order_quantity | ending_on_hand | on_order_end | inventory_position_end | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 4 | period | scoring | 5 | 2026-01-10 | 4.19 | 0.00 | 4.19 | 0.0 | 22.98 | 14.83 | 22.98 | 37.81 |
| 5 | period | scoring | 6 | 2026-01-11 | 3.19 | 0.00 | 3.19 | 0.0 | 0.00 | 11.64 | 22.98 | 34.62 |
| 6 | period | scoring | 7 | 2026-01-12 | 3.85 | 22.98 | 3.85 | 0.0 | 0.00 | 30.77 | 0.00 | 30.77 |
| 7 | period | scoring | 8 | 2026-01-13 | 5.47 | 0.00 | 5.47 | 0.0 | 0.00 | 25.30 | 0.00 | 25.30 |
| 8 | period | scoring | 9 | 2026-01-14 | 6.00 | 0.00 | 6.00 | 0.0 | 16.70 | 19.30 | 16.70 | 36.00 |
| 9 | period | scoring | 10 | 2026-01-15 | 6.25 | 0.00 | 6.25 | 0.0 | 0.00 | 13.06 | 16.70 | 29.75 |
| 10 | period | scoring | 11 | 2026-01-16 | 5.82 | 16.70 | 5.82 | 0.0 | 0.00 | 23.93 | 0.00 | 23.93 |
| 11 | period | scoring | 12 | 2026-01-17 | 4.47 | 0.00 | 4.47 | 0.0 | 0.00 | 19.46 | 0.00 | 19.46 |
# The scoring days on which an order was placed.
scoring_orders = scoring_events.loc[scoring_events["order_quantity"] > 0, event_columns]
scoring_orders.round(display_decimals)
| event_type | run_window | period | date | demand | received_units | fulfilled_units | shortage_units | order_quantity | ending_on_hand | on_order_end | inventory_position_end | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 4 | period | scoring | 5 | 2026-01-10 | 4.19 | 0.0 | 4.19 | 0.0 | 22.98 | 14.83 | 22.98 | 37.81 |
| 8 | period | scoring | 9 | 2026-01-14 | 6.00 | 0.0 | 6.00 | 0.0 | 16.70 | 19.30 | 16.70 | 36.00 |
| 12 | period | scoring | 13 | 2026-01-18 | 3.57 | 0.0 | 3.57 | 0.0 | 22.54 | 15.88 | 22.54 | 38.43 |
| 16 | period | scoring | 17 | 2026-01-22 | 5.99 | 0.0 | 5.99 | 0.0 | 18.48 | 17.53 | 18.48 | 36.01 |
| 20 | period | scoring | 21 | 2026-01-26 | 3.18 | 0.0 | 3.18 | 0.0 | 18.25 | 20.56 | 18.25 | 38.82 |
| 24 | period | scoring | 25 | 2026-01-30 | 5.71 | 0.0 | 5.71 | 0.0 | 20.39 | 15.91 | 20.39 | 36.29 |
Look at the whole run¶
The full event table also keeps the warm-up days, so its demand total must still match the original demand calendar. It also shows the first decision: before the demand of 2026-01-06 the state holds 40 units against a target of 42, so the policy orders 2 units, and they arrive two days later, on 2026-01-08.
assert set(all_events["run_window"]) == {"warmup", "scoring"}
assert all_events["demand"].sum() == demand["y"].sum()
# Whole run: the first demand periods (the warm-up window comes first).
all_events[event_columns].head(5).round(display_decimals)
| event_type | run_window | period | date | demand | received_units | fulfilled_units | shortage_units | order_quantity | ending_on_hand | on_order_end | inventory_position_end | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | period | warmup | 1 | 2026-01-06 | 5.00 | 0.0 | 5.00 | 0.0 | 2.00 | 35.00 | 2.00 | 37.00 |
| 1 | period | warmup | 2 | 2026-01-07 | 6.28 | 0.0 | 6.28 | 0.0 | 0.00 | 28.72 | 2.00 | 30.72 |
| 2 | period | warmup | 3 | 2026-01-08 | 6.37 | 2.0 | 6.37 | 0.0 | 0.00 | 24.36 | 0.00 | 24.36 |
| 3 | period | warmup | 4 | 2026-01-09 | 5.34 | 0.0 | 5.34 | 0.0 | 0.00 | 19.02 | 0.00 | 19.02 |
| 4 | period | scoring | 5 | 2026-01-10 | 4.19 | 0.0 | 4.19 | 0.0 | 22.98 | 14.83 | 22.98 | 37.81 |
# Whole run: the last demand periods.
all_events[event_columns].tail(8).round(display_decimals)
| event_type | run_window | period | date | demand | received_units | fulfilled_units | shortage_units | order_quantity | ending_on_hand | on_order_end | inventory_position_end | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 20 | period | scoring | 21 | 2026-01-26 | 3.18 | 0.00 | 3.18 | 0.0 | 18.25 | 20.56 | 18.25 | 38.82 |
| 21 | period | scoring | 22 | 2026-01-27 | 4.92 | 0.00 | 4.92 | 0.0 | 0.00 | 15.65 | 18.25 | 33.90 |
| 22 | period | scoring | 23 | 2026-01-28 | 5.73 | 18.25 | 5.73 | 0.0 | 0.00 | 28.17 | 0.00 | 28.17 |
| 23 | period | scoring | 24 | 2026-01-29 | 6.56 | 0.00 | 6.56 | 0.0 | 0.00 | 21.61 | 0.00 | 21.61 |
| 24 | period | scoring | 25 | 2026-01-30 | 5.71 | 0.00 | 5.71 | 0.0 | 20.39 | 15.91 | 20.39 | 36.29 |
| 25 | period | scoring | 26 | 2026-01-31 | 4.28 | 0.00 | 4.28 | 0.0 | 0.00 | 11.62 | 20.39 | 32.01 |
| 26 | period | scoring | 27 | 2026-02-01 | 2.66 | 20.39 | 2.66 | 0.0 | 0.00 | 29.35 | 0.00 | 29.35 |
| 27 | period | scoring | 28 | 2026-02-02 | 3.64 | 0.00 | 3.64 | 0.0 | 0.00 | 25.72 | 0.00 | 25.72 |
Read demand, decisions, receipts, and state together¶
This figure is drawn from the event table. Read it left to right: demand reduces stock; an order raises inventory position and pipeline immediately; the receipt marker appears later, on the end-of-day stock after that day's demand is served.
import matplotlib.dates as mdates
ON_HAND_COLOR = "#2C7FB8"
ORDER_COLOR = "#F58518"
RECEIPT_COLOR = "#54A24B"
POSITION_COLOR = "#59A14F"
TARGET_COLOR = "#7F7F7F"
order_events = period_events.loc[period_events["order_quantity"] > 0]
receipt_events = period_events.loc[period_events["received_units"] > 0]
target_level = float(policy.get_target_levels().loc[0, "target_level"])
fig, (demand_ax, stock_ax, position_ax) = plt.subplots(
3,
1,
figsize=(11, 8),
sharex=True,
gridspec_kw={"height_ratios": [1, 2, 1], "hspace": 0.1},
)
# Demand is kept on its own scale so it does not hide the state trajectory.
demand_ax.bar(
period_events["date"],
period_events["demand"],
width=0.8,
color=DEMAND_COLOR,
label="Demand",
)
demand_ax.set_ylabel("Demand")
demand_ax.legend(frameon=False, loc="upper right")
# End-of-day stock, with the target and the order and receipt days marked on it.
stock_ax.step(
period_events["date"],
period_events["ending_on_hand"],
where="post",
color=ON_HAND_COLOR,
linewidth=2,
label="End-of-day on hand",
)
stock_ax.axhline(
target_level,
color=TARGET_COLOR,
linestyle="--",
linewidth=1.2,
label=f"Policy target ({target_level:.0f})",
)
stock_ax.scatter(
order_events["date"],
order_events["ending_on_hand"],
marker="v",
s=55,
color=ORDER_COLOR,
zorder=4,
label="Order placed",
)
stock_ax.scatter(
receipt_events["date"],
receipt_events["ending_on_hand"],
marker="^",
s=55,
color=RECEIPT_COLOR,
edgecolor="white",
zorder=5,
label="Receipt day",
)
stock_ax.set_ylabel("Units")
stock_ax.set_ylim(0, target_level * 1.25) # headroom for the legend
stock_ax.legend(frameon=False, ncol=2, loc="upper right")
# Pipeline and inventory position at the end of each day.
position_ax.step(
period_events["date"],
period_events["on_order_end"],
where="post",
color=ORDER_COLOR,
linewidth=1.8,
label="Pipeline at end of day",
)
position_ax.step(
period_events["date"],
period_events["inventory_position_end"],
where="post",
color=POSITION_COLOR,
linewidth=1.8,
label="Inventory position",
)
position_ax.set(xlabel="Demand date", ylabel="Units", ylim=(0, target_level * 1.4))
position_ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d"))
position_ax.legend(frameon=False, ncol=2, loc="upper right")
for axis in (demand_ax, stock_ax, position_ax):
axis.grid(axis="y", alpha=0.2)
axis.spines[["top", "right"]].set_visible(False)
fig.suptitle("How the engine turns demand into pipeline and inventory state", y=0.98)
fig.subplots_adjust(top=0.92)
plt.show()
5. Keep the settings beside the results¶
A summary is useful, but it cannot reproduce the scenario alone. The run manifest preserves the demand fingerprint and generation settings, the target metadata, opening-state fingerprint, timing windows, and dependency/source provenance. The selected fields below make those choices inspectable without turning this notebook into a manifest dump.
manifest = result.run_manifest
manifest_view = pd.DataFrame(
[
{
"demand_source_name": manifest["demand_source"]["name"],
"demand_rows": manifest["demand_source"]["rows"],
"demand_seed": manifest["demand_source"]["random_seed"],
"period_frequency": manifest["run_settings"]["period_frequency"],
"warmup_periods": manifest["run_settings"]["warmup_periods"],
"scoring_periods": manifest["run_settings"]["scoring_periods"],
}
]
)
manifest_view
| demand_source_name | demand_rows | demand_seed | period_frequency | warmup_periods | scoring_periods | |
|---|---|---|---|---|---|---|
| 0 | seeded_tea_tutorial | 28 | 7 | D | 4 | 24 |
# The policy's target metadata is retained in the manifest too.
pd.DataFrame([manifest["policy"]["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-05T00:00:00 | D | 2026-01-11T00:00:00 |
6. Evaluate the scoring window explicitly¶
InventoryEvaluator reads the event table, not the policy's internals or the plot. We select the scoring window and request one pooled result with groupby=[]. These are units and service measures only, so no cost component or rate is implied by this synthetic scenario.
The table below reports five measures. fill_rate is the share of demanded units that were served, and demand_period_service_level is the share of days with no stockout. Both are 1.0: nothing was lost inside the scoring window. avg_on_hand is the mean end-of-day stock (about 20.4 units), order_units the total quantity ordered (about 119.3 units), and order_event_count the number of orders placed (6, one per review).
# Section 6: scoring-window evaluation from the event table, not plots.
from stockcast.evaluation import (
InventoryEvaluator,
avg_on_hand,
demand_period_service_level,
fill_rate,
order_event_count,
order_units,
)
evaluator = InventoryEvaluator()
evaluator.fit(simulation_result=result, window="scoring")
# groupby=[] pools every SKU and period into a single row.
metrics = evaluator.evaluate(
metrics=[
fill_rate,
demand_period_service_level,
avg_on_hand,
order_units,
order_event_count,
],
groupby=[],
)
metrics.round(3)
| fill_rate | demand_period_service_level | avg_on_hand | order_units | order_event_count | |
|---|---|---|---|---|---|
| 0 | 1.0 | 1.0 | 20.364 | 119.345 | 6 |
7. Use the plot to read the run, not to replace the event table¶
The chart gives a compact view of the engine-owned end-of-day stock trajectory. Demand and receipts are already recorded in the event table above; the plot is only a picture of the same rows. A shaded band would mark a stockout day, while the line is end-of-day on-hand stock.
What to notice: there is no shaded band, because stock never reaches zero. That matches the service level of 1.0 above. Each order lifts the line two days later, when it arrives.
# Section 7: presentation plot of the engine-owned trajectory; event table stays the evidence.
from stockcast.visualization import plot_inventory
fig, ax = plt.subplots(figsize=(10, 4))
plot_inventory(result, sku=sku, ax=ax)
ax.lines[0].set(drawstyle="steps-post", linewidth=2.0, label="End-of-day on hand")
tick_events = period_events.iloc[::4]
ax.set_xticks(tick_events["period"])
ax.set_xticklabels(tick_events["date"].dt.strftime("%b %d"))
ax.set(
title="Engine-owned inventory trajectory",
xlabel="Demand date",
ylabel="Units",
)
ax.legend(frameon=False)
ax.spines[["top", "right"]].set_visible(False)
fig.tight_layout()
plt.show()