Files
bambuddy/backend/app/services/stock_forecast.py
T

285 lines
10 KiB
Python

"""Reorder-point forecast for inventory SKUs (#2955).
This is the arithmetic ``frontend/src/components/ForecastPanel.tsx`` runs in the
browser, as pure functions, so something that is not a browser can decide when
a SKU has reached its reorder point or is about to break. ``test_stock_forecast_2955.py``
pins the two to the same numbers on the same inputs.
The pieces, in the order the panel applies them:
* Spools are grouped by SKU -- ``(material, subtype, brand, color_name)``.
* The daily rate comes from usage history when there are at least two distinct
days of it (weighted by a 30-day half-life, so recent prints dominate), and
from consumption since the spool baseline over the age of the oldest spool
otherwise.
* ``reorder point = rate * lead time + safety stock``, where the safety stock
is a statistical term (``Z_95 * sigma * sqrt(lead time)``) plus a margin the
user sets in days or grams.
* A *stock break* is "the stock runs out before a replenishment can arrive"; a
*reorder* is "the reorder point has been reached". They are exclusive: a SKU
that is already breaking is not also reported as reorderable.
Nothing here touches the database. The caller supplies spools, their usage
history and the settings, and decides what to do with the answer.
"""
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import date, datetime, timezone
# One-sided 95 % z-score, as in the panel.
Z_95 = 1.65
# Prints from this many days ago count half as much as one from today.
_HALF_LIFE_DAYS = 30.0
_LAMBDA = math.log(2) / _HALF_LIFE_DAYS
DEFAULT_SAFETY_MARGIN_VALUE = 14
DEFAULT_SAFETY_MARGIN_UNIT = "days"
# With no measured spread the panel assumes 20 % of the rate, and with no rate at
# all it assumes 5 g/day so a margin given in days still means something.
_FALLBACK_SIGMA_FRACTION = 0.2
_FALLBACK_RATE_G_PER_DAY = 5
_SECONDS_PER_DAY = 86400.0
SkuKey = tuple[str, str, str, str]
def sku_key(material: str, subtype: str | None, brand: str | None, color_name: str | None) -> SkuKey:
"""The grouping key. ``None`` and an empty string are the same SKU, as in the panel."""
return (material, subtype or "", brand or "", color_name or "")
@dataclass(frozen=True)
class StockSpool:
"""The fields of a spool the forecast reads, whichever inventory mode it came from."""
id: int
material: str
subtype: str | None
brand: str | None
color_name: str | None
label_weight: float
weight_used: float
weight_used_baseline: float
created_at: datetime | None
# True when color_name is not a name the spool carries but the subtype standing in
# for one (Spoolman mode does this so the list has no blank colours). The SKU key
# keeps it, as the panel does; anything shown to a person should not.
color_name_is_synthesized: bool = False
@property
def key(self) -> SkuKey:
return sku_key(self.material, self.subtype, self.brand, self.color_name)
@property
def remaining_g(self) -> float:
return max(0.0, self.label_weight - self.weight_used)
@property
def consumed_g(self) -> float:
"""Consumed since the baseline, so "Reset usage to 0" restarts the forecast (#1390)."""
return max(0.0, self.weight_used - self.weight_used_baseline)
@dataclass(frozen=True)
class UsageRecord:
"""One consumption event: how much a print took from a spool, and when."""
created_at: datetime
weight_used: float
@dataclass(frozen=True)
class SkuSettings:
"""A SKU's user-set overrides. The defaults are the panel's for a SKU with no row."""
lead_time_days: int = 0
safety_margin_value: float = DEFAULT_SAFETY_MARGIN_VALUE
safety_margin_unit: str = DEFAULT_SAFETY_MARGIN_UNIT
alerts_snoozed: bool = False
@dataclass(frozen=True)
class SkuForecast:
key: SkuKey
remaining_g: float
daily_rate_g: float | None
effective_lead_time_days: int
reorder_point_g: float
days_remaining: int | None
days_until_reorder_point: int | None
stock_break_alert: bool
reorder_alert: bool
snoozed: bool
def _utc(moment: datetime) -> datetime:
"""A naive timestamp is UTC: that is what the database stores."""
return moment.replace(tzinfo=timezone.utc) if moment.tzinfo is None else moment.astimezone(timezone.utc)
def history_rate(records: Sequence[UsageRecord], now: datetime) -> tuple[float, float] | None:
"""Daily consumption rate and its standard deviation from usage history.
Records are summed per UTC calendar day, so concurrent multi-spool prints on
one day count together. Each day after the first gives one observation: the
grams printed that day over the gap since the previous day with any usage,
weighted by ``exp(-lambda * age)``. Returns None with fewer than two
distinct days -- there is no gap to measure -- and the caller falls back to
:func:`delta_rate`.
"""
if len(records) < 2:
return None
by_day: dict[date, float] = {}
for record in records:
day = _utc(record.created_at).date()
by_day[day] = by_day.get(day, 0.0) + record.weight_used
if len(by_day) < 2:
return None
now = _utc(now)
days = sorted(by_day.items())
observations: list[tuple[float, float]] = [] # (rate, weight)
for i in range(1, len(days)):
elapsed_days = max((days[i][0] - days[i - 1][0]).days, 1)
midnight = datetime(days[i][0].year, days[i][0].month, days[i][0].day, tzinfo=timezone.utc)
age_days = (now - midnight).total_seconds() / _SECONDS_PER_DAY
observations.append((days[i][1] / elapsed_days, math.exp(-_LAMBDA * age_days)))
total_weight = sum(w for _, w in observations)
if total_weight == 0:
return None
mean = sum(r * w for r, w in observations) / total_weight
variance = sum(w * (r - mean) ** 2 for r, w in observations) / total_weight
return mean, math.sqrt(variance)
def delta_rate(spools: Sequence[StockSpool], now: datetime) -> float | None:
"""Daily rate from consumption since baseline over the age of the oldest spool.
The fallback when there is not enough usage history, which is always the
case in Spoolman mode: Spoolman owns the usage there and Bambuddy's own
``spool_usage_history`` table holds nothing for those spools. Returns None
with nothing consumed, or with under a day of age to divide by.
"""
total_used = sum(s.consumed_g for s in spools)
if total_used == 0:
return None
now = _utc(now)
oldest = now
for spool in spools:
if spool.created_at is not None:
created = _utc(spool.created_at)
if created < oldest:
oldest = created
age_days = (now - oldest).total_seconds() / _SECONDS_PER_DAY
if age_days < 1:
return None
return total_used / age_days
def forecast_sku(
spools: Sequence[StockSpool],
history_by_spool: Mapping[int, Sequence[UsageRecord]],
settings: SkuSettings,
global_lead_time_days: int,
now: datetime,
) -> SkuForecast:
"""Forecast one SKU from its spools. ``spools`` must be non-empty and share a key."""
lead_time = max(global_lead_time_days, settings.lead_time_days)
remaining = sum(s.remaining_g for s in spools)
# Only history from spools that were never reset: a reset spool's earlier
# events have no anchor and would inflate the rate. A spool with no
# baseline is clean and keeps its records.
history: list[UsageRecord] = []
for spool in spools:
if spool.weight_used_baseline == 0:
history.extend(history_by_spool.get(spool.id, ()))
rate: float | None = None
std_dev: float | None = None
measured = history_rate(history, now)
if measured is not None:
rate, std_dev = measured
else:
rate = delta_rate(spools, now)
sigma = std_dev if std_dev is not None else (rate * _FALLBACK_SIGMA_FRACTION if rate is not None else 0.0)
statistical_safety_g = Z_95 * sigma * math.sqrt(lead_time)
if settings.safety_margin_unit == "g":
margin_g = settings.safety_margin_value
elif rate is not None:
margin_g = rate * settings.safety_margin_value
else:
margin_g = settings.safety_margin_value * _FALLBACK_RATE_G_PER_DAY
safety_stock_g = statistical_safety_g + margin_g
reorder_point_g = rate * lead_time + safety_stock_g if rate is not None else 0.0
days_remaining: int | None = None
days_until_rop: int | None = None
if rate is not None and rate > 0:
days_remaining = math.floor(remaining / rate)
days_until_rop = math.floor((remaining - reorder_point_g) / rate)
stock_break = days_remaining is not None and lead_time > 0 and days_remaining <= lead_time
reorder = not stock_break and days_until_rop is not None and days_until_rop <= 0
return SkuForecast(
key=spools[0].key,
remaining_g=remaining,
daily_rate_g=rate,
effective_lead_time_days=lead_time,
reorder_point_g=reorder_point_g,
days_remaining=days_remaining,
days_until_reorder_point=days_until_rop,
stock_break_alert=stock_break,
reorder_alert=reorder,
snoozed=settings.alerts_snoozed,
)
ForecastMap = dict[SkuKey, tuple[SkuForecast, StockSpool]]
"""Each SKU's forecast with the first spool of its group, which names it."""
def forecast_all(
spools: Sequence[StockSpool],
history_by_spool: Mapping[int, Sequence[UsageRecord]],
sku_settings: Mapping[SkuKey, SkuSettings],
global_lead_time_days: int,
now: datetime,
) -> ForecastMap:
"""Forecast every SKU present in ``spools``.
Returns each SKU's forecast with the first spool of its group, which is what
a caller needs to name the SKU (material, subtype, brand, colour) without a
second lookup. The caller passes only spools that count as stock -- archived
ones excluded -- because what is archived differs by inventory mode.
A colour-specific SKU with no settings row of its own falls back to the
colourless row, which is where settings saved before colour became part of
the key still live.
"""
groups: dict[SkuKey, list[StockSpool]] = {}
for spool in spools:
groups.setdefault(spool.key, []).append(spool)
out: ForecastMap = {}
for key, members in groups.items():
settings = sku_settings.get(key)
if settings is None and key[3] != "":
settings = sku_settings.get((key[0], key[1], key[2], ""))
out[key] = (
forecast_sku(members, history_by_spool, settings or SkuSettings(), global_lead_time_days, now),
members[0],
)
return out