stock_forecast.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. """Reorder-point forecast for inventory SKUs (#2955).
  2. This is the arithmetic ``frontend/src/components/ForecastPanel.tsx`` runs in the
  3. browser, as pure functions, so something that is not a browser can decide when
  4. a SKU has reached its reorder point or is about to break. ``test_stock_forecast_2955.py``
  5. pins the two to the same numbers on the same inputs.
  6. The pieces, in the order the panel applies them:
  7. * Spools are grouped by SKU -- ``(material, subtype, brand, color_name)``.
  8. Archived spools are not stock, but one archived in the last
  9. ``ARCHIVED_RATE_WINDOW_DAYS`` still counts towards how fast the SKU is used:
  10. used-up spools are archived, and leaving out what they took makes a SKU in
  11. weekly use look untouched (#3399). A SKU with no stock left is not forecast.
  12. * The daily rate comes from usage history when there are at least two distinct
  13. days of it (weighted by a 30-day half-life, so recent prints dominate), and
  14. from consumption since the spool baseline over the age of the oldest spool
  15. otherwise.
  16. * ``reorder point = rate * lead time + safety stock``, where the safety stock
  17. is a statistical term (``Z_95 * sigma * sqrt(lead time)``) plus a margin the
  18. user sets in days or grams.
  19. * A *stock break* is "the stock runs out before a replenishment can arrive"; a
  20. *reorder* is "the reorder point has been reached". They are exclusive: a SKU
  21. that is already breaking is not also reported as reorderable.
  22. Nothing here touches the database. The caller supplies spools, their usage
  23. history and the settings, and decides what to do with the answer.
  24. """
  25. from __future__ import annotations
  26. import math
  27. from collections.abc import Mapping, Sequence
  28. from dataclasses import dataclass
  29. from datetime import date, datetime, timezone
  30. # One-sided 95 % z-score, as in the panel.
  31. Z_95 = 1.65
  32. # Prints from this many days ago count half as much as one from today.
  33. _HALF_LIFE_DAYS = 30.0
  34. _LAMBDA = math.log(2) / _HALF_LIFE_DAYS
  35. DEFAULT_SAFETY_MARGIN_VALUE = 14
  36. DEFAULT_SAFETY_MARGIN_UNIT = "days"
  37. # With no measured spread the panel assumes 20 % of the rate, and with no rate at
  38. # all it assumes 5 g/day so a margin given in days still means something.
  39. _FALLBACK_SIGMA_FRACTION = 0.2
  40. _FALLBACK_RATE_G_PER_DAY = 5
  41. _SECONDS_PER_DAY = 86400.0
  42. # An archived spool counts towards the rate for this long after it was archived.
  43. # Older ones would stretch the delta rate's age over years and dilute it.
  44. ARCHIVED_RATE_WINDOW_DAYS = 90
  45. SkuKey = tuple[str, str, str, str]
  46. def sku_key(material: str, subtype: str | None, brand: str | None, color_name: str | None) -> SkuKey:
  47. """The grouping key. ``None`` and an empty string are the same SKU, as in the panel."""
  48. return (material, subtype or "", brand or "", color_name or "")
  49. @dataclass(frozen=True)
  50. class StockSpool:
  51. """The fields of a spool the forecast reads, whichever inventory mode it came from."""
  52. id: int
  53. material: str
  54. subtype: str | None
  55. brand: str | None
  56. color_name: str | None
  57. label_weight: float
  58. weight_used: float
  59. weight_used_baseline: float
  60. created_at: datetime | None
  61. # When the spool was archived (Spoolman: its last use); None while it is stock.
  62. archived_at: datetime | None = None
  63. # True when color_name is not a name the spool carries but the subtype standing in
  64. # for one (Spoolman mode does this so the list has no blank colours). The SKU key
  65. # keeps it, as the panel does; anything shown to a person should not.
  66. color_name_is_synthesized: bool = False
  67. @property
  68. def key(self) -> SkuKey:
  69. return sku_key(self.material, self.subtype, self.brand, self.color_name)
  70. @property
  71. def remaining_g(self) -> float:
  72. return max(0.0, self.label_weight - self.weight_used)
  73. @property
  74. def consumed_g(self) -> float:
  75. """Consumed since the baseline, so "Reset usage to 0" restarts the forecast (#1390)."""
  76. return max(0.0, self.weight_used - self.weight_used_baseline)
  77. @dataclass(frozen=True)
  78. class UsageRecord:
  79. """One consumption event: how much a print took from a spool, and when."""
  80. created_at: datetime
  81. weight_used: float
  82. @dataclass(frozen=True)
  83. class SkuSettings:
  84. """A SKU's user-set overrides. The defaults are the panel's for a SKU with no row."""
  85. lead_time_days: int = 0
  86. safety_margin_value: float = DEFAULT_SAFETY_MARGIN_VALUE
  87. safety_margin_unit: str = DEFAULT_SAFETY_MARGIN_UNIT
  88. alerts_snoozed: bool = False
  89. @dataclass(frozen=True)
  90. class SkuForecast:
  91. key: SkuKey
  92. remaining_g: float
  93. daily_rate_g: float | None
  94. effective_lead_time_days: int
  95. reorder_point_g: float
  96. days_remaining: int | None
  97. days_until_reorder_point: int | None
  98. stock_break_alert: bool
  99. reorder_alert: bool
  100. snoozed: bool
  101. def _utc(moment: datetime) -> datetime:
  102. """A naive timestamp is UTC: that is what the database stores."""
  103. return moment.replace(tzinfo=timezone.utc) if moment.tzinfo is None else moment.astimezone(timezone.utc)
  104. def archived_counts(archived_at: datetime, now: datetime) -> bool:
  105. """Whether a spool archived then still counts towards its SKU's rate."""
  106. return (_utc(now) - _utc(archived_at)).total_seconds() <= ARCHIVED_RATE_WINDOW_DAYS * _SECONDS_PER_DAY
  107. def history_rate(records: Sequence[UsageRecord], now: datetime) -> tuple[float, float] | None:
  108. """Daily consumption rate and its standard deviation from usage history.
  109. Records are summed per UTC calendar day, so concurrent multi-spool prints on
  110. one day count together. Each day after the first gives one observation: the
  111. grams printed that day over the gap since the previous day with any usage,
  112. weighted by ``exp(-lambda * age)``. Returns None with fewer than two
  113. distinct days -- there is no gap to measure -- and the caller falls back to
  114. :func:`delta_rate`.
  115. """
  116. if len(records) < 2:
  117. return None
  118. by_day: dict[date, float] = {}
  119. for record in records:
  120. day = _utc(record.created_at).date()
  121. by_day[day] = by_day.get(day, 0.0) + record.weight_used
  122. if len(by_day) < 2:
  123. return None
  124. now = _utc(now)
  125. days = sorted(by_day.items())
  126. observations: list[tuple[float, float]] = [] # (rate, weight)
  127. for i in range(1, len(days)):
  128. elapsed_days = max((days[i][0] - days[i - 1][0]).days, 1)
  129. midnight = datetime(days[i][0].year, days[i][0].month, days[i][0].day, tzinfo=timezone.utc)
  130. age_days = (now - midnight).total_seconds() / _SECONDS_PER_DAY
  131. observations.append((days[i][1] / elapsed_days, math.exp(-_LAMBDA * age_days)))
  132. total_weight = sum(w for _, w in observations)
  133. if total_weight == 0:
  134. return None
  135. mean = sum(r * w for r, w in observations) / total_weight
  136. variance = sum(w * (r - mean) ** 2 for r, w in observations) / total_weight
  137. return mean, math.sqrt(variance)
  138. def delta_rate(spools: Sequence[StockSpool], now: datetime) -> float | None:
  139. """Daily rate from consumption since baseline over the age of the oldest spool.
  140. The fallback when there is not enough usage history, which is always the
  141. case in Spoolman mode: Spoolman owns the usage there and Bambuddy's own
  142. ``spool_usage_history`` table holds nothing for those spools. Returns None
  143. with nothing consumed, or with under a day of age to divide by.
  144. """
  145. total_used = sum(s.consumed_g for s in spools)
  146. if total_used == 0:
  147. return None
  148. now = _utc(now)
  149. oldest = now
  150. for spool in spools:
  151. if spool.created_at is not None:
  152. created = _utc(spool.created_at)
  153. if created < oldest:
  154. oldest = created
  155. age_days = (now - oldest).total_seconds() / _SECONDS_PER_DAY
  156. if age_days < 1:
  157. return None
  158. return total_used / age_days
  159. def forecast_sku(
  160. spools: Sequence[StockSpool],
  161. history_by_spool: Mapping[int, Sequence[UsageRecord]],
  162. settings: SkuSettings,
  163. global_lead_time_days: int,
  164. now: datetime,
  165. used_up: Sequence[StockSpool] = (),
  166. ) -> SkuForecast:
  167. """Forecast one SKU from its spools. ``spools`` (its stock) must be non-empty
  168. and share a key; ``used_up`` are its recently archived spools, which count
  169. towards the rate only."""
  170. lead_time = max(global_lead_time_days, settings.lead_time_days)
  171. remaining = sum(s.remaining_g for s in spools)
  172. rate_spools = [*spools, *used_up]
  173. # Only history from spools that were never reset: a reset spool's earlier
  174. # events have no anchor and would inflate the rate. A spool with no
  175. # baseline is clean and keeps its records.
  176. history: list[UsageRecord] = []
  177. for spool in rate_spools:
  178. if spool.weight_used_baseline == 0:
  179. history.extend(history_by_spool.get(spool.id, ()))
  180. rate: float | None = None
  181. std_dev: float | None = None
  182. measured = history_rate(history, now)
  183. if measured is not None:
  184. rate, std_dev = measured
  185. else:
  186. rate = delta_rate(rate_spools, now)
  187. sigma = std_dev if std_dev is not None else (rate * _FALLBACK_SIGMA_FRACTION if rate is not None else 0.0)
  188. statistical_safety_g = Z_95 * sigma * math.sqrt(lead_time)
  189. if settings.safety_margin_unit == "g":
  190. margin_g = settings.safety_margin_value
  191. elif rate is not None:
  192. margin_g = rate * settings.safety_margin_value
  193. else:
  194. margin_g = settings.safety_margin_value * _FALLBACK_RATE_G_PER_DAY
  195. safety_stock_g = statistical_safety_g + margin_g
  196. reorder_point_g = rate * lead_time + safety_stock_g if rate is not None else 0.0
  197. days_remaining: int | None = None
  198. days_until_rop: int | None = None
  199. if rate is not None and rate > 0:
  200. days_remaining = math.floor(remaining / rate)
  201. days_until_rop = math.floor((remaining - reorder_point_g) / rate)
  202. stock_break = days_remaining is not None and lead_time > 0 and days_remaining <= lead_time
  203. reorder = not stock_break and days_until_rop is not None and days_until_rop <= 0
  204. return SkuForecast(
  205. key=spools[0].key,
  206. remaining_g=remaining,
  207. daily_rate_g=rate,
  208. effective_lead_time_days=lead_time,
  209. reorder_point_g=reorder_point_g,
  210. days_remaining=days_remaining,
  211. days_until_reorder_point=days_until_rop,
  212. stock_break_alert=stock_break,
  213. reorder_alert=reorder,
  214. snoozed=settings.alerts_snoozed,
  215. )
  216. ForecastMap = dict[SkuKey, tuple[SkuForecast, StockSpool]]
  217. """Each SKU's forecast with the first spool of its group, which names it."""
  218. def forecast_all(
  219. spools: Sequence[StockSpool],
  220. history_by_spool: Mapping[int, Sequence[UsageRecord]],
  221. sku_settings: Mapping[SkuKey, SkuSettings],
  222. global_lead_time_days: int,
  223. now: datetime,
  224. ) -> ForecastMap:
  225. """Forecast every SKU that has stock in ``spools``.
  226. Returns each SKU's forecast with the first stock spool of its group, which is
  227. what a caller needs to name the SKU (material, subtype, brand, colour)
  228. without a second lookup. A spool with ``archived_at`` set is not stock; the
  229. caller sets it however its inventory mode records archiving. One archived
  230. within ``ARCHIVED_RATE_WINDOW_DAYS`` counts towards its SKU's rate.
  231. A colour-specific SKU with no settings row of its own falls back to the
  232. colourless row, which is where settings saved before colour became part of
  233. the key still live.
  234. """
  235. groups: dict[SkuKey, list[StockSpool]] = {}
  236. used_up: dict[SkuKey, list[StockSpool]] = {}
  237. for spool in spools:
  238. if spool.archived_at is None:
  239. groups.setdefault(spool.key, []).append(spool)
  240. elif archived_counts(spool.archived_at, now):
  241. used_up.setdefault(spool.key, []).append(spool)
  242. out: ForecastMap = {}
  243. for key, members in groups.items():
  244. settings = sku_settings.get(key)
  245. if settings is None and key[3] != "":
  246. settings = sku_settings.get((key[0], key[1], key[2], ""))
  247. out[key] = (
  248. forecast_sku(
  249. members,
  250. history_by_spool,
  251. settings or SkuSettings(),
  252. global_lead_time_days,
  253. now,
  254. used_up=used_up.get(key, ()),
  255. ),
  256. members[0],
  257. )
  258. return out