restockiq / eval /test_inventory_math.py
RV302001's picture
RestockIQ v1: M5 quantile forecasting + reorder decision dashboard
3d4c9ec
Raw
History Blame Contribute Delete
5.12 kB
"""Hand-checked unit tests for the safety-stock / reorder-point math.
Every expected value below was computed by hand from the formulas in
app/inventory_math.py — see the arithmetic in each test's comments.
"""
import math
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.inventory_math import (
Z_90,
demand_std_from_quantiles,
recommend,
z_from_service_level,
)
class TestZFromServiceLevel:
def test_median_is_zero(self):
assert z_from_service_level(0.5) == pytest.approx(0.0, abs=1e-8)
def test_95_percent(self):
# Phi^-1(0.95) = 1.6449 (standard normal table)
assert z_from_service_level(0.95) == pytest.approx(1.6449, abs=1e-3)
def test_90_percent_matches_Z_90_constant(self):
assert z_from_service_level(0.90) == pytest.approx(Z_90, abs=1e-3)
def test_99_percent(self):
# Phi^-1(0.99) = 2.3263
assert z_from_service_level(0.99) == pytest.approx(2.3263, abs=1e-3)
def test_tail_region(self):
# 0.999 falls in the approximation's upper tail branch; Phi^-1(0.999) = 3.0902
assert z_from_service_level(0.999) == pytest.approx(3.0902, abs=1e-3)
@pytest.mark.parametrize("bad", [0.0, 1.0, -0.5, 1.5])
def test_rejects_out_of_range(self, bad):
with pytest.raises(ValueError):
z_from_service_level(bad)
class TestDemandStd:
def test_constant_spread(self):
# spread = 12.816 - 10 = 2.816 every day; 2.816 / 1.2816 = 2.1972...
p50 = [10.0, 10.0, 10.0]
p90 = [12.816, 12.816, 12.816]
assert demand_std_from_quantiles(p50, p90) == pytest.approx(2.816 / 1.2816, rel=1e-9)
def test_zero_spread_means_zero_std(self):
assert demand_std_from_quantiles([5.0, 5.0], [5.0, 5.0]) == 0.0
def test_negative_spread_clamped_to_zero(self):
# crossed quantiles (can happen with independently trained models) never
# produce a negative std
assert demand_std_from_quantiles([10.0], [8.0]) == 0.0
def test_rejects_length_mismatch(self):
with pytest.raises(ValueError):
demand_std_from_quantiles([1.0, 2.0], [1.0])
class TestRecommend:
def test_hand_checked_full_example(self):
# 7-day horizon, p50 = 10/day, p90 = 12.5632/day everywhere:
# avg_daily_demand = 10
# demand_std = (12.5632 - 10) / 1.2816 = 2.0
# z(0.95) = 1.6449
# safety_stock = 1.6449 * 2.0 * sqrt(7) = 8.7040...
# reorder_point = 10 * 7 + 8.7040 = 78.7040...
# order_qty (inventory 20) = 78.7040 - 20 = 58.7040...
p50 = [10.0] * 7
p90 = [12.5632] * 7
rec = recommend(p50, p90, current_inventory=20.0, service_level=0.95,
lead_time_days=7)
expected_ss = 1.6449 * 2.0 * math.sqrt(7)
assert rec.avg_daily_demand == pytest.approx(10.0)
assert rec.demand_std == pytest.approx(2.0, rel=1e-9)
assert rec.safety_stock == pytest.approx(expected_ss, abs=2e-3)
assert rec.reorder_point == pytest.approx(70.0 + expected_ss, abs=2e-3)
assert rec.suggested_order_qty == pytest.approx(50.0 + expected_ss, abs=2e-3)
def test_zero_uncertainty_reduces_to_deterministic_reorder(self):
# p90 == p50 -> no safety stock; reorder point is just demand * lead time
rec = recommend([4.0] * 7, [4.0] * 7, current_inventory=0.0,
service_level=0.95, lead_time_days=7)
assert rec.safety_stock == 0.0
assert rec.reorder_point == pytest.approx(28.0)
assert rec.suggested_order_qty == pytest.approx(28.0)
def test_order_qty_floors_at_zero(self):
# plenty of inventory -> no order, never negative
rec = recommend([1.0] * 7, [1.5] * 7, current_inventory=1000.0)
assert rec.suggested_order_qty == 0.0
def test_only_lead_time_days_are_used(self):
# extra forecast days beyond the lead time must not affect the answer
base = recommend([10.0] * 7, [12.0] * 7, lead_time_days=7)
extended = recommend([10.0] * 7 + [999.0] * 21, [12.0] * 7 + [999.0] * 21,
lead_time_days=7)
assert base.reorder_point == extended.reorder_point
def test_lead_time_scaling(self):
# lead time 4 with constant demand: reorder = 5*4 + z*std*sqrt(4) = 20 + 2*z*std
# p90-p50 = 1.2816 -> std = 1.0 exactly; z(0.90) = 1.2816
rec = recommend([5.0] * 4, [6.2816] * 4, current_inventory=0.0,
service_level=0.90, lead_time_days=4)
assert rec.demand_std == pytest.approx(1.0, rel=1e-9)
assert rec.safety_stock == pytest.approx(1.2816 * 1.0 * 2.0, abs=2e-3)
assert rec.reorder_point == pytest.approx(20.0 + 2.5632, abs=2e-3)
def test_rejects_short_forecast(self):
with pytest.raises(ValueError):
recommend([1.0] * 3, [2.0] * 3, lead_time_days=7)
def test_rejects_bad_lead_time(self):
with pytest.raises(ValueError):
recommend([1.0] * 7, [2.0] * 7, lead_time_days=0)