stock_forecast.py 11 KB

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