walopy 0.2.7__tar.gz → 0.2.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {walopy-0.2.7/src/walopy.egg-info → walopy-0.2.8}/PKG-INFO +1 -1
  2. {walopy-0.2.7 → walopy-0.2.8}/pyproject.toml +1 -1
  3. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/__init__.py +37 -0
  4. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/inventory.py +519 -0
  5. walopy-0.2.8/src/walopy/project.py +348 -0
  6. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/reliability.py +204 -0
  7. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/scheduling.py +154 -0
  8. {walopy-0.2.7 → walopy-0.2.8/src/walopy.egg-info}/PKG-INFO +1 -1
  9. {walopy-0.2.7 → walopy-0.2.8}/src/walopy.egg-info/SOURCES.txt +7 -1
  10. walopy-0.2.8/tests/test_abc_xyz.py +179 -0
  11. walopy-0.2.8/tests/test_cpm_pert.py +165 -0
  12. walopy-0.2.8/tests/test_mrp.py +90 -0
  13. walopy-0.2.8/tests/test_neh_flowshop.py +111 -0
  14. walopy-0.2.8/tests/test_weibull.py +108 -0
  15. {walopy-0.2.7 → walopy-0.2.8}/LICENSE +0 -0
  16. {walopy-0.2.7 → walopy-0.2.8}/README.md +0 -0
  17. {walopy-0.2.7 → walopy-0.2.8}/setup.cfg +0 -0
  18. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/__main__.py +0 -0
  19. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/_utils.py +0 -0
  20. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/advanced.py +0 -0
  21. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/bottleneck.py +0 -0
  22. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/fitting.py +0 -0
  23. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/kpi.py +0 -0
  24. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/network.py +0 -0
  25. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/operations.py +0 -0
  26. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/plotting.py +0 -0
  27. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/queuing.py +0 -0
  28. {walopy-0.2.7 → walopy-0.2.8}/src/walopy/solver.py +0 -0
  29. {walopy-0.2.7 → walopy-0.2.8}/src/walopy.egg-info/dependency_links.txt +0 -0
  30. {walopy-0.2.7 → walopy-0.2.8}/src/walopy.egg-info/requires.txt +0 -0
  31. {walopy-0.2.7 → walopy-0.2.8}/src/walopy.egg-info/top_level.txt +0 -0
  32. {walopy-0.2.7 → walopy-0.2.8}/tests/test_advanced.py +0 -0
  33. {walopy-0.2.7 → walopy-0.2.8}/tests/test_bottleneck.py +0 -0
  34. {walopy-0.2.7 → walopy-0.2.8}/tests/test_exchange_curve.py +0 -0
  35. {walopy-0.2.7 → walopy-0.2.8}/tests/test_fitting.py +0 -0
  36. {walopy-0.2.7 → walopy-0.2.8}/tests/test_inventory.py +0 -0
  37. {walopy-0.2.7 → walopy-0.2.8}/tests/test_inventory_new.py +0 -0
  38. {walopy-0.2.7 → walopy-0.2.8}/tests/test_kpi.py +0 -0
  39. {walopy-0.2.7 → walopy-0.2.8}/tests/test_mg1.py +0 -0
  40. {walopy-0.2.7 → walopy-0.2.8}/tests/test_network.py +0 -0
  41. {walopy-0.2.7 → walopy-0.2.8}/tests/test_new_models.py +0 -0
  42. {walopy-0.2.7 → walopy-0.2.8}/tests/test_operations.py +0 -0
  43. {walopy-0.2.7 → walopy-0.2.8}/tests/test_queuing.py +0 -0
  44. {walopy-0.2.7 → walopy-0.2.8}/tests/test_safety_stock_curve.py +0 -0
  45. {walopy-0.2.7 → walopy-0.2.8}/tests/test_solver.py +0 -0
  46. {walopy-0.2.7 → walopy-0.2.8}/tests/test_v026.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: walopy
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: Queuing theory, operations analysis, OEE, bottleneck analysis and KPI trees for Python
5
5
  Author-email: LeoSanta15 <angelsanta1@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "walopy"
7
- version = "0.2.7"
7
+ version = "0.2.8"
8
8
  description = "Queuing theory, operations analysis, OEE, bottleneck analysis and KPI trees for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -119,11 +119,19 @@ from .inventory import (
