"""
ISOMORPH Supply Chain Digital Twin — Interactive Demo
======================================================
Run locally:
python app.py
Deploy to Hugging Face Spaces:
Upload this file together with the simulator/ and demo/ directories.
Set SDK: gradio in the Space README.
Interaction flow:
Configure parameters → ▶ Run Simulation
→ Network Map (animated shipment propagation)
→ Node Detail (per-node time series)
→ Bullwhip (tier-level amplification chart)
→ Edge Util (heatmap of capacity usage)
→ Download (CSV export)
"""
from __future__ import annotations
import io
import tempfile
import time
from typing import Optional
import gradio as gr
import numpy as np
import pandas as pd
from simulator.demo_simulator import run_demo_simulation
from demo.visualize import (
make_network_animation_html,
make_network_animation_gif,
make_node_timeseries,
make_bullwhip_chart,
make_edge_heatmap,
)
# ============================================================================
# Static reference data
# ============================================================================
# All directed edges in the fixed ISOMORPH topology
_EDGE_STRINGS = [
"None (no disruption)",
"SanFrancisco → Nashville",
"StLouis → Nashville",
"Orlando → Nashville",
"Nashville → Atlanta",
"Atlanta → Chicago",
"Atlanta → Charlotte",
"Atlanta → Memphis",
"Chicago → Columbus",
"Charlotte → Richmond",
"Columbus → Philadelphia",
"Richmond → Philadelphia",
"Richmond → Baltimore",
"Columbus → Baltimore",
"Memphis → Baltimore",
"Philadelphia → NewYork",
"Baltimore → NewYork",
]
# Node choices ordered: destination first, then by tier
_NODE_TIER = {
"NewYork": 0,
"Philadelphia": 1, "Baltimore": 1,
"Columbus": 2, "Richmond": 2,
"Charlotte": 3, "Chicago": 3, "Memphis": 3,
"Atlanta": 4,
"Nashville": 5,
"SanFrancisco": 6, "StLouis": 6, "Orlando": 6,
}
_NODE_CHOICES = sorted(_NODE_TIER, key=lambda n: (_NODE_TIER[n], n))
_TIER_LABEL = {
0: "Destination", 1: "Last-mile",
2: "Tier-4", 3: "Tier-3",
4: "Tier-2 (Atlanta)", 5: "Hub (Nashville)", 6: "Source",
}
# Narrative preset configurations (T, n_items, seed, pipeline_mult,
# phi_lo, shock_height_scale, burst_rate_scale, burst_height_scale,
# containers_scale, ss_scale, leadtime_scale,
# disruption_edge_str, disruption_prob, disruption_duration,
# holding_cost, backlog_penalty)
_PRESETS = {
"baseline": (
200, 3, 42, 7.0, 0.95, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
"None (no disruption)", 0.0, 10, 1.0, 5.0,
),
"demand_shock": (
200, 3, 42, 7.0, 0.95, 4.0, 2.0, 3.0, 1.0, 1.0, 1.0,
"None (no disruption)", 0.0, 10, 1.0, 5.0,
),
"disruption": (
200, 3, 42, 7.0, 0.95, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,
"Atlanta → Chicago", 0.08, 14, 1.0, 5.0,
),
"low_capacity": (
200, 3, 42, 7.0, 0.95, 1.0, 1.0, 1.0, 0.4, 1.0, 1.0,
"None (no disruption)", 0.0, 10, 1.0, 5.0,
),
}
# ============================================================================
# Helper utilities
# ============================================================================
def _parse_edge(edge_str: str):
"""'Atlanta → Chicago' → ('Atlanta', 'Chicago'), 'None...' → None."""
if edge_str.startswith("None"):
return None
parts = edge_str.split(" → ")
return (parts[0].strip(), parts[1].strip()) if len(parts) == 2 else None
def _build_config(T, n_items, seed, pipeline_mult,
phi_lo, shock_height_scale,
burst_rate_scale, burst_height_scale,
containers_scale, ss_scale, leadtime_scale,
disruption_edge_str, disruption_prob, disruption_duration,
holding_cost, backlog_penalty) -> dict:
return {
"T": int(T),
"n_items": int(n_items),
"seed": int(seed),
"pipeline_mult": float(pipeline_mult),
"phi_lo": float(phi_lo),
"phi_hi": min(float(phi_lo) + 0.02, 0.9999),
"shock_height_scale": float(shock_height_scale),
"burst_rate_scale": float(burst_rate_scale),
"burst_height_scale": float(burst_height_scale),
"containers_scale": float(containers_scale),
"ss_scale": float(ss_scale),
"leadtime_scale": float(leadtime_scale),
"disruption_edge": _parse_edge(str(disruption_edge_str)),
"disruption_prob": float(disruption_prob),
"disruption_duration": int(disruption_duration),
"holding_cost": float(holding_cost),
"backlog_penalty": float(backlog_penalty),
}
def _format_summary(result, elapsed_s: float) -> str:
"""Return a Markdown summary table."""
lines = [
f"**Simulation complete** in `{elapsed_s:.2f}s` · "
f"T = {result.T} days · "
f"Items = {len(result.item_ids)} · "
f"Shipments logged = {len(result.shipments):,}",
"",
"| Item | Fill Rate | Total Demand | Backlogs |",
"|------|----------:|-------------:|---------:|",
]
for iid in result.item_ids:
fr = result.fill_rate.get(iid, 0.0)
dem = int(result.demand[iid].sum())
bl = int(result.backlog["NewYork"][iid].sum())
lines.append(f"| `{iid}` | {fr:.1%} | {dem:,} | {bl:,} |")
n_ev = len(result.disruption_log)
if n_ev:
ded = result.disruption_log[0]["edge"]
lines += [
"",
f"⚠️ **Disruption** on `{ded[0]} → {ded[1]}`"
f" · {n_ev} event(s) triggered over {result.T} days",
]
return "\n".join(lines)
def _make_csv_bytes(result) -> bytes:
"""Return a CSV as bytes for download."""
rows = []
for nid in result.node_ids:
for iid in result.item_ids:
for t in range(result.T):
rows.append({
"day": t,
"node": nid,
"item": iid,
"inventory": int(result.inventory[nid][iid][t]),
"backlog": int(result.backlog[nid][iid][t]),
"inflow": int(result.inflow[nid][iid][t]),
"outflow": int(result.outflow[nid][iid][t]),
"demand": int(result.demand[iid][t])
if nid == "NewYork" else "",
})
buf = io.StringIO()
pd.DataFrame(rows).to_csv(buf, index=False)
return buf.getvalue().encode()
# ============================================================================
# Gradio callbacks
# ============================================================================
def run_sim(T, n_items, seed, pipeline_mult,
phi_lo, shock_height_scale,
burst_rate_scale, burst_height_scale,
containers_scale, ss_scale, leadtime_scale,
disruption_edge_str, disruption_prob, disruption_duration,
holding_cost, backlog_penalty):
"""
Main callback: builds config, runs simulation, generates all figures.
Returns (sim_state, anim_fig, ts_fig, bw_fig, heat_fig, summary_md,
item_filter_update).
"""
try:
config = _build_config(
T, n_items, seed, pipeline_mult,
phi_lo, shock_height_scale,
burst_rate_scale, burst_height_scale,
containers_scale, ss_scale, leadtime_scale,
disruption_edge_str, disruption_prob, disruption_duration,
holding_cost, backlog_penalty,
)
t0 = time.time()
result = run_demo_simulation(config)
elapsed = time.time() - t0
fig_anim = make_network_animation_html(result)
fig_ts = make_node_timeseries(result, "NewYork")
fig_bw = make_bullwhip_chart(result)
fig_heat = make_edge_heatmap(result)
summary = _format_summary(result, elapsed)
item_choices = ["All items"] + result.item_ids
return (
result,
fig_anim,
fig_ts,
fig_bw,
fig_heat,
summary,
gr.Dropdown(choices=item_choices, value="All items"),
)
except Exception as exc:
err = f"**Error during simulation:** `{exc}`"
return None, None, None, None, None, err, gr.skip()
def update_timeseries(result, node_id: str, item_filter: str):
"""Redraws the node time-series when node or item selection changes."""
if result is None:
return None
item_ids = None if item_filter == "All items" else [item_filter]
return make_node_timeseries(result, node_id, item_ids=item_ids)
def generate_gif(result):
"""Renders the network animation as an animated GIF and returns the file path."""
if result is None:
return None
return make_network_animation_gif(result)
def prepare_download(result):
"""Writes a CSV to a temp file and returns the path for gr.File."""
if result is None:
return None
data = _make_csv_bytes(result)
tmp = tempfile.NamedTemporaryFile(
suffix=".csv", delete=False, mode="wb", prefix="isomorph_")
tmp.write(data)
tmp.close()
return tmp.name
# Collect all slider/dropdown inputs in order (must match run_sim signature)
def _all_inputs(components: dict) -> list:
return [
components["T"], components["n_items"], components["seed"],
components["pipeline_mult"], components["phi_lo"],
components["shock_height_scale"],
components["burst_rate_scale"], components["burst_height_scale"],
components["containers_scale"], components["ss_scale"],
components["leadtime_scale"],
components["disruption_edge"], components["disruption_prob"],
components["disruption_duration"],
components["holding_cost"], components["backlog_penalty"],
]
# ============================================================================
# Gradio application layout
# ============================================================================
_CSS = """
*, body, .gradio-container, .gradio-container * {
font-family: "Inter", "Helvetica Neue", Arial, sans-serif !important;
}
#run-btn { font-size: 1.1em; }
.panel-header { font-weight: 600; color: #1a1a2e; }
.result-tab > div { padding-top: 4px; }
footer { display: none !important; }
/* Shared accent accordion style — used for highlighted collapsibles */
#markov-accordion > .label-wrap,
#learn-more-accordion > .label-wrap,
#state-space-accordion > .label-wrap {
background: linear-gradient(90deg, #1a3a5c 0%, #2471a3 100%);
border-radius: 6px;
padding: 8px 14px;
}
#markov-accordion > .label-wrap span,
#markov-accordion > .label-wrap .icon,
#learn-more-accordion > .label-wrap span,
#learn-more-accordion > .label-wrap .icon,
#state-space-accordion > .label-wrap span,
#state-space-accordion > .label-wrap .icon {
color: #ffffff !important;
font-weight: 700;
font-size: 1.05em;
letter-spacing: 0.01em;
}
#markov-accordion,
#learn-more-accordion,
#state-space-accordion {
border: 2px solid #2471a3 !important;
border-radius: 8px;
margin-top: 10px;
}
"""
_DESCRIPTION = """
***Interactive simulation environment for stress-testing supply chains under demand shocks, disruptions, and cascading transport congestion.***
Modern supply chains are vulnerable to delays, congestion, shortages, and cascading disruptions. This demo provides an interpretable digital twin for configuring and simulating the real-time evolution of a **stochastic multi-echelon supply-chain network**, studying how local operational decisions propagate through large logistics networks over time.
This demo runs a **fixed 13-node US network**: three suppliers (San Francisco, St. Louis, Orlando) → regional hub (Nashville) → distribution warehouses (Atlanta, Chicago, Charlotte, Memphis, Columbus, Richmond) → last-mile DCs (Philadelphia, Baltimore) → destination (New York).
**Users can interactively configure:**
**Demand characteristics** · **Network capacity** · **Edge disruptions** · **Simulation scope** — then observe how operational effects propagate through the system over time.
**The platform is designed to support:**
- Stress-testing supply chains under demand shocks, congestion, and disruptions
- Studying bullwhip effect amplification across network tiers
- Visualizing cascading congestion, bottleneck formation, and inventory depletion
- Building intuition for how local operational decisions affect global network behavior
All within a fixed 13-node network and a product catalogue of up to 5 SKUs.
---
**Presets and parameter tuning**
Four preset buttons at the top instantly load and run a scenario. There is one baseline and three stress-test scenarios:
- 🟢 **Baseline** — stable operating conditions; observe inventory cycles and the mild bullwhip effect emerging internally from (s, S) ordering and lead-time delays alone
- ⚡ **Demand Shock** — correlated macro shocks and per-item bursts amplify demand variability; backlogs build at NewYork (destination) and variability amplifies upstream through the network
- 🔴 **Disruption** — the Atlanta→Chicago lane is randomly blocked; goods reroute and inventory depletes downstream of the outage, then a catch-up wave propagates on recovery
- 📦 **Low Capacity** — all edges at 40% capacity; cascading transport congestion propagates from the last-mile inward, causing systemic stockouts and extreme bullwhip amplification
To stress-test further, adjust individual parameters using the sliders in the left panel and click **▶ Run Simulation**. The sliders are organized into groups:
- **⚙️ Simulation Settings** — horizon length, number of SKUs, random seed, and pipeline multiplier; these define the scope of any run and are not specific to a scenario
- **📈 Demand Scenario** — shock amplitude, burst frequency and size, AR persistence; primary levers for the ⚡ Demand Shock scenario
- **🔴 Disruption** — edge to block, outage probability, duration; primary levers for the 🔴 Disruption scenario
- **🚚 Supply & Network** — edge capacity, reorder thresholds, source lead times; primary levers for the 📦 Low Capacity scenario
- **💰 Costs** — holding cost and backlog penalty; display-only annotations that do not affect simulation dynamics, but are recorded in the CSV export for post-hoc cost analysis
The full simulator — where topology, catalogue size, demand model, replenishment policy, and routing are all user-configurable — is open-source at **https://github.com/tuhinsahai/ISOMORPH**.
"""
_LEARN_MORE = """
ISOMORPH simulates the flow of multiple product types (each called a **Stock Keeping Unit, SKU**) through a directed network of factories, intermediate warehouses, and a customer-facing destination, advancing every location and link forward in discrete time.
In each time step, random customer demand arrives at the destination, is served from available stock or recorded as **backlog**, and triggers replenishment orders that propagate back through the network. Shipments travel along directed edges with fixed transit times, are routed via **Dijkstra shortest-path**, packed greedily into **finite-capacity containers**, and restocked at each warehouse under **(s, S) reorder policies** — reorder up to level S when stock drops below reorder point s.
Demand is driven by a **five-component signal**: a persistent AR(1) trend, a long-run drift, rare macro shocks shared across all SKUs (e.g. a holiday surge), independent per-item burst events, and Gaussian noise — producing the correlated, lumpy demand patterns characteristic of real logistics networks.
The simulation is **stochastic**: demand draws, source lead times, and disruption timing are all random. Changing the **random seed** gives a different realization of the same scenario, enabling ensemble analysis and forward uncertainty quantification.
"""
_STATE_SPACE = """
**Why ISOMORPH tracks more than just inventory — the "right state space"**
Most supply-chain datasets record only how much stock sits at each location. ISOMORPH also records, at every simulated day: the replenishment orders currently outstanding with suppliers, the shipments traveling through the network, and a running smoothed estimate of recent demand — alongside on-hand inventory and backlog at every node. Together these five types of information form the **complete state** of the simulation.
This matters because knowing only today's warehouse stock is not enough to predict tomorrow: a shipment already in transit will arrive regardless of what else happens, and a pending supplier order determines when restocking will occur. Without tracking these "invisible" quantities, the simulation would need to remember weeks of past history to make predictions. With all five, tomorrow depends only on today — the mathematical property called **Markovian** (a Markov chain).
This "right state" design also enforces **three exact conservation laws** that hold on every simulated day, for every random scenario, with no approximation:
1. Each node's inventory changes by exactly what arrives minus what ships.
2. Total units inside the network change only when a supplier delivers or a customer is served.
3. Backlog only grows when customer demand exceeds on-hand stock.
These laws are built into the simulator's structure, not imposed afterward — so they serve as verification tools: any violation would indicate a bug, not a modeling choice.
"""
with gr.Blocks(
title="ISOMORPH Supply Chain Digital Twin",
css=_CSS,
theme=gr.themes.Soft(primary_hue="blue", neutral_hue="slate"),
) as demo:
# ── State ────────────────────────────────────────────────────────────────
sim_state = gr.State(None)
# ── Header ───────────────────────────────────────────────────────────────
gr.HTML(
'
'
)
gr.Markdown(_DESCRIPTION)
with gr.Accordion("📖 Learn more about the simulator dynamics", open=False, elem_id="learn-more-accordion"):
gr.Markdown(_LEARN_MORE)
with gr.Accordion("🔢 Mathematical structure of the state space — the \"right state space\"", open=False, elem_id="state-space-accordion"):
gr.Markdown(_STATE_SPACE)
# ── Quick-preset buttons ─────────────────────────────────────────────────
with gr.Row():
gr.Markdown("**Quick presets:**")
btn_baseline = gr.Button("🟢 Baseline", size="sm", variant="secondary")
btn_shock = gr.Button("⚡ Demand Shock", size="sm", variant="secondary")
btn_disrupt = gr.Button("🔴 Disruption", size="sm", variant="secondary")
btn_low_cap = gr.Button("📦 Low Capacity", size="sm", variant="secondary")
gr.Markdown("---")
# ── Main layout: controls | results ──────────────────────────────────────
with gr.Row(equal_height=False):
# ── LEFT: Configuration panel ─────────────────────────────────────
with gr.Column(scale=1, min_width=280):
with gr.Accordion("⚙️ Simulation Settings", open=True):
c = {} # dict of all input components (for easy callback wiring)
c["T"] = gr.Slider(
10, 500, value=200, step=5,
label="Horizon T (days)",
info="Total simulation length. 200 days ≈ 7 months; enough to see seasonal patterns and bullwhip cycles. Keep ≤ 500 for fast response.",
)
c["n_items"] = gr.Slider(
1, 5, value=3, step=1,
label="Number of SKUs (Stock Keeping Units)",
info="Each SKU is an independent product type with its own AR(1) demand, (s,S) reorder policy, and physical volume. More SKUs increases total network load.",
)
c["seed"] = gr.Number(
value=42, label="Random seed", precision=0,
info="Controls all stochastic draws (demand, shocks, bursts, lead times). Change this to get a different random realization of the same scenario.",
)
c["pipeline_mult"] = gr.Slider(
0, 15, value=7.0, step=0.5,
label="Pipeline multiplier",
info="How many days of smoothed (EMA) demand are kept in the in-transit replenishment pipeline. Higher = more inventory pre-positioned; 0 = purely reactive (s,S) ordering.",
)
with gr.Accordion("📈 Demand Scenario", open=True):
c["phi_lo"] = gr.Slider(
0.50, 0.999, value=0.95, step=0.01,
label="Demand memory φ (AR coefficient)",
info="Auto-regressive coefficient controlling demand persistence. Near 1.0 = slow, long trends (hard to forecast); near 0.5 = rapid mean-reverting fluctuations.",
)
c["shock_height_scale"] = gr.Slider(
0.0, 6.0, value=1.0, step=0.1,
label="Macro shock amplitude",
info="Scales the height of rare, correlated demand spikes that hit all SKUs simultaneously (e.g. a holiday surge). 0 = no shocks; 4+ = severe shocks.",
)
c["burst_rate_scale"] = gr.Slider(
0.0, 6.0, value=1.0, step=0.1,
label="Idiosyncratic burst frequency",
info="Scales how often individual-SKU demand bursts occur (independent across items). Higher = more frequent localized spikes.",
)
c["burst_height_scale"] = gr.Slider(
0.0, 6.0, value=1.0, step=0.1,
label="Idiosyncratic burst amplitude",
info="Scales how large each individual-SKU burst is when it occurs.",
)
with gr.Accordion("🔴 Disruption", open=False):
c["disruption_edge"] = gr.Dropdown(
choices=_EDGE_STRINGS,
value="None (no disruption)",
label="Edge to disrupt",
info="Select a directed shipping lane to subject to random outages. When triggered, that edge's capacity drops to zero for the disruption duration.",
)
c["disruption_prob"] = gr.Slider(
0.0, 0.30, value=0.0, step=0.01,
label="Disruption probability per day",
info="Each day, a Bernoulli draw with this probability triggers a new disruption event (if none is currently active). 0.08 ≈ one event every ~13 days.",
)
c["disruption_duration"] = gr.Slider(
1, 60, value=10, step=1,
label="Disruption duration (days)",
info="Number of consecutive days the edge is completely blocked once triggered. Marked as red dashed lines in the Node Detail and Edge Util plots.",
)
with gr.Accordion("🚚 Supply & Network", open=False):
c["containers_scale"] = gr.Slider(
0.1, 3.0, value=1.0, step=0.1,
label="Edge capacity scale",
info="Multiplies the number of shipping containers available per edge per day. <1 = congested network (queuing, missed shipments); >1 = excess capacity (lower utilization).",
)
c["ss_scale"] = gr.Slider(
0.1, 3.0, value=1.0, step=0.1,
label="Reorder threshold (s, S) scale",
info="Scales both the reorder point s and the order-up-to level S at every warehouse. <1 = lean / just-in-time; >1 = large safety-stock buffers.",
)
c["leadtime_scale"] = gr.Slider(
0.5, 10.0, value=1.0, step=0.5,
label="Source lead-time scale",
info="Multiplies the replenishment lead time at source nodes (SanFrancisco, StLouis, Orlando). High values simulate distant or slow suppliers and create longer supply gaps.",
)
with gr.Accordion("💰 Costs (display only)", open=False):
c["holding_cost"] = gr.Slider(
0.1, 10.0, value=1.0, step=0.1,
label="Inventory holding cost per unit per day",
info="Cost of carrying one unit of stock for one day (warehousing, capital). Display only — does not affect simulation dynamics.",
)
c["backlog_penalty"] = gr.Slider(
1.0, 20.0, value=5.0, step=0.5,
label="Backlog penalty per unit per day",
info="Penalty for each unit of unfulfilled demand per day (lost-sale cost, SLA breach). Display only — does not affect simulation dynamics.",
)
run_btn = gr.Button(
"▶ Run Simulation", variant="primary",
size="lg", elem_id="run-btn",
)
status_md = gr.Markdown(
"_Configure parameters above and click ▶ Run Simulation._"
)
# ── RIGHT: Results panel ──────────────────────────────────────────
with gr.Column(scale=2, min_width=560):
with gr.Tabs():
# Tab 1 — animated network map
with gr.Tab("🗺️ Network Map"):
gr.Markdown(
"This map shows the physical flow of goods across the US supply chain in real time."
" Node colors reflect inventory health at every simulation day;"
" moving dots trace shipments as they travel between facilities.\n\n"
"**Node color** = backlog stress —"
" ■ green (healthy)"
" → ■ yellow (building backlog)"
" → ■ red (stockout) \n"
"**Node shape** = facility role —"
" ★ destination · ■ last-mile DC · ● warehouse · ◆ Tier-2 · ⬡ hub · ▲ supplier \n"
"**Moving dot color** = which Stock Keeping Unit (SKU) is being shipped \n"
"**Edge thickness** = proportional to the daily shipping capacity of that lane\n\n\n"
"**How to interact:**\n\n"
"- ▶ **Play / scrub:** press **▶ Play** to animate through the simulation, or drag the day slider to jump to any point.\n"
"- 🏭 **Hover over a node:** see its current total inventory and backlog counts.\n"
"- 📦 **Hover over a moving dot:** see the shipment's origin, destination, quantity, and arrival day."
)
anim_plot = gr.HTML(label="Shipment propagation")
with gr.Row():
gif_btn = gr.Button(
"⬇️ Export as GIF", variant="secondary", size="sm",
)
gr.Markdown(
"_Renders up to 80 frames as a portable animated GIF. May take ~30 s._"
)
gif_dl = gr.File(label="Animated GIF", file_types=[".gif"])
# Tab 2 — node detail time series
with gr.Tab("📊 Node Detail"):
gr.Markdown(
"Shows the time history of inventory and material flows at one node across all simulation days."
" Use this to trace how a facility responds to demand shocks, disruptions, or capacity constraints.\n\n"
"**Inventory** = on-hand stock at this node (units)"
" — state component (1), OHn,it: integer count of item i held at node n on day t \n"
"**Backlog** = unfulfilled demand waiting to be served (units; non-zero means stock ran out)"
" — state component (2), Bn,it: demand units owed; non-zero only at the destination \n"
"**Inflow** = units arriving per day"
" — daily receipts Rn,it: the realization of component (4) ITt;"
" at non-destination nodes Rn,it = q when a pending order Outn,i = (t, q) matures;"
" at the destination it is the subset of ITt whose arrival timestamp equals t \n"
"**Outflow** = units dispatched per day"
" — daily dispatches Dn,it: units packed onto outgoing edges by the greedy bin-packing algorithm,"
" driven by component (3) Outt and Dijkstra routing \n"
"**Demand** = actual customer orders received per day, yi,t"
" — external random input to the chain, not a state component; shown only at the destination (NewYork)."
" Note: the internal smoothed demand estimate (state component 5),"
" λ̃it+1 = 0.05·yi,t + 0.95·λ̃it,"
" is the exponential moving average of yi,t and is what drives replenishment order sizing — it is not plotted here \n"
"**Red dashed vertical line**"
" = day a disruption event was triggered on a connected edge\n\n\n"
"Select a **Node** and an **Item (SKU)** from the dropdowns below. **How to read the panels:**\n\n"
"- 📦 **Inventory + Backlog:** when inventory (panel 1) hits zero, backlog (panel 2) spikes — the node has run out of stock and is accumulating unfulfilled demand.\n"
"- 🔄 **Inflow + Outflow:** when inflow (panel 3) persistently exceeds outflow (panel 4), the node is building up stock; when outflow exceeds inflow, it is drawing down reserves.\n"
"- ⚡ **Demand (destination only):** compare demand (panel 5) with inflow (panel 3) — a lag between a demand spike and the inflow response reveals the end-to-end replenishment delay through the network."
)
with gr.Accordion("📐 About the Markov State — Mathematical Details", open=False, elem_id="markov-accordion"):
gr.Markdown(
"The simulation is a Markov chain whose full state at each day is "
"ξt = (OHt, Bt, Outt, ITt, λ̃t) — five components: "
"(1) on-hand inventory, (2) backlog, (3) outstanding supplier orders, (4) scheduled in-transit arrivals, and (5) smoothed demand estimate. "
"Together these C(3N+1) = **120 fixed scalar dimensions** "
"— where N = 13 nodes and C = 3 SKUs (Stock Keeping Units) for this simulation — "
"(plus a variable-length in-transit list ITt) "
"contain everything needed to determine the next day's state, with no reliance on history. "
"The full ISOMORPH release provides datasets at catalogue sizes C = 50 and C = 200, corresponding to "
"**≥ 2,000 and ≥ 8,000 fixed scalar state dimensions** respectively "
"(see the ISOMORPH paper for the complete state-space specification).\n\n"
"This panel directly plots components (1) and (2). "
"The remaining three components are not plotted directly, but their effects appear as the daily increment terms Inflow and Outflow, "
"which satisfy the per-node conservation law:\n\n"
"OHn,it+1 = OHn,it + Rn,it − Dn,it\n\n"
"This identity holds exactly on every simulated day — it is not an approximation but a structural invariant of the Markov chain. "
"Any deviation would indicate a simulation bug, not a modeling choice."
)
with gr.Row():
node_dd = gr.Dropdown(
choices=_NODE_CHOICES,
value="NewYork",
label="Node",
info="13 nodes by tier — NewYork: destination · Philadelphia, Baltimore: last-mile DCs · Columbus, Richmond: tier-4 · Charlotte, Chicago, Memphis: tier-3 · Atlanta: tier-2 · Nashville: hub · SanFrancisco, StLouis, Orlando: suppliers",
scale=2,
)
item_filter_dd = gr.Dropdown(
choices=["All items"],
value="All items",
label="Item (SKU)",
info="Stock Keeping Unit — a distinct product type. Show all SKUs overlaid, or isolate one to remove clutter.",
scale=1,
)
ts_plot = gr.Plot(label="Time series")
# Tab 3 — bullwhip chart
with gr.Tab("📈 Bullwhip"):
gr.Markdown(
'Measures how much demand variability amplifies as orders travel upstream — the "bullwhip effect."'
" Replenishment decisions made with delayed inventory information, lead-time uncertainty, and lumpy (s,S) ordering"
" can cause upstream order variability to exceed downstream demand variability — but the pattern is not always a"
" simple monotone staircase. **Amplification can be uneven, selective, or even locally suppressed depending on"
" the scenario and the network topology** — which nodes connect to which, and how many paths exist between tiers.\n\n"
"**Bar height** = B = Var(inflow) / Var(outflow), averaged over all nodes in that tier \n"
"**⎯⎯ Dashed line at B = 1**"
" = no-amplification baseline; each tier passes demand through unchanged \n"
"**B > 1**"
" = orders placed at that tier are more variable than the downstream demand that triggered them \n"
"**B < 1**"
" = that tier smooths variability — can occur when bottlenecks suppress flow propagation \n"
"**Tier axis** = NewYork (downstream, left)"
" → Suppliers (upstream, right) \n"
"**Bar color** = one distinct color per Stock Keeping Unit (SKU)\n\n\n"
"**What to expect across scenarios:**\n\n"
"- 🟢 **Baseline:** the network already exhibits a clear bullwhip effect without any externally imposed shocks."
" Amplification is not uniform — specific tiers, particularly last-mile replenishment nodes, may become dominant"
" variability amplifiers as local ordering policies interact with transportation delays and inventory thresholds."
" Complex upstream variability emerges internally from otherwise stable conditions.\n"
"- ⚡ **Demand Shock:** macro shocks and per-SKU bursts inject additional variability at the destination."
" Upstream tiers respond with larger replenishment swings as delayed information and transport lags compound the fluctuations.\n"
"- 🔴 **Disruption:** the pattern becomes mixed. Some tiers may temporarily show B < 1 because transport bottlenecks"
" suppress downstream flow propagation. Other tiers exhibit sharp amplification as delayed replenishment creates"
" catch-up ordering waves once the disruption clears.\n"
"- 📦 **Low Capacity:** congestion and stockouts generate highly lumpy replenishment behavior."
" Extreme amplification can appear at selected tiers, where recovery orders become much larger and more variable"
" than the original downstream demand signal."
)
bw_plot = gr.Plot(label="Bullwhip amplification")
# Tab 4 — edge utilization heatmap
with gr.Tab("🔥 Edge Util"):
gr.Markdown(
"Shows which shipping lanes are busy, congested, or completely blocked at each simulation day."
" Persistent congestion on a lane starves downstream nodes and propagates stockouts forward.\n\n"
"**Row** = one directed shipping lane (upstream node → downstream node) \n"
"**Column** = simulation day \n"
"**Cell color** = fraction of daily shipping capacity used —"
" ■ light blue (near-empty)"
" → ■ orange (~50%)"
" → ■ red (saturated at 100%) \n"
"**Blue dashed vertical line**"
" = day a disruption event blocked this lane (capacity dropped to zero)\n\n\n"
"**How to read the heatmap:**\n\n"
"- 🔴 **Red bands:** persistent red on a lane means it is chronically saturated, forcing upstream nodes to stockpile or reroute goods via longer alternate paths.\n"
"- 🔍 **Locate the bottleneck:** compare the last-mile lanes (Philadelphia/Baltimore → NewYork) with upstream lanes — the lane that stays red longest is the binding constraint.\n"
"- 🔵 **Disruption events:** enable a disruption in the left panel and re-run to see blue dashed markers appear on the day each outage was triggered."
)
heat_plot = gr.Plot(label="Edge utilization")
# Tab 5 — download
with gr.Tab("⬇️ Download"):
gr.Markdown(
"Click **Prepare CSV** to generate a downloadable file "
"containing inventory, backlog, inflow, and outflow "
"for every node and item at every time step."
)
dl_btn = gr.Button("Prepare CSV", variant="secondary")
csv_dl = gr.File(label="Download", file_types=[".csv"])
# =========================================================================
# Callback wiring
# =========================================================================
_inputs = _all_inputs(c)
# ── Outputs returned by run_sim ───────────────────────────────────────────
_run_outputs = [
sim_state, anim_plot, ts_plot, bw_plot, heat_plot,
status_md, item_filter_dd,
]
# api_name=False on every handler suppresses Gradio's JSON-schema
# introspection, which crashes on Python 3.9 + Gradio 4.44 when a
# gr.State holds a complex dataclass (TypeError: 'bool' not iterable).
run_btn.click(
fn=run_sim,
inputs=_inputs,
outputs=_run_outputs,
api_name=False,
)
# ── Quick preset buttons (set knobs then run) ─────────────────────────────
def _preset(name: str):
vals = _PRESETS[name]
result_vals = run_sim(*vals)
return list(vals) + list(result_vals)
_preset_slider_outputs = [
c["T"], c["n_items"], c["seed"], c["pipeline_mult"],
c["phi_lo"], c["shock_height_scale"],
c["burst_rate_scale"], c["burst_height_scale"],
c["containers_scale"], c["ss_scale"], c["leadtime_scale"],
c["disruption_edge"], c["disruption_prob"], c["disruption_duration"],
c["holding_cost"], c["backlog_penalty"],
]
for btn, name in [
(btn_baseline, "baseline"),
(btn_shock, "demand_shock"),
(btn_disrupt, "disruption"),
(btn_low_cap, "low_capacity"),
]:
btn.click(
fn=lambda n=name: _preset(n),
inputs=[],
outputs=_preset_slider_outputs + _run_outputs,
api_name=False,
)
# ── Node / item selection updates time series ─────────────────────────────
node_dd.change(
fn=update_timeseries,
inputs=[sim_state, node_dd, item_filter_dd],
outputs=[ts_plot],
api_name=False,
)
item_filter_dd.change(
fn=update_timeseries,
inputs=[sim_state, node_dd, item_filter_dd],
outputs=[ts_plot],
api_name=False,
)
# ── CSV download ──────────────────────────────────────────────────────────
dl_btn.click(
fn=prepare_download,
inputs=[sim_state],
outputs=[csv_dl],
api_name=False,
)
# ── GIF export ────────────────────────────────────────────────────────────
gif_btn.click(
fn=generate_gif,
inputs=[sim_state],
outputs=[gif_dl],
api_name=False,
)
# ── Auto-run baseline on page load ────────────────────────────────────────
demo.load(
fn=lambda: _preset("baseline"),
inputs=[],
outputs=_preset_slider_outputs + _run_outputs,
api_name=False,
)
# ============================================================================
# Entry point
# ============================================================================
if __name__ == "__main__":
demo.launch(show_error=True)