The classic illustration is the newsvendor problem: you must order newspapers before knowing demand. Order too few and you lose sales; order too many and you waste inventory. Below are four equally likely demand scenarios.
Drag the slider to choose your first-stage order quantity. The demo evaluates each scenario, applies the optimal second-stage recourse (sell what you can, scrap the rest), and shows the expected total cost across all scenarios.
<!-- Stochastic programming demo — two-stage newsvendor -->
<div class="sp-wrap">
<div class="intro-text">Order stock before demand is known. Each scenario is equally likely. The <b>second-stage recourse</b>: sell what you can, scrap the rest. Find the first-stage order that minimizes <b>expected net cost</b>.</div>
<div class="controls">
<label for="order-slider"><strong>First-stage order quantity:</strong> <span id="order-val">50</span> units</label>
<input id="order-slider" type="range" min="10" max="100" value="50" step="5">
</div>
<div class="scenario-grid" id="scenario-grid">
<!-- Scenario cards rendered by JavaScript -->
</div>
<div class="summary" id="summary">
<div class="summary-row">
<span>Expected net cost:</span>
<span id="exp-cost" class="cost-val">—</span>
</div>
<div class="summary-row">
<span>Optimal order:</span>
<span id="opt-order" class="cost-val opt">—</span>
</div>
</div>
<div class="btns">
<button id="btn-optimize" type="button">Find optimal order</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status"></div>
</div>
/* Stochastic programming demo styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.sp-wrap { padding: .5rem 0; }
.intro-text { font-size: .88rem; color: #444; margin-bottom: .9rem; line-height: 1.5; }
.controls { margin-bottom: .9rem; }
.controls label { display: block; font-size: .9rem; margin-bottom: .35rem; }
input[type=range] { width: 100%; max-width: 320px; accent-color: #1d3557; cursor: pointer; }
.scenario-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: .6rem; margin-bottom: .9rem; }
.scenario-card { background: #e8eef3; border: 1.5px solid #cdd9e3; border-radius: 10px; padding: .55rem .7rem; font-size: .82rem; line-height: 1.55; }
.scenario-card .sc-title { font-weight: 700; color: #1d3557; margin-bottom: .25rem; }
.scenario-card .sc-row { display: flex; justify-content: space-between; }
.scenario-card .sc-val { font-weight: 600; }
.scenario-card.active-best { border-color: #0a7d33; background: #e3f5ea; }
.scenario-card.active-worst { border-color: #c92f3c; background: #fce8ea; }
.summary { background: #f4f7fa; border: 1px solid #cdd9e3; border-radius: 10px; padding: .55rem .8rem; margin-bottom: .8rem; }
.summary-row { display: flex; justify-content: space-between; font-size: .9rem; padding: .15rem 0; }
.cost-val { font-weight: 700; color: #1d3557; }
.cost-val.opt { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.status { font-size: .92rem; font-weight: 600; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Two-stage stochastic programming — newsvendor model.
// Stage 1: choose order quantity x. Stage 2: sell min(x, demand), scrap rest.
// Cost parameters (per unit)
const UNIT_COST = 3; // purchase cost per unit
const SELL_PRICE = 8; // selling price per unit
const SCRAP_VAL = 1; // scrap value per leftover unit
// Four equally likely demand scenarios (discrete distribution)
const SCENARIOS = [
{ label: `Scenario A — Low demand`, demand: 20, prob: 0.25 },
{ label: `Scenario B — Medium demand`, demand: 40, prob: 0.25 },
{ label: `Scenario C — High demand`, demand: 65, prob: 0.25 },
{ label: `Scenario D — Very high demand`, demand: 90, prob: 0.25 }
];
// Second-stage recourse: sell min(order, demand), scrap leftover
function scenarioCost(order, demand) {
const sold = Math.min(order, demand);
const leftover = order - sold;
const unmet = demand - sold;
const revenue = sold * SELL_PRICE + leftover * SCRAP_VAL;
const cost = order * UNIT_COST;
return {
netCost: cost - revenue, // net cost = purchase cost minus revenue
sold, leftover, unmet
};
}
// Expected cost = sum over scenarios of prob * scenario cost
function expectedCost(order) {
return SCENARIOS.reduce((sum, s) => sum + s.prob * scenarioCost(order, s.demand).netCost, 0);
}
// Brute-force search over integer multiples of 5 in [10, 100]
function findOptimal() {
let best = Infinity, bestQ = 10;
for (let q = 10; q <= 100; q += 5) {
const ec = expectedCost(q);
if (ec < best) { best = ec; bestQ = q; }
}
return bestQ;
}
const slider = document.getElementById('order-slider');
const orderVal = document.getElementById('order-val');
const grid = document.getElementById('scenario-grid');
const expCostEl = document.getElementById('exp-cost');
const optOrderEl = document.getElementById('opt-order');
const statusEl = document.getElementById('status');
function render() {
const order = parseInt(slider.value, 10);
orderVal.textContent = order;
const results = SCENARIOS.map(s => ({ ...s, ...scenarioCost(order, s.demand) }));
const ec = expectedCost(order);
// Highlight best and worst scenario for current order
const sorted = [...results].sort((a, b) => a.netCost - b.netCost);
const bestIdx = results.indexOf(sorted[0]);
const worstIdx = results.indexOf(sorted[sorted.length - 1]);
grid.innerHTML = '';
results.forEach((r, i) => {
const card = document.createElement('div');
let cls = 'scenario-card';
if (i === bestIdx) cls += ' active-best';
if (i === worstIdx) cls += ' active-worst';
card.className = cls;
card.innerHTML = "\n " + fmt(`<div class="sc-title">%label%</div>
<div class="sc-row"><span>Demand:</span><span class="sc-val">%demand%</span></div>
<div class="sc-row"><span>Units sold:</span><span class="sc-val">%sold%</span></div>
<div class="sc-row"><span>Leftover (scrapped):</span><span class="sc-val">%leftover%</span></div>
<div class="sc-row"><span>Net cost ($):</span><span class="sc-val">%netCost%</span></div>`, { label: r.label, demand: r.demand, sold: r.sold, leftover: r.leftover, netCost: r.netCost.toFixed(0) }) + "\n ";
grid.appendChild(card);
});
expCostEl.textContent = ec.toFixed(1);
optOrderEl.textContent = '—';
statusEl.textContent = '';
statusEl.className = 'status';
}
document.getElementById('btn-optimize').addEventListener('click', () => {
const opt = findOptimal();
slider.value = opt;
render();
optOrderEl.textContent = opt;
statusEl.textContent = fmt(`Optimal first-stage order: %opt% units.`, { opt: opt });
statusEl.className = 'status ok';
console.log(`Optimal order:`, opt, `| expected cost:`, expectedCost(opt).toFixed(2));
});
document.getElementById('btn-reset').addEventListener('click', () => {
slider.value = 50;
render();
});
slider.addEventListener('input', render);
render();
<!-- Stochastic programming demo — two-stage newsvendor -->
<div class="sp-wrap">
<div class="intro-text">Order stock before demand is known. Each scenario is equally likely. The <b>second-stage recourse</b>: sell what you can, scrap the rest. Find the first-stage order that minimizes <b>expected net cost</b>.</div>
<div class="controls">
<label for="order-slider"><strong>First-stage order quantity:</strong> <span id="order-val">50</span> units</label>
<input id="order-slider" type="range" min="10" max="100" value="50" step="5">
</div>
<div class="scenario-grid" id="scenario-grid">
<!-- Scenario cards rendered by JavaScript -->
</div>
<div class="summary" id="summary">
<div class="summary-row">
<span>Expected net cost:</span>
<span id="exp-cost" class="cost-val">—</span>
</div>
<div class="summary-row">
<span>Optimal order:</span>
<span id="opt-order" class="cost-val opt">—</span>
</div>
</div>
<div class="btns">
<button id="btn-optimize" type="button">Find optimal order</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status"></div>
</div>
/* Stochastic programming demo styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.sp-wrap { padding: .5rem 0; }
.intro-text { font-size: .88rem; color: #444; margin-bottom: .9rem; line-height: 1.5; }
.controls { margin-bottom: .9rem; }
.controls label { display: block; font-size: .9rem; margin-bottom: .35rem; }
input[type=range] { width: 100%; max-width: 320px; accent-color: #1d3557; cursor: pointer; }
.scenario-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: .6rem; margin-bottom: .9rem; }
.scenario-card { background: #e8eef3; border: 1.5px solid #cdd9e3; border-radius: 10px; padding: .55rem .7rem; font-size: .82rem; line-height: 1.55; }
.scenario-card .sc-title { font-weight: 700; color: #1d3557; margin-bottom: .25rem; }
.scenario-card .sc-row { display: flex; justify-content: space-between; }
.scenario-card .sc-val { font-weight: 600; }
.scenario-card.active-best { border-color: #0a7d33; background: #e3f5ea; }
.scenario-card.active-worst { border-color: #c92f3c; background: #fce8ea; }
.summary { background: #f4f7fa; border: 1px solid #cdd9e3; border-radius: 10px; padding: .55rem .8rem; margin-bottom: .8rem; }
.summary-row { display: flex; justify-content: space-between; font-size: .9rem; padding: .15rem 0; }
.cost-val { font-weight: 700; color: #1d3557; }
.cost-val.opt { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.status { font-size: .92rem; font-weight: 600; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Two-stage stochastic programming — newsvendor model.
// Stage 1: choose order quantity x. Stage 2: sell min(x, demand), scrap rest.
// Cost parameters (per unit)
const UNIT_COST = 3; // purchase cost per unit
const SELL_PRICE = 8; // selling price per unit
const SCRAP_VAL = 1; // scrap value per leftover unit
// Four equally likely demand scenarios (discrete distribution)
const SCENARIOS = [
{ label: `Scenario A — Low demand`, demand: 20, prob: 0.25 },
{ label: `Scenario B — Medium demand`, demand: 40, prob: 0.25 },
{ label: `Scenario C — High demand`, demand: 65, prob: 0.25 },
{ label: `Scenario D — Very high demand`, demand: 90, prob: 0.25 }
];
// Second-stage recourse: sell min(order, demand), scrap leftover
function scenarioCost(order, demand) {
const sold = Math.min(order, demand);
const leftover = order - sold;
const unmet = demand - sold;
const revenue = sold * SELL_PRICE + leftover * SCRAP_VAL;
const cost = order * UNIT_COST;
return {
netCost: cost - revenue, // net cost = purchase cost minus revenue
sold, leftover, unmet
};
}
// Expected cost = sum over scenarios of prob * scenario cost
function expectedCost(order) {
return SCENARIOS.reduce((sum, s) => sum + s.prob * scenarioCost(order, s.demand).netCost, 0);
}
// Brute-force search over integer multiples of 5 in [10, 100]
function findOptimal() {
let best = Infinity, bestQ = 10;
for (let q = 10; q <= 100; q += 5) {
const ec = expectedCost(q);
if (ec < best) { best = ec; bestQ = q; }
}
return bestQ;
}
const slider = document.getElementById('order-slider');
const orderVal = document.getElementById('order-val');
const grid = document.getElementById('scenario-grid');
const expCostEl = document.getElementById('exp-cost');
const optOrderEl = document.getElementById('opt-order');
const statusEl = document.getElementById('status');
function render() {
const order = parseInt(slider.value, 10);
orderVal.textContent = order;
const results = SCENARIOS.map(s => ({ ...s, ...scenarioCost(order, s.demand) }));
const ec = expectedCost(order);
// Highlight best and worst scenario for current order
const sorted = [...results].sort((a, b) => a.netCost - b.netCost);
const bestIdx = results.indexOf(sorted[0]);
const worstIdx = results.indexOf(sorted[sorted.length - 1]);
grid.innerHTML = '';
results.forEach((r, i) => {
const card = document.createElement('div');
let cls = 'scenario-card';
if (i === bestIdx) cls += ' active-best';
if (i === worstIdx) cls += ' active-worst';
card.className = cls;
card.innerHTML = "\n " + fmt(`<div class="sc-title">%label%</div>
<div class="sc-row"><span>Demand:</span><span class="sc-val">%demand%</span></div>
<div class="sc-row"><span>Units sold:</span><span class="sc-val">%sold%</span></div>
<div class="sc-row"><span>Leftover (scrapped):</span><span class="sc-val">%leftover%</span></div>
<div class="sc-row"><span>Net cost ($):</span><span class="sc-val">%netCost%</span></div>`, { label: r.label, demand: r.demand, sold: r.sold, leftover: r.leftover, netCost: r.netCost.toFixed(0) }) + "\n ";
grid.appendChild(card);
});
expCostEl.textContent = ec.toFixed(1);
optOrderEl.textContent = '—';
statusEl.textContent = '';
statusEl.className = 'status';
}
document.getElementById('btn-optimize').addEventListener('click', () => {
const opt = findOptimal();
slider.value = opt;
render();
optOrderEl.textContent = opt;
statusEl.textContent = fmt(`Optimal first-stage order: %opt% units.`, { opt: opt });
statusEl.className = 'status ok';
console.log(`Optimal order:`, opt, `| expected cost:`, expectedCost(opt).toFixed(2));
});
document.getElementById('btn-reset').addEventListener('click', () => {
slider.value = 50;
render();
});
slider.addEventListener('input', render);
render();
Notice how the optimal order quantity is not the one that looks best in any single scenario — it is the one that balances all four. That balance is the essence of stochastic programming: the first stage hedges, the second stage adapts.
Comments
Loading comments...