119
119
  RSPolicyResult,
120
120
  ExchangeCurveResult,
121
121
  SafetyStockCurveResult,
122
+ ABCResult,
123
+ XYZResult,
124
+ ABCXYZResult,
125
+ MRPResult,
122
126
  rq_policy,
123
127
  rs_policy,
124
128
  wagner_whitin,
125
129
  exchange_curve,
126
130
  safety_stock_curve,
131
+ abc_analysis,
132
+ xyz_analysis,
133
+ abc_xyz,
134
+ mrp,
127
135
  )
128
136
 
129
137
  # Scheduling
@@ -131,17 +139,29 @@ from .scheduling import (
131
139
  JobSchedule,
132
140
  ScheduleResult,
133
141
  FlowShopResult,
142
+ NEHResult,
134
143
  schedule_single,
135
144
  johnson_flowshop,
145
+ neh_flowshop,
136
146
  )
137
147
 
138
148
  # Reliability
139
149
  from .reliability import (
140
150
  ReliabilityResult,
151
+ WeibullResult,
141
152
  mtbf_analysis,
142
153
  series_system,
143
154
  parallel_system,
144
155
  koon_system,
156
+ weibull_analysis,
157
+ )
158
+
159
+ # Project scheduling
160
+ from .project import (
161
+ ActivityResult,
162
+ ProjectResult,
163
+ cpm,
164
+ pert,
145
165
  )
146
166
 
147
167
  from importlib.metadata import version, PackageNotFoundError
@@ -241,21 +261,38 @@ __all__ = [
241
261
  "RSPolicyResult",
242
262
  "ExchangeCurveResult",
243
263
  "SafetyStockCurveResult",
264
+ "ABCResult",
265
+ "XYZResult",
266
+ "ABCXYZResult",
267
+ "MRPResult",
244
268
  "rq_policy",
245
269
  "rs_policy",
246
270
  "wagner_whitin",
247
271
  "exchange_curve",
248
272
  "safety_stock_curve",
273
+ "abc_analysis",
274
+ "xyz_analysis",
275
+ "abc_xyz",
276
+ "mrp",
249
277
  # scheduling
250
278
  "JobSchedule",
251
279
  "ScheduleResult",
252
280
  "FlowShopResult",
281
+ "NEHResult",
253
282
  "schedule_single",
254
283
  "johnson_flowshop",
284
+ "neh_flowshop",
255
285
  # reliability
256
286
  "ReliabilityResult",
287
+ "WeibullResult",
257
288
  "mtbf_analysis",
258
289
  "series_system",
259
290
  "parallel_system",
260
291
  "koon_system",
292
+ "weibull_analysis",
293
+ # project
294
+ "ActivityResult",
295
+ "ProjectResult",
296
+ "cpm",
297
+ "pert",
261
298
  ]
@@ -2002,3 +2002,522 @@ def safety_stock_curve(
2002
2002
  optimal_quantities=opt_qtys,
2003
2003
  curve_points=curve_pts,
2004
2004
  )
