stock_forecast.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  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. otherwise from the consumption since each spool's baseline, spread over the
  15. time the spool was in use and counted over the last
  16. ``ARCHIVED_RATE_WINDOW_DAYS`` (#3407).
  17. * ``reorder point = rate * lead time + safety stock``, where the safety stock
  18. is a statistical term (``Z_95 * sigma * sqrt(lead time)``) plus a margin the
  19. user sets in days or grams.
  20. * A *stock break* is "the stock runs out before a replenishment can arrive"; a
  21. *reorder* is "the reorder point has been reached". They are exclusive: a SKU
  22. that is already breaking is not also reported as reorderable.
  23. Nothing here touches the database. The caller supplies spools, their usage
  24. history and the settings, and decides what to do with the answer.
  25. """
  26. from __future__ import annotations
  27. import math
  28. from collections.abc import Mapping, Sequence
  29. from dataclasses import dataclass
  30. from datetime import date, datetime, timedelta, timezone
  31. # One-sided 95 % z-score, as in the panel.
  32. Z_95 = 1.65
  33. # Prints from this many days ago count half as much as one from today.
  34. _HALF_LIFE_DAYS = 30.0
  35. _LAMBDA = math.log(2) / _HALF_LIFE_DAYS
  36. DEFAULT_SAFETY_MARGIN_VALUE = 14
  37. DEFAULT_SAFETY_MARGIN_UNIT = "days"
  38. # With no measured spread the panel assumes 20 % of the rate, and with no rate at
  39. # all it assumes 5 g/day so a margin given in days still means something.
  40. _FALLBACK_SIGMA_FRACTION = 0.2
  41. _FALLBACK_RATE_G_PER_DAY = 5
  42. _SECONDS_PER_DAY = 86400.0
  43. # An archived spool counts towards the rate for this long after it was archived.
  44. # Older ones would stretch the delta rate's age over years and dilute it.
  45. ARCHIVED_RATE_WINDOW_DAYS = 90
  46. SkuKey = tuple[str, str, str, str]
  47. def sku_key(material: str, subtype: str | None, brand: str | None, color_name: str | None) -> SkuKey:
  48. """The grouping key. ``None`` and an empty string are the same SKU, as in the panel."""
  49. return (material, subtype or "", brand or "", color_name or "")
  50. @dataclass(frozen=True)
  51. class StockSpool:
  52. """The fields of a spool the forecast reads, whichever inventory mode it came from."""
  53. id: int
  54. material: str
  55. subtype: str | None
  56. brand: str | None
  57. color_name: str | None
  58. label_weight: float
  59. weight_used: float
  60. weight_used_baseline: float
  61. created_at: datetime | None
  62. # When the spool was archived (Spoolman: its last use); None while it is stock.
  63. archived_at: datetime | None = None
  64. # When it was first and last used. Spoolman records both; the built-in
  65. # inventory only the last, and its first use is its earliest usage record.
  66. first_used_at: datetime | None = None
  67. last_used_at: datetime | None = None
  68. # True when color_name is not a name the spool carries but the subtype standing in
  69. # for one (Spoolman mode does this so the list has no blank colours). The SKU key
  70. # keeps it, as the panel does; anything shown to a person should not.
  71. color_name_is_synthesized: bool = False
  72. @property
  73. def key(self) -> SkuKey:
  74. return sku_key(self.material, self.subtype, self.brand, self.color_name)
  75. @property
  76. def remaining_g(self) -> float:
  77. return max(0.0, self.label_weight - self.weight_used)
  78. @property
  79. def consumed_g(self) -> float:
  80. """Consumed since the baseline, so "Reset usage to 0" restarts the forecast (#1390)."""
  81. return max(0.0, self.weight_used - self.weight_used_baseline)
  82. @dataclass(frozen=True)
  83. class UsageRecord:
  84. """One consumption event: how much a print took from a spool, and when."""
  85. created_at: datetime
  86. weight_used: float
  87. @dataclass(frozen=True)
  88. class SkuSettings:
  89. """A SKU's user-set overrides. The defaults are the panel's for a SKU with no row."""
  90. lead_time_days: int = 0
  91. safety_margin_value: float = DEFAULT_SAFETY_MARGIN_VALUE
  92. safety_margin_unit: str = DEFAULT_SAFETY_MARGIN_UNIT
  93. alerts_snoozed: bool = False
  94. @dataclass(frozen=True)
  95. class SkuForecast:
  96. key: SkuKey
  97. remaining_g: float
  98. daily_rate_g: float | None
  99. effective_lead_time_days: int
  100. reorder_point_g: float
  101. days_remaining: int | None
  102. days_until_reorder_point: int | None
  103. stock_break_alert: bool
  104. reorder_alert: bool
  105. snoozed: bool
  106. def _utc(moment: datetime) -> datetime:
  107. """A naive timestamp is UTC: that is what the database stores."""
  108. return moment.replace(tzinfo=timezone.utc) if moment.tzinfo is None else moment.astimezone(timezone.utc)
  109. def archived_counts(archived_at: datetime, now: datetime) -> bool:
  110. """Whether a spool archived then still counts towards its SKU's rate."""
  111. return (_utc(now) - _utc(archived_at)).total_seconds() <= ARCHIVED_RATE_WINDOW_DAYS * _SECONDS_PER_DAY
  112. def history_rate(records: Sequence[UsageRecord], now: datetime) -> tuple[float, float] | None:
  113. """Daily consumption rate and its standard deviation from usage history.
  114. Records are summed per UTC calendar day, so concurrent multi-spool prints on
  115. one day count together. Each day after the first gives one observation: the
  116. grams printed that day over the gap since the previous day with any usage,
  117. weighted by ``exp(-lambda * age)``. Returns None with fewer than two
  118. distinct days -- there is no gap to measure -- and the caller falls back to
  119. :func:`delta_rate`.
  120. """
  121. if len(records) < 2:
  122. return None
  123. by_day: dict[date, float] = {}
  124. for record in records:
  125. day = _utc(record.created_at).date()
  126. by_day[day] = by_day.get(day, 0.0) + record.weight_used
  127. if len(by_day) < 2:
  128. return None
  129. now = _utc(now)
  130. days = sorted(by_day.items())
  131. observations: list[tuple[float, float]] = [] # (rate, weight)
  132. for i in range(1, len(days)):
  133. elapsed_days = max((days[i][0] - days[i - 1][0]).days, 1)
  134. midnight = datetime(days[i][0].year, days[i][0].month, days[i][0].day, tzinfo=timezone.utc)
  135. age_days = (now - midnight).total_seconds() / _SECONDS_PER_DAY
  136. observations.append((days[i][1] / elapsed_days, math.exp(-_LAMBDA * age_days)))
  137. total_weight = sum(w for _, w in observations)
  138. if total_weight == 0:
  139. return None
  140. mean = sum(r * w for r, w in observations) / total_weight
  141. variance = sum(w * (r - mean) ** 2 for r, w in observations) / total_weight
  142. return mean, math.sqrt(variance)
  143. def own_records(spool: StockSpool, records: Sequence[UsageRecord]) -> list[UsageRecord]:
  144. """The records of this spool, not of an earlier one that had its id.
  145. Spoolman can give a deleted spool's id to a new one -- deleted in
  146. Spoolman's own UI, or a Spoolman instance replaced -- and the usage rows
  147. Bambuddy recorded under it stay. A row from before the spool was created
  148. is not its own (#3407). Built-in ids are never handed on.
  149. """
  150. if spool.created_at is None:
  151. return list(records)
  152. created = _utc(spool.created_at)
  153. return [r for r in records if _utc(r.created_at) >= created]
  154. def delta_rate(
  155. spools: Sequence[StockSpool],
  156. now: datetime,
  157. first_use: Mapping[int, datetime] | None = None,
  158. used_before: bool = False,
  159. ) -> float | None:
  160. """Daily rate from each spool's consumption since its baseline.
  161. The fallback when there is not enough usage history: a spool's total is
  162. all there is, so it is spread evenly over the time the spool was in use,
  163. from its first use (``first_used_at``, else ``first_use[id]``, else its
  164. creation) to its last (else its archiving, else now). What falls inside the
  165. last ``ARCHIVED_RATE_WINDOW_DAYS`` is divided by the days of that window,
  166. which starts no earlier than the SKU's first use; ``used_before`` says the
  167. SKU has spools archived before the window, so it was in use all along. The
  168. grams and the days
  169. then cover the same span: before #3407 the days ran from the oldest
  170. spool's creation, so shelf time counted as use, and a spool used up
  171. inside them but last used before the window was left out whole.
  172. Returns None with nothing consumed, or with under a day to divide by.
  173. """
  174. now = _utc(now)
  175. window_start = now - timedelta(days=ARCHIVED_RATE_WINDOW_DAYS)
  176. spans: list[tuple[float, datetime, datetime]] = []
  177. for spool in spools:
  178. consumed = spool.consumed_g
  179. if consumed <= 0:
  180. continue
  181. end = spool.last_used_at or spool.archived_at
  182. end = min(_utc(end), now) if end is not None else now
  183. start = spool.first_used_at or (first_use or {}).get(spool.id) or spool.created_at
  184. start = min(_utc(start), end) if start is not None else end
  185. spans.append((consumed, start, end))
  186. if not spans:
  187. return None
  188. observed_from = window_start if used_before else max(window_start, min(start for _, start, _ in spans))
  189. days = (now - observed_from).total_seconds() / _SECONDS_PER_DAY
  190. if days < 1:
  191. return None
  192. grams = 0.0
  193. for consumed, start, end in spans:
  194. length = (end - start).total_seconds()
  195. if length < _SECONDS_PER_DAY:
  196. # Used up within a day: all of it at its end.
  197. grams += consumed if end >= observed_from else 0.0
  198. continue
  199. inside = (end - max(start, observed_from)).total_seconds()
  200. if inside > 0:
  201. grams += consumed * inside / length
  202. return grams / days
  203. def forecast_sku(
  204. spools: Sequence[StockSpool],
  205. history_by_spool: Mapping[int, Sequence[UsageRecord]],
  206. settings: SkuSettings,
  207. global_lead_time_days: int,
  208. now: datetime,
  209. used_up: Sequence[StockSpool] = (),
  210. used_before: bool = False,
  211. ) -> SkuForecast:
  212. """Forecast one SKU from its spools. ``spools`` (its stock) must be non-empty
  213. and share a key; ``used_up`` are its recently archived spools, which count
  214. towards the rate only; ``used_before`` says it has spools archived longer
  215. ago than that."""
  216. lead_time = max(global_lead_time_days, settings.lead_time_days)
  217. remaining = sum(s.remaining_g for s in spools)
  218. rate_spools = [*spools, *used_up]
  219. own = {spool.id: list(history_by_spool.get(spool.id, ())) for spool in rate_spools}
  220. # Only history from spools that were never reset: a reset spool's earlier
  221. # events have no anchor and would inflate the rate. A spool with no
  222. # baseline is clean and keeps its records.
  223. history: list[UsageRecord] = []
  224. for spool in rate_spools:
  225. if spool.weight_used_baseline == 0:
  226. history.extend(own[spool.id])
  227. rate: float | None = None
  228. std_dev: float | None = None
  229. measured = history_rate(history, now)
  230. if measured is not None:
  231. rate, std_dev = measured
  232. else:
  233. # A built-in spool's first use is its earliest record, kept or not:
  234. # a reset drops records from the rate, not from when use began.
  235. first_use = {spool.id: min(r.created_at for r in own[spool.id]) for spool in rate_spools if own[spool.id]}
  236. rate = delta_rate(rate_spools, now, first_use, used_before)
  237. sigma = std_dev if std_dev is not None else (rate * _FALLBACK_SIGMA_FRACTION if rate is not None else 0.0)
  238. statistical_safety_g = Z_95 * sigma * math.sqrt(lead_time)
  239. if settings.safety_margin_unit == "g":
  240. margin_g = settings.safety_margin_value
  241. elif rate is not None:
  242. margin_g = rate * settings.safety_margin_value
  243. else:
  244. margin_g = settings.safety_margin_value * _FALLBACK_RATE_G_PER_DAY
  245. safety_stock_g = statistical_safety_g + margin_g
  246. reorder_point_g = rate * lead_time + safety_stock_g if rate is not None else 0.0
  247. days_remaining: int | None = None
  248. days_until_rop: int | None = None
  249. if rate is not None and rate > 0:
  250. days_remaining = math.floor(remaining / rate)
  251. days_until_rop = math.floor((remaining - reorder_point_g) / rate)
  252. stock_break = days_remaining is not None and lead_time > 0 and days_remaining <= lead_time
  253. reorder = not stock_break and days_until_rop is not None and days_until_rop <= 0
  254. return SkuForecast(
  255. key=spools[0].key,
  256. remaining_g=remaining,
  257. daily_rate_g=rate,
  258. effective_lead_time_days=lead_time,
  259. reorder_point_g=reorder_point_g,
  260. days_remaining=days_remaining,
  261. days_until_reorder_point=days_until_rop,
  262. stock_break_alert=stock_break,
  263. reorder_alert=reorder,
  264. snoozed=settings.alerts_snoozed,
  265. )
  266. ForecastMap = dict[SkuKey, tuple[SkuForecast, StockSpool]]
  267. """Each SKU's forecast with the first spool of its group, which names it."""
  268. def forecast_all(
  269. spools: Sequence[StockSpool],
  270. history_by_spool: Mapping[int, Sequence[UsageRecord]],
  271. sku_settings: Mapping[SkuKey, SkuSettings],
  272. global_lead_time_days: int,
  273. now: datetime,
  274. ) -> ForecastMap:
  275. """Forecast every SKU that has stock in ``spools``.
  276. Returns each SKU's forecast with the first stock spool of its group, which is
  277. what a caller needs to name the SKU (material, subtype, brand, colour)
  278. without a second lookup. A spool with ``archived_at`` set is not stock; the
  279. caller sets it however its inventory mode records archiving. One archived
  280. within ``ARCHIVED_RATE_WINDOW_DAYS`` counts towards its SKU's rate.
  281. A colour-specific SKU with no settings row of its own falls back to the
  282. colourless row, which is where settings saved before colour became part of
  283. the key still live.
  284. """
  285. groups: dict[SkuKey, list[StockSpool]] = {}
  286. used_up: dict[SkuKey, list[StockSpool]] = {}
  287. used_before: set[SkuKey] = set()
  288. for spool in spools:
  289. if spool.archived_at is None:
  290. groups.setdefault(spool.key, []).append(spool)
  291. elif archived_counts(spool.archived_at, now):
  292. used_up.setdefault(spool.key, []).append(spool)
  293. elif spool.weight_used > 0:
  294. # Only a spool that was used says the SKU was in use before the window.
  295. used_before.add(spool.key)
  296. out: ForecastMap = {}
  297. for key, members in groups.items():
  298. settings = sku_settings.get(key)
  299. if settings is None and key[3] != "":
  300. settings = sku_settings.get((key[0], key[1], key[2], ""))
  301. out[key] = (
  302. forecast_sku(
  303. members,
  304. history_by_spool,
  305. settings or SkuSettings(),
  306. global_lead_time_days,
  307. now,
  308. used_up=used_up.get(key, ()),
  309. used_before=key in used_before,
  310. ),
  311. members[0],
  312. )
  313. return out