Fixing Thrashing Reorder Points From Noisy Daily Demand
Recomputing a reorder point every cycle directly from the latest daily demand sample makes the threshold exactly as noisy as the demand itself. This page documents that failure inside inventory threshold optimization: a min/reorder level that swings from a sensible value to an absurd one and back within three recompute cycles, because nothing in the calculation smooths the input or requires a sustained trend before the threshold is allowed to move. The same instability upstream corrupts every downstream consumer of asset lookup and inventory synchronization data, from procurement to technician dispatch.
Incident Profile
A nightly job recomputes the reorder point for every SKU from the previous day’s demand and writes it straight into the CMMS inventory record. The part below is a mid-volume bearing with genuinely stable long-run demand — but its reorder point does not look stable at all.
2026-07-01 02:00:11 INFO [reorder_recompute] SKU=BRG-4402 cycle=118 demand_yesterday=41 reorder_point=41 status=OK (below threshold, no PO)
2026-07-02 02:00:09 INFO [reorder_recompute] SKU=BRG-4402 cycle=119 demand_yesterday=118 reorder_point=118 status=TRIGGER (PO-55231 created)
2026-07-03 02:00:14 WARN [reorder_recompute] SKU=BRG-4402 cycle=120 demand_yesterday=52 reorder_point=52 status=OK (below threshold, PO-55231 still open)
2026-07-04 02:00:10 INFO [reorder_recompute] SKU=BRG-4402 cycle=121 demand_yesterday=44 reorder_point=44 status=OK
2026-07-05 02:00:12 WARN [reorder_recompute] SKU=BRG-4402 cycle=122 demand_yesterday=121 reorder_point=121 status=TRIGGER (PO-55247 created)
2026-07-06 02:00:09 ERROR [reorder_recompute] SKU=BRG-4402 cycle=123 demand_yesterday=48 reorder_point=48 status=OK (2 open POs for same SKU)
2026-07-07 02:00:13 WARN [reorder_recompute] SKU=BRG-4402 cycle=124 demand_yesterday=55 reorder_point=55 status=OK
2026-07-08 02:00:11 INFO [reorder_recompute] SKU=BRG-4402 cycle=125 demand_yesterday=97 reorder_point=97 status=TRIGGER (PO-55261 created)
2026-07-09 02:00:08 ERROR [alert_channel] SKU=BRG-4402 fired 3 reorder alerts in 8 cycles — procurement queue flagged as noisy
Between cycle=118 and cycle=125 the reorder point recorded for the same part is 41 → 118 → 52 → 44 → 121 → 48 → 55 → 97. Three separate purchase orders open for one SKU inside eight days, one of them (PO-55247) is created while an earlier one (PO-55231) is still outstanding, and the procurement team’s alert channel escalates the part as a data-quality problem rather than a genuine supply issue. The bearing’s actual long-run average demand is close to 65 units a day — nowhere near either extreme the threshold keeps landing on.
The diagram below plots the same failure against a stabilized alternative: the dashed line is the naive reorder point (it is the raw demand sample, unsmoothed), and the solid line is an EWMA-smoothed reorder point gated by a dead-band, so it only moves when the smoothed signal clears the band rather than on every daily wobble.
Eleven of the naive line’s fourteen points fall outside the shaded dead-band, each one a fresh reorder decision computed from scratch. The smoothed line clears the band once, on the first cycle while it is still settling, and then stays inside it for the rest of the window even though the underlying demand is exactly as noisy.
Root Cause Analysis
The reorder point in the failing job is defined as a pure function of the single most recent demand observation:
def reorder_point(demand_log: list[float]) -> float:
# Whatever demand did yesterday becomes today's threshold, verbatim.
return demand_log[-1]
Three properties of this function guarantee thrash:
- No smoothing. The threshold has the same variance as the input series. If daily demand for a part legitimately ranges from 40 to 120 units around a stable mean, the reorder point ranges over the same interval, even though the part’s true replenishment need barely changes cycle to cycle.
- No safety margin derived from variability. A defensible reorder point accounts for lead-time demand plus a safety stock term sized to the standard deviation of demand, not the latest sample. Ignoring the variance term means a single unusually high or low day is treated as the new normal.
- No dead-band (hysteresis). Even a smoothed signal will drift up and down slightly around its mean. Without a band the threshold still updates on every cycle, and each update re-evaluates the automated reorder triggers rule, so a part with genuinely flat demand can still generate a new purchase order every time the smoothed value ticks a fraction past its old value.
The result is a pipeline that mistakes sampling noise for a trend on every single cycle, and reacts to it every single time.
Resolution
The fix has two independent parts: smooth the input before it becomes a threshold, and require the smoothed signal to clear a band — not just move — before the stored reorder point changes at all.
Before — direct pass-through, no memory of history, no band:
def compute_reorder_point(demand_log: list[float]) -> float:
"""Naive reorder point: whatever demand did yesterday, verbatim."""
if not demand_log:
return 0.0
# Direct function of the latest sample — no smoothing, no dead-band.
return demand_log[-1]
def should_reorder(on_hand_qty: int, demand_log: list[float]) -> bool:
rp = compute_reorder_point(demand_log)
return on_hand_qty <= rp
After — EWMA smoothing, variance-based safety stock, and hysteresis:
from statistics import stdev
class ReorderPointTracker:
"""Stateful reorder point that only moves on a sustained demand shift."""
def __init__(self, alpha: float = 0.3, lead_time_days: int = 5, z_factor: float = 1.65, band_width: float = 20.0):
self.alpha = alpha # EWMA weight given to the newest sample
self.lead_time_days = lead_time_days
self.z_factor = z_factor # safety-stock multiplier (service level)
self.band_width = band_width # dead-band half-width around the stored reorder point
self.smoothed_demand: float | None = None
self.stored_reorder_point: float | None = None
self.recent_demand: list[float] = []
def update(self, latest_demand: float) -> float:
"""Feed one new demand observation; returns the reorder point to act on."""
self.recent_demand.append(latest_demand)
self.recent_demand = self.recent_demand[-30:] # bound the variance window
# 1. Smooth the noisy input instead of trusting the latest sample.
if self.smoothed_demand is None:
self.smoothed_demand = latest_demand
else:
self.smoothed_demand = self.alpha * latest_demand + (1 - self.alpha) * self.smoothed_demand
# 2. Safety stock scales with observed demand variance, not the last sample.
variance_sample = self.recent_demand if len(self.recent_demand) > 1 else [self.smoothed_demand, self.smoothed_demand]
safety_stock = self.z_factor * stdev(variance_sample)
candidate = self.smoothed_demand + safety_stock
# 3. Hysteresis: only move the stored threshold if the candidate clears the band.
if self.stored_reorder_point is None:
self.stored_reorder_point = candidate
elif abs(candidate - self.stored_reorder_point) > self.band_width:
self.stored_reorder_point = candidate
# else: candidate is inside the dead-band — keep the existing threshold.
return self.stored_reorder_point
The ReorderPointTracker never resets between cycles: smoothed_demand carries the EWMA state forward, and stored_reorder_point is only overwritten when the newly computed candidate differs from the currently stored value by more than band_width. A single noisy day nudges the smoothed average slightly but almost never clears the band on its own; a real, multi-cycle shift in demand eventually does, and the threshold updates exactly once instead of every night.
Minimal Reproducible Pipeline
This script runs as-is against a synthetic noisy demand series matching the incident profile. It defines the canonical WorkOrderPayload (with the site-wide SLA fields priority, requested_completion, and escalation_tier) alongside the tracker, and prints a side-by-side comparison of naive versus stabilized reorder decisions.
import logging
import random
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from statistics import stdev
from typing import Dict, List, Optional
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("reorder_stability")
class Priority(str, Enum):
CRITICAL = "critical"
HIGH = "high"
STANDARD = "standard"
PLANNED = "planned"
@dataclass
class WorkOrderPayload:
"""Canonical CMMS work order — SLA fields are mandatory site-wide."""
work_order_id: str
asset_id: str
part_skus: List[str]
required_quantities: Dict[str, int]
location_id: str
priority: Priority = Priority.STANDARD
requested_completion: Optional[datetime] = None
escalation_tier: int = 0
status: str = "open"
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
class ReorderPointTracker:
"""Stateful reorder point that only moves on a sustained demand shift."""
def __init__(self, alpha: float = 0.3, lead_time_days: int = 5, z_factor: float = 1.65, band_width: float = 20.0):
self.alpha = alpha
self.lead_time_days = lead_time_days
self.z_factor = z_factor
self.band_width = band_width
self.smoothed_demand: Optional[float] = None
self.stored_reorder_point: Optional[float] = None
self.recent_demand: List[float] = []
def update(self, latest_demand: float) -> float:
self.recent_demand.append(latest_demand)
self.recent_demand = self.recent_demand[-30:]
if self.smoothed_demand is None:
self.smoothed_demand = latest_demand
else:
self.smoothed_demand = self.alpha * latest_demand + (1 - self.alpha) * self.smoothed_demand
variance_sample = self.recent_demand if len(self.recent_demand) > 1 else [self.smoothed_demand, self.smoothed_demand]
safety_stock = self.z_factor * stdev(variance_sample)
candidate = self.smoothed_demand + safety_stock
if self.stored_reorder_point is None:
self.stored_reorder_point = candidate
elif abs(candidate - self.stored_reorder_point) > self.band_width:
self.stored_reorder_point = candidate
return self.stored_reorder_point
def naive_reorder_point(demand_log: List[float]) -> float:
return demand_log[-1] if demand_log else 0.0
def simulate_cycles(seed: int = 7, cycles: int = 14) -> List[float]:
"""Reproduce a noisy-but-stable demand series like the incident's BRG-4402."""
rng = random.Random(seed)
base = 68.0
return [max(0.0, base + rng.uniform(-30, 45)) for _ in range(cycles)]
def run_comparison(wo: WorkOrderPayload, on_hand_qty: int) -> None:
demand_series = simulate_cycles()
tracker = ReorderPointTracker()
demand_log: List[float] = []
naive_flips, stable_flips = 0, 0
last_naive_trigger, last_stable_trigger = None, None
for cycle, demand in enumerate(demand_series, start=1):
demand_log.append(demand)
naive_rp = naive_reorder_point(demand_log)
naive_trigger = on_hand_qty <= naive_rp
if last_naive_trigger is not None and naive_trigger != last_naive_trigger:
naive_flips += 1
last_naive_trigger = naive_trigger
stable_rp = tracker.update(demand)
stable_trigger = on_hand_qty <= stable_rp
if last_stable_trigger is not None and stable_trigger != last_stable_trigger:
stable_flips += 1
last_stable_trigger = stable_trigger
logger.info(
"cycle=%02d demand=%.1f naive_rp=%.1f (%s) stable_rp=%.1f (%s)",
cycle, demand, naive_rp, "TRIGGER" if naive_trigger else "ok",
stable_rp, "TRIGGER" if stable_trigger else "ok",
)
logger.info("naive threshold flip-flops: %d — stable threshold flip-flops: %d", naive_flips, stable_flips)
assert stable_flips <= naive_flips, "stabilized reorder point must not thrash more than the naive one"
if __name__ == "__main__":
work_order = WorkOrderPayload(
work_order_id="WO-90114",
asset_id="PUMP-12",
part_skus=["BRG-4402"],
required_quantities={"BRG-4402": 1},
location_id="WH-04",
priority=Priority.STANDARD,
escalation_tier=0,
)
run_comparison(work_order, on_hand_qty=65)
Run it once and the log lines show the naive reorder_point bouncing across a wide range every cycle while stable_rp holds nearly flat, exactly like the incident excerpt. The safety-stock term inside ReorderPointTracker should ultimately be widened whenever supplier lead time sync reports that a supplier’s actual delivery window is drifting longer than the lead_time_days constant assumes — a longer or less predictable lead time needs a wider band, not a twitchier one. Before a stabilized trigger actually cuts a purchase order, gate it against a fresh parts availability checks read so the reorder decision and the on-hand count are never more than one cycle apart.
Prevention Checklist
FAQ
Why not just widen the reorder point formula instead of adding a dead-band?
A wider safety-stock term raises the average threshold but does not stop it from moving every cycle — the noise is still directly propagated, just around a higher baseline. The dead-band is what actually stops the thrash, because it makes “no update” the default outcome unless the smoothed signal has genuinely moved.
What EWMA alpha should I start with?
An alpha between 0.2 and 0.35 works well for daily part-level demand: low enough to absorb single-day spikes, high enough that a real multi-week shift is reflected within a handful of cycles. Tune it against your own demand history rather than trusting a single default — faster-moving consumables can tolerate a higher alpha than slow-moving capital spares.
How wide should the dead-band be?
Size it relative to the demand’s own standard deviation — a band narrower than roughly one standard deviation of daily demand will still let ordinary noise clear it and defeats the purpose. The example above uses a fixed band_width for clarity; a production tracker should scale it from the same stdev calculation used for safety stock.
Will hysteresis make the system slow to react to a genuine demand spike?
It adds a small, bounded lag — a real trend still clears the band within a few cycles because the EWMA keeps moving toward it every time. That lag is the intended trade-off: a few cycles of delay on a genuine shift is far cheaper than a duplicate purchase order every time demand samples wobble.
Related
This failure sits inside inventory threshold optimization alongside the broader read-and-reconcile flow in asset lookup and inventory synchronization; a stabilized threshold feeds directly into automated reorder triggers, should be widened in step with supplier lead time sync, and should only fire against a confirmed parts availability checks read.
Part of: Inventory Threshold Optimization.