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.
- {walopy-0.2.7/src/walopy.egg-info → walopy-0.2.8}/PKG-INFO +1 -1
- {walopy-0.2.7 → walopy-0.2.8}/pyproject.toml +1 -1
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/__init__.py +37 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/inventory.py +519 -0
- walopy-0.2.8/src/walopy/project.py +348 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/reliability.py +204 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/scheduling.py +154 -0
- {walopy-0.2.7 → walopy-0.2.8/src/walopy.egg-info}/PKG-INFO +1 -1
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy.egg-info/SOURCES.txt +7 -1
- walopy-0.2.8/tests/test_abc_xyz.py +179 -0
- walopy-0.2.8/tests/test_cpm_pert.py +165 -0
- walopy-0.2.8/tests/test_mrp.py +90 -0
- walopy-0.2.8/tests/test_neh_flowshop.py +111 -0
- walopy-0.2.8/tests/test_weibull.py +108 -0
- {walopy-0.2.7 → walopy-0.2.8}/LICENSE +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/README.md +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/setup.cfg +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/__main__.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/_utils.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/advanced.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/bottleneck.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/fitting.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/kpi.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/network.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/operations.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/plotting.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/queuing.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy/solver.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy.egg-info/dependency_links.txt +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy.egg-info/requires.txt +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/src/walopy.egg-info/top_level.txt +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_advanced.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_bottleneck.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_exchange_curve.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_fitting.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_inventory.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_inventory_new.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_kpi.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_mg1.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_network.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_new_models.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_operations.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_queuing.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_safety_stock_curve.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_solver.py +0 -0
- {walopy-0.2.7 → walopy-0.2.8}/tests/test_v026.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "walopy"
|
|
7
|
-
version = "0.2.
|
|
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
|
+
)
|