2005
+
2006
+
2007
+ # ---------------------------------------------------------------------------
2008
+ # ABC / XYZ / ABC-XYZ classification
2009
+ # ---------------------------------------------------------------------------
2010
+
2011
+ @dataclass
2012
+ class ABCResult:
2013
+ """ABC Pareto classification result.
2014
+
2015
+ Attributes
2016
+ ----------
2017
+ items : list[dict]
2018
+ Items sorted descending by annual value; each dict has keys
2019
+ ``name``, ``demand``, ``unit_value``, ``annual_value``,
2020
+ ``cumulative_pct``, ``pct_value``, ``rank``, ``class``.
2021
+ class_summary : dict
2022
+ ``{A: {count, pct_items, pct_value}, B: …, C: …}``.
2023
+ total_value : float
2024
+ Total annual inventory value.
2025
+ thresholds : dict
2026
+ ``{a: float, b: float}`` used for classification.
2027
+ """
2028
+
2029
+ items: list
2030
+ class_summary: dict
2031
+ total_value: float
2032
+ thresholds: dict
2033
+
2034
+ def to_frame(self) -> "pd.DataFrame":
2035
+ import pandas as pd
2036
+ return pd.DataFrame(self.items)
2037
+
2038
+ def summary(self) -> str:
2039
+ lines = [
2040
+ f"Total annual value : {self.total_value:.6g}",
2041
+ f"{'Class':<6} {'Items':>6} {'%Items':>7} {'%Value':>7}",
2042
+ "-" * 32,
2043
+ ]
2044
+ for cls in ["A", "B", "C"]:
2045
+ s = self.class_summary[cls]
2046
+ lines.append(
2047
+ f" {cls} {s['count']:>4} {s['pct_items']:>6.1%} {s['pct_value']:>6.1%}"
2048
+ )
2049
+ return "\n".join(lines)
2050
+
2051
+ def __str__(self) -> str:
2052
+ return self.summary()
2053
+
2054
+
2055
+ @dataclass
2056
+ class XYZResult:
2057
+ """XYZ demand-variability classification result.
2058
+
2059
+ Attributes
2060
+ ----------
2061
+ items : list[dict]
2062
+ Each dict has keys ``name``, ``cv``, ``class``.
2063
+ class_summary : dict
2064
+ ``{X: {count, pct_items}, Y: …, Z: …}``.
2065
+ thresholds : dict
2066
+ ``{x: float, y: float}`` CV boundaries.
2067
+ """
2068
+
2069
+ items: list
2070
+ class_summary: dict
2071
+ thresholds: dict
2072
+
2073
+ def to_frame(self) -> "pd.DataFrame":
2074
+ import pandas as pd
2075
+ return pd.DataFrame(self.items)
2076
+
2077
+ def summary(self) -> str:
2078
+ t = self.thresholds
2079
+ ranges = {
2080
+ "X": f"CV ≤ {t['x']:.2g}",
2081
+ "Y": f"{t['x']:.2g} < CV ≤ {t['y']:.2g}",
2082
+ "Z": f"CV > {t['y']:.2g}",
2083
+ }
2084
+ n_total = sum(s["count"] for s in self.class_summary.values())
2085
+ lines = [
2086
+ f"{'Class':<6} {'Items':>6} {'%Items':>7} {'CV range'}",
2087
+ "-" * 42,
2088
+ ]
2089
+ for cls in ["X", "Y", "Z"]:
2090
+ s = self.class_summary[cls]
2091
+ lines.append(
2092
+ f" {cls} {s['count']:>4} {s['pct_items']:>6.1%} {ranges[cls]}"
2093
+ )
2094
+ return "\n".join(lines)
2095
+
2096
+ def __str__(self) -> str:
2097
+ return self.summary()
2098
+
2099
+
2100
+ @dataclass
2101
+ class ABCXYZResult:
2102
+ """Combined ABC-XYZ classification result.
2103
+
2104
+ Attributes
2105
+ ----------
2106
+ items : list[dict]
2107
+ Each dict has keys ``name``, ``annual_value``, ``cv``,
2108
+ ``abc_class``, ``xyz_class``, ``combined_class``.
2109
+ matrix : dict
2110
+ ``{(abc, xyz): count}`` for all 9 cells.
2111
+ """
2112
+
2113
+ items: list
2114
+ matrix: dict
2115
+
2116
+ def to_frame(self) -> "pd.DataFrame":
2117
+ import pandas as pd
2118
+ return pd.DataFrame(self.items)
2119
+
2120
+ def matrix_frame(self) -> "pd.DataFrame":
2121
+ """Pivot table ABC (rows) × XYZ (columns) with item counts."""
2122
+ import pandas as pd
2123
+ data = {
2124
+ xyz: {abc: self.matrix.get((abc, xyz), 0) for abc in ["A", "B", "C"]}
2125
+ for xyz in ["X", "Y", "Z"]
2126
+ }
2127
+ return pd.DataFrame(data, index=["A", "B", "C"])
2128
+
2129
+ def summary(self) -> str:
2130
+ lines = ["ABC\\XYZ X Y Z Total"]
2131
+ for abc in ["A", "B", "C"]:
2132
+ row_total = sum(self.matrix.get((abc, xyz), 0) for xyz in ["X", "Y", "Z"])
2133
+ lines.append(
2134
+ f" {abc} "
2135
+ + "".join(f" {self.matrix.get((abc, xyz), 0):3d}" for xyz in ["X", "Y", "Z"])
2136
+ + f" {row_total:4d}"
2137
+ )
2138
+ col_totals = [sum(self.matrix.get((abc, xyz), 0) for abc in ["A", "B", "C"]) for xyz in ["X", "Y", "Z"]]
2139
+ grand = sum(col_totals)
2140
+ lines.append("Total " + "".join(f" {c:3d}" for c in col_totals) + f" {grand:4d}")
2141
+ return "\n".join(lines)
2142
+
2143
+ def __str__(self) -> str:
2144
+ return self.summary()
2145
+
2146
+
2147
+ def abc_analysis(
2148
+ items: list,
2149
+ *,
2150
+ a_threshold: float = 0.80,
2151
+ b_threshold: float = 0.95,
2152
+ ) -> ABCResult:
2153
+ """ABC Pareto classification of inventory items by annual value.
2154
+
2155
+ Items are sorted descending by annual value (demand × unit_value).
2156
+ Classification follows cumulative percentage of total value:
2157
+
2158
+ - **A**: items whose cumulative value has not yet exceeded
2159
+ *a_threshold* (typically top ~80% of value, ~20% of items).
2160
+ - **B**: next band up to *b_threshold* (~15% of value).
2161
+ - **C**: remainder (~5% of value, ~50% of items).
2162
+
2163
+ Parameters
2164
+ ----------
2165
+ items : list of dict
2166
+ Each dict must have ``'demand'`` (annual demand) and
2167
+ ``'unit_value'``. Optional ``'name'`` key.
2168
+ a_threshold : float
2169
+ Cumulative value fraction that ends class A (default 0.80).
2170
+ b_threshold : float
2171
+ Cumulative value fraction that ends class B (default 0.95).
2172
+
2173
+ Returns
2174
+ -------
2175
+ ABCResult
2176
+ """
2177
+ if len(items) == 0:
2178
+ raise ValueError("'items' must contain at least one item.")
2179
+ if not (0.0 < a_threshold < b_threshold < 1.0):
2180
+ raise ValueError("Must have 0 < a_threshold < b_threshold < 1.")
2181
+
2182
+ enriched = []
2183
+ for i, it in enumerate(items):
2184
+ nm = it.get("name", f"Item{i + 1}")
2185
+ D = as_positive(float(it["demand"]), f"items[{i}]['demand']")
2186
+ v = as_positive(float(it["unit_value"]), f"items[{i}]['unit_value']")
2187
+ enriched.append({"name": str(nm), "demand": D, "unit_value": v,
2188
+ "annual_value": D * v})
2189
+
2190
+ enriched.sort(key=lambda x: -x["annual_value"])
2191
+ n = len(enriched)
2192
+ total_value = sum(e["annual_value"] for e in enriched)
2193
+
2194
+ cum = 0.0
2195
+ class_agg: dict = {"A": {"count": 0, "value": 0.0},
2196
+ "B": {"count": 0, "value": 0.0},
2197
+ "C": {"count": 0, "value": 0.0}}
2198
+ for i, e in enumerate(enriched):
2199
+ prev_pct = cum / total_value
2200
+ cum += e["annual_value"]
2201
+ e["cumulative_pct"] = cum / total_value
2202
+ e["pct_value"] = e["annual_value"] / total_value
2203
+ e["rank"] = i + 1
2204
+ if prev_pct < a_threshold:
2205
+ cls = "A"
2206
+ elif prev_pct < b_threshold:
2207
+ cls = "B"
2208
+ else:
2209
+ cls = "C"
2210
+ e["class"] = cls
2211
+ class_agg[cls]["count"] += 1
2212
+ class_agg[cls]["value"] += e["annual_value"]
2213
+
2214
+ class_summary = {
2215
+ cls: {
2216
+ "count": class_agg[cls]["count"],
2217
+ "pct_items": class_agg[cls]["count"] / n,
2218
+ "pct_value": class_agg[cls]["value"] / total_value,
2219
+ }
2220
+ for cls in ["A", "B", "C"]
2221
+ }
2222
+
2223
+ return ABCResult(
2224
+ items=enriched,
2225
+ class_summary=class_summary,
2226
+ total_value=total_value,
2227
+ thresholds={"a": a_threshold, "b": b_threshold},
2228
+ )
2229
+
2230
+
2231
+ def xyz_analysis(
2232
+ items: list,
2233
+ *,
2234
+ x_threshold: float = 0.5,
2235
+ y_threshold: float = 1.0,
2236
+ ) -> XYZResult:
2237
+ """XYZ classification of inventory items by demand variability (CV).
2238
+
2239
+ - **X**: CV ≤ *x_threshold* — stable, predictable demand.
2240
+ - **Y**: *x_threshold* < CV ≤ *y_threshold* — moderate variability.
2241
+ - **Z**: CV > *y_threshold* — erratic, hard to forecast.
2242
+
2243
+ Parameters
2244
+ ----------
2245
+ items : list of dict
2246
+ Each dict must supply the coefficient of variation in one of two ways:
2247
+
2248
+ - ``'cv'`` directly, **or**
2249
+ - ``'demand_std'`` **+** (``'demand_mean'`` or ``'demand_rate'``).
2250
+
2251
+ Optional ``'name'`` key.
2252
+ x_threshold : float
2253
+ Upper CV boundary for class X (default 0.5).
2254
+ y_threshold : float
2255
+ Upper CV boundary for class Y (default 1.0).
2256
+
2257
+ Returns
2258
+ -------
2259
+ XYZResult
2260
+ """
2261
+ if len(items) == 0:
2262
+ raise ValueError("'items' must contain at least one item.")
2263
+ if not (0.0 <= x_threshold < y_threshold):
2264
+ raise ValueError("Must have 0 ≤ x_threshold < y_threshold.")
2265
+
2266
+ enriched = []
2267
+ for i, it in enumerate(items):
2268
+ nm = it.get("name", f"Item{i + 1}")
2269
+ if "cv" in it:
2270
+ cv = float(it["cv"])
2271
+ else:
2272
+ std = float(it["demand_std"])
2273
+ mean = float(it.get("demand_mean", it.get("demand_rate", 0.0)))
2274
+ if mean <= 0:
2275
+ raise ValueError(
2276
+ f"items[{i}]: 'demand_mean' (or 'demand_rate') must be > 0 when 'cv' is not provided."
2277
+ )
2278
+ cv = std / mean
2279
+ if cv < 0:
2280
+ raise ValueError(f"items[{i}]: CV must be ≥ 0, got {cv}.")
2281
+ if cv <= x_threshold:
2282
+ cls = "X"
2283
+ elif cv <= y_threshold:
2284
+ cls = "Y"
2285
+ else:
2286
+ cls = "Z"
2287
+ enriched.append({"name": str(nm), "cv": cv, "class": cls})
2288
+
2289
+ n = len(enriched)
2290
+ class_agg: dict = {"X": 0, "Y": 0, "Z": 0}
2291
+ for e in enriched:
2292
+ class_agg[e["class"]] += 1
2293
+
2294
+ class_summary = {
2295
+ cls: {"count": class_agg[cls], "pct_items": class_agg[cls] / n}
2296
+ for cls in ["X", "Y", "Z"]
2297
+ }
2298
+
2299
+ return XYZResult(
2300
+ items=enriched,
2301
+ class_summary=class_summary,
2302
+ thresholds={"x": x_threshold, "y": y_threshold},
2303
+ )
2304
+
2305
+
2306
+ def abc_xyz(
2307
+ items: list,
2308
+ *,
2309
+ a_threshold: float = 0.80,
2310
+ b_threshold: float = 0.95,
2311
+ x_threshold: float = 0.5,
2312
+ y_threshold: float = 1.0,
2313
+ ) -> ABCXYZResult:
2314
+ """Combined ABC-XYZ classification.
2315
+
2316
+ Each item requires fields for both ABC (``demand``, ``unit_value``) and
2317
+ XYZ (``cv`` or ``demand_std`` + ``demand_mean``/``demand_rate``).
2318
+
2319
+ Parameters
2320
+ ----------
2321
+ items : list of dict
2322
+ Must supply fields required by both :func:`abc_analysis` and
2323
+ :func:`xyz_analysis`. ``name`` is optional.
2324
+ a_threshold, b_threshold : float
2325
+ ABC thresholds (see :func:`abc_analysis`).
2326
+ x_threshold, y_threshold : float
2327
+ XYZ thresholds (see :func:`xyz_analysis`).
2328
+
2329
+ Returns
2330
+ -------
2331
+ ABCXYZResult
2332
+ """
2333
+ abc_r = abc_analysis(items, a_threshold=a_threshold, b_threshold=b_threshold)
2334
+ xyz_r = xyz_analysis(items, x_threshold=x_threshold, y_threshold=y_threshold)
2335
+
2336
+ abc_map = {e["name"]: e for e in abc_r.items}
2337
+ xyz_map = {e["name"]: e for e in xyz_r.items}
2338
+
2339
+ combined = []
2340
+ matrix: dict = {}
2341
+ for e_abc in abc_r.items:
2342
+ nm = e_abc["name"]
2343
+ e_xyz = xyz_map[nm]
2344
+ abc_cls = e_abc["class"]
2345
+ xyz_cls = e_xyz["class"]
2346
+ key = (abc_cls, xyz_cls)
2347
+ matrix[key] = matrix.get(key, 0) + 1
2348
+ combined.append({
2349
+ "name": nm,
2350
+ "annual_value": e_abc["annual_value"],
2351
+ "cv": e_xyz["cv"],
2352
+ "abc_class": abc_cls,
2353
+ "xyz_class": xyz_cls,
2354
+ "combined_class": f"{abc_cls}{xyz_cls}",
2355
+ })
2356
+
2357
+ return ABCXYZResult(items=combined, matrix=matrix)
2358
+
2359
+
2360
+ # ---------------------------------------------------------------------------
2361
+ # MRP — single-level Material Requirements Planning
2362
+ # ---------------------------------------------------------------------------
2363
+
2364
+ @dataclass
2365
+ class MRPResult:
2366
+ """Single-level MRP result.
2367
+
2368
+ Attributes
2369
+ ----------
2370
+ item_name : str
2371
+ periods : list
2372
+ Period labels (1, 2, … or user-supplied).
2373
+ gross_requirements : list[float]
2374
+ scheduled_receipts : list[float]
2375
+ projected_on_hand : list[float]
2376
+ Ending on-hand inventory after all transactions each period.
2377
+ net_requirements : list[float]
2378
+ planned_receipts : list[float]
2379
+ Planned order receipts arriving this period.
2380
+ planned_releases : list[float]
2381
+ Planned order releases (issued *lead_time* periods before receipt).
2382
+ """
2383
+
2384
+ item_name: str
2385
+ periods: list
2386
+ gross_requirements: list
2387
+ scheduled_receipts: list
2388
+ projected_on_hand: list
2389
+ net_requirements: list
2390
+ planned_receipts: list
2391
+ planned_releases: list
2392
+
2393
+ def to_frame(self) -> "pd.DataFrame":
2394
+ import pandas as pd
2395
+ return pd.DataFrame({
2396
+ "Period": self.periods,
2397
+ "Gross_Req": self.gross_requirements,
2398
+ "Sched_Receipt": self.scheduled_receipts,
2399
+ "Proj_On_Hand": self.projected_on_hand,
2400
+ "Net_Req": self.net_requirements,
2401
+ "Planned_Receipt": self.planned_receipts,
2402
+ "Planned_Release": self.planned_releases,
2403
+ })
2404
+
2405
+ def summary(self) -> str:
2406
+ hdr = f"{'Period':>8} {'GR':>8} {'SR':>8} {'OH':>8} {'NR':>8} {'PR':>8} {'PO':>8}"
2407
+ lines = [f"Item: {self.item_name}", hdr, "-" * len(hdr)]
2408
+ for i, p in enumerate(self.periods):
2409
+ lines.append(
2410
+ f"{str(p):>8} {self.gross_requirements[i]:>8.2f}"
2411
+ f" {self.scheduled_receipts[i]:>8.2f}"
2412
+ f" {self.projected_on_hand[i]:>8.2f}"
2413
+ f" {self.net_requirements[i]:>8.2f}"
2414
+ f" {self.planned_receipts[i]:>8.2f}"
2415
+ f" {self.planned_releases[i]:>8.2f}"
2416
+ )
2417
+ return "\n".join(lines)
2418
+
2419
+ def __str__(self) -> str:
2420
+ return self.summary()
2421
+
2422
+
2423
+ def mrp(
2424
+ gross_requirements: Sequence[float],
2425
+ *,
2426
+ initial_on_hand: float = 0.0,
2427
+ scheduled_receipts: Sequence[float] | None = None,
2428
+ lead_time: int = 1,
2429
+ lot_size: float | str = "LFL",
2430
+ safety_stock: float = 0.0,
2431
+ item_name: str = "Item",
2432
+ periods: Sequence | None = None,
2433
+ ) -> MRPResult:
2434
+ """Single-level Material Requirements Planning (MRP).
2435
+
2436
+ Computes the standard MRP table: gross requirements → net requirements →
2437
+ planned receipts → planned order releases.
2438
+
2439
+ Parameters
2440
+ ----------
2441
+ gross_requirements : sequence of float
2442
+ Demand for each period (length T).
2443
+ initial_on_hand : float
2444
+ On-hand inventory at the start of period 1 (default 0).
2445
+ scheduled_receipts : sequence of float, optional
2446
+ Already-ordered receipts arriving each period (length T, default all 0).
2447
+ lead_time : int
2448
+ Replenishment lead time in periods (default 1).
2449
+ lot_size : float or ``'LFL'``
2450
+ Order policy: ``'LFL'`` (lot-for-lot) places the exact net requirement;
2451
+ a positive float rounds up to the nearest multiple of that quantity.
2452
+ safety_stock : float
2453
+ Minimum desired ending on-hand each period (default 0).
2454
+ item_name : str
2455
+ Label for the item (default ``'Item'``).
2456
+ periods : sequence, optional
2457
+ Period labels (default 1, 2, …, T).
2458
+
2459
+ Returns
2460
+ -------
2461
+ MRPResult
2462
+ """
2463
+ GR = [as_nonneg(float(g), f"gross_requirements[{i}]")
2464
+ for i, g in enumerate(gross_requirements)]
2465
+ T = len(GR)
2466
+ if T == 0:
2467
+ raise ValueError("'gross_requirements' must not be empty.")
2468
+
2469
+ SR = [0.0] * T
2470
+ if scheduled_receipts is not None:
2471
+ sr_list = list(scheduled_receipts)
2472
+ if len(sr_list) != T:
2473
+ raise ValueError("'scheduled_receipts' must have the same length as 'gross_requirements'.")
2474
+ SR = [as_nonneg(float(s), f"scheduled_receipts[{i}]") for i, s in enumerate(sr_list)]
2475
+
2476
+ if not isinstance(lead_time, int) or lead_time < 0:
2477
+ raise ValueError("'lead_time' must be a non-negative integer.")
2478
+
2479
+ SS = as_nonneg(float(safety_stock), "safety_stock")
2480
+ if isinstance(lot_size, str):
2481
+ if lot_size.upper() != "LFL":
2482
+ raise ValueError("'lot_size' must be 'LFL' or a positive float.")
2483
+ lot_q: float | None = None
2484
+ else:
2485
+ lot_q = as_positive(float(lot_size), "lot_size")
2486
+
2487
+ pds = list(periods) if periods is not None else list(range(1, T + 1))
2488
+
2489
+ OH = initial_on_hand
2490
+ NR_out: list = [0.0] * T
2491
+ PR_out: list = [0.0] * T
2492
+ OH_out: list = [0.0] * T
2493
+
2494
+ for t in range(T):
2495
+ available = OH + SR[t] - GR[t]
2496
+ nr = max(0.0, SS - available)
2497
+ if nr > 0.0:
2498
+ pr = nr if lot_q is None else math.ceil(nr / lot_q) * lot_q
2499
+ else:
2500
+ pr = 0.0
2501
+ OH = available + pr
2502
+ NR_out[t] = nr
2503
+ PR_out[t] = pr
2504
+ OH_out[t] = OH
2505
+
2506
+ # Planned releases: release at period (t - lead_time) for receipt at t
2507
+ POR: list = [0.0] * T
2508
+ for t in range(T):
2509
+ if PR_out[t] > 0.0:
2510
+ rel = t - lead_time
2511
+ if 0 <= rel < T:
2512
+ POR[rel] += PR_out[t]
2513
+
2514
+ return MRPResult(
2515
+ item_name=item_name,
2516
+ periods=pds,
2517
+ gross_requirements=GR,
2518
+ scheduled_receipts=SR,
2519
+ projected_on_hand=OH_out,
2520
+ net_requirements=NR_out,
2521
+ planned_receipts=PR_out,
2522
+ planned_releases=POR,
2523
+ )