Update app.py
Browse files
app.py
CHANGED
|
@@ -1,45 +1,31 @@
|
|
| 1 |
-
import os, json,
|
| 2 |
import gradio as gr
|
| 3 |
import pandas as pd
|
| 4 |
import numpy as np
|
| 5 |
|
| 6 |
-
#
|
| 7 |
logging.getLogger("cmdstanpy").setLevel(logging.WARNING)
|
| 8 |
logging.getLogger("prophet").setLevel(logging.WARNING)
|
| 9 |
|
| 10 |
-
#
|
| 11 |
-
# Auth: set OPENAI_API_KEY in HF/Colab secrets
|
| 12 |
-
# -----------------------------
|
| 13 |
-
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 14 |
-
if not OPENAI_API_KEY:
|
| 15 |
-
print("β οΈ OPENAI_API_KEY not set. Set a Space secret or env var. Tools will still run locally; the agent needs it.")
|
| 16 |
-
|
| 17 |
-
# -----------------------------
|
| 18 |
-
# Tools (your requested @tool style)
|
| 19 |
-
# -----------------------------
|
| 20 |
from smolagents import tool, CodeAgent, OpenAIServerModel
|
| 21 |
|
| 22 |
@tool
|
| 23 |
-
def forecast_tool(
|
| 24 |
-
horizon_months: int = 1,
|
| 25 |
-
use_demo: bool = True,
|
| 26 |
-
history_csv_path: str = ""
|
| 27 |
-
) -> str:
|
| 28 |
"""
|
| 29 |
Forecast monthly demand for finished goods using Prophet (demo-friendly).
|
| 30 |
|
| 31 |
Args:
|
| 32 |
-
horizon_months (int): Number of future months to forecast. Defaults to 1.
|
| 33 |
-
use_demo (bool): If True, generate synthetic history for
|
| 34 |
-
history_csv_path (str): Optional path
|
| 35 |
|
| 36 |
Returns:
|
| 37 |
-
str: JSON string list of
|
| 38 |
-
{"product_id": str, "period_start": "YYYY-MM-01", "forecast_qty": float}
|
| 39 |
"""
|
| 40 |
from prophet import Prophet
|
| 41 |
|
| 42 |
-
# 1)
|
| 43 |
if use_demo or not history_csv_path:
|
| 44 |
rng = pd.date_range("2023-01-01", periods=24, freq="MS")
|
| 45 |
rows = []
|
|
@@ -52,82 +38,62 @@ def forecast_tool(
|
|
| 52 |
df = pd.DataFrame(rows)
|
| 53 |
else:
|
| 54 |
df = pd.read_csv(history_csv_path)
|
| 55 |
-
assert {"product_id", "date", "qty"} <= set(df.columns), "
|
| 56 |
df["date"] = pd.to_datetime(df["date"], errors="coerce")
|
| 57 |
df = df.dropna(subset=["date"])
|
| 58 |
df["qty"] = pd.to_numeric(df["qty"], errors="coerce").fillna(0.0)
|
| 59 |
|
| 60 |
-
# 2) Forecast per product
|
| 61 |
out = []
|
| 62 |
-
|
| 63 |
for pid, g in df.groupby("product_id"):
|
| 64 |
-
s = (
|
| 65 |
-
g.set_index("date")["qty"]
|
| 66 |
-
.resample("MS").sum()
|
| 67 |
-
.asfreq("MS").fillna(0.0)
|
| 68 |
-
)
|
| 69 |
m = Prophet(yearly_seasonality=True, weekly_seasonality=False, daily_seasonality=False, n_changepoints=10)
|
| 70 |
m.fit(pd.DataFrame({"ds": s.index, "y": s.values}))
|
| 71 |
-
future = m.make_future_dataframe(periods=
|
| 72 |
pred = m.predict(future)[["ds", "yhat"]]
|
| 73 |
for _, r in pred.iterrows():
|
| 74 |
-
out.append({
|
| 75 |
-
"product_id": str(pid),
|
| 76 |
-
"period_start": r["ds"].strftime("%Y-%m-%d"),
|
| 77 |
-
"forecast_qty": float(r["yhat"])
|
| 78 |
-
})
|
| 79 |
return json.dumps(out)
|
| 80 |
|
| 81 |
|
| 82 |
@tool
|
| 83 |
-
def optimize_supply_tool(
|
| 84 |
-
forecast_json: str
|
| 85 |
-
) -> str:
|
| 86 |
"""
|
| 87 |
-
Optimize a single-month supply plan (
|
| 88 |
|
| 89 |
Args:
|
| 90 |
forecast_json (str): JSON string returned by forecast_tool.
|
| 91 |
|
| 92 |
Returns:
|
| 93 |
-
str: JSON
|
| 94 |
-
{
|
| 95 |
-
"status": "OPTIMAL",
|
| 96 |
-
"profit": float,
|
| 97 |
-
"products": [{"product_id": ..., "produce_qty": ..., "sell_qty": ...}],
|
| 98 |
-
"raw_materials": [{"rm_id": ..., "purchase_qty": ..., "consumption_qty": ...}],
|
| 99 |
-
"resources": [{"resource_id": "R1"/"R2", "used_hours": ..., "available_hours": ...}]
|
| 100 |
-
}
|
| 101 |
"""
|
| 102 |
-
|
| 103 |
-
|
|
|
|
| 104 |
# Use first month per product
|
| 105 |
demand = {}
|
| 106 |
-
for
|
| 107 |
-
p =
|
| 108 |
-
|
|
|
|
| 109 |
|
| 110 |
-
P = sorted(demand.keys()) or ["FG100", "FG200"]
|
| 111 |
-
# Prices / conversion costs / resource usage
|
| 112 |
price = {"FG100": 98.0, "FG200": 120.0}
|
| 113 |
conv = {"FG100": 12.5, "FG200": 15.0}
|
| 114 |
r1 = {"FG100": 0.03, "FG200": 0.05}
|
| 115 |
r2 = {"FG100": 0.02, "FG200": 0.01}
|
| 116 |
-
|
| 117 |
RMs = ["RM_A", "RM_B"]
|
| 118 |
rm_cost = {"RM_A": 20.0, "RM_B": 30.0}
|
| 119 |
rm_start = {"RM_A": 1000.0, "RM_B": 100.0}
|
| 120 |
-
rm_cap
|
| 121 |
bom = {
|
| 122 |
-
"FG100": {"RM_A": 0.8, "RM_B": 0.2 * 1.02},
|
| 123 |
"FG200": {"RM_A": 1.0, "RM_B": 0.1},
|
| 124 |
}
|
| 125 |
r1_cap, r2_cap = 320.0, 480.0
|
| 126 |
-
start_inv = {p: 0.0 for p in P} # keep the LP minimal
|
| 127 |
-
safety = {p: 0.0 for p in P}
|
| 128 |
|
| 129 |
-
# Build LP: variables = produce[p], sell[p], purchase[r], end_inv_rm[r], end_inv[p]
|
| 130 |
-
from scipy.optimize import linprog
|
| 131 |
nP, nR = len(P), len(RMs)
|
| 132 |
pidx = {p:i for i,p in enumerate(P)}
|
| 133 |
ridx = {r:i for i,r in enumerate(RMs)}
|
|
@@ -142,203 +108,257 @@ def optimize_supply_tool(
|
|
| 142 |
c = np.zeros(n_vars)
|
| 143 |
bounds = [None]*n_vars
|
| 144 |
|
| 145 |
-
# objective: minimize (costs - revenue)
|
| 146 |
for p in P:
|
| 147 |
c[i_prod(p)] += conv[p]
|
| 148 |
c[i_sell(p)] -= price[p]
|
| 149 |
c[i_einv(p)] += 0.0
|
| 150 |
bounds[i_prod(p)] = (0, None)
|
| 151 |
-
bounds[i_sell(p)] = (0,
|
| 152 |
-
bounds[i_einv(p)] = (
|
| 153 |
for r in RMs:
|
| 154 |
c[i_pur(r)] += rm_cost[r]
|
| 155 |
c[i_einr(r)] += 0.0
|
| 156 |
bounds[i_pur(r)] = (0, rm_cap[r])
|
| 157 |
bounds[i_einr(r)] = (0, None)
|
| 158 |
|
| 159 |
-
#
|
| 160 |
Aeq, beq = [], []
|
| 161 |
-
# FG balance: start + produce - sell - end_inv = 0
|
| 162 |
for p in P:
|
| 163 |
-
row = np.zeros(n_vars)
|
| 164 |
-
row
|
| 165 |
-
Aeq.append(row); beq.append(-start_inv[p])
|
| 166 |
-
# RM balance: start + purchase - sum(use*produce) - end_inv_rm = 0
|
| 167 |
for r in RMs:
|
| 168 |
-
row = np.zeros(n_vars)
|
| 169 |
-
row[
|
| 170 |
-
for p in P:
|
| 171 |
-
row[i_prod(p)] -= bom.get(p, {}).get(r, 0.0)
|
| 172 |
Aeq.append(row); beq.append(-rm_start[r])
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
# inequalities (resources)
|
| 177 |
Aub, bub = [], []
|
| 178 |
-
row = np.zeros(n_vars)
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
Aub.append(row); bub.append(r2_cap)
|
| 184 |
-
Aub, bub = np.array(Aub), np.array(bub)
|
| 185 |
-
|
| 186 |
-
res = linprog(c, A_ub=Aub, b_ub=bub, A_eq=Aeq, b_eq=beq, bounds=bounds, method="highs")
|
| 187 |
if not res.success:
|
| 188 |
return json.dumps({"status": "FAILED", "message": res.message})
|
| 189 |
|
| 190 |
x = res.x
|
| 191 |
def v(idx): return float(x[idx])
|
| 192 |
|
| 193 |
-
#
|
| 194 |
-
|
|
|
|
| 195 |
for p in P:
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
# resource usage
|
| 202 |
-
r1_used = float(sum(r1[p]*v(i_prod(p)) for p in P))
|
| 203 |
-
r2_used = float(sum(r2[p]*v(i_prod(p)) for p in P))
|
| 204 |
-
resources = [
|
| 205 |
-
{"resource_id": "R1", "used_hours": r1_used, "available_hours": r1_cap, "slack_hours": r1_cap - r1_used},
|
| 206 |
-
{"resource_id": "R2", "used_hours": r2_used, "available_hours": r2_cap, "slack_hours": r2_cap - r2_used},
|
| 207 |
-
]
|
| 208 |
-
# raw material flows
|
| 209 |
-
raw_rows = []
|
| 210 |
rm_purch_cost = 0.0
|
| 211 |
for r in RMs:
|
| 212 |
purchase = v(i_pur(r))
|
| 213 |
-
cons = float(sum(bom
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
| 220 |
profit = revenue - conv_cost - rm_purch_cost
|
| 221 |
|
| 222 |
out = {
|
| 223 |
"status": "OPTIMAL",
|
| 224 |
-
"
|
| 225 |
-
"
|
| 226 |
-
"
|
| 227 |
-
"
|
| 228 |
-
"products": prod_rows,
|
| 229 |
-
"raw_materials": raw_rows,
|
| 230 |
-
"resources": resources
|
| 231 |
}
|
| 232 |
return json.dumps(out)
|
| 233 |
|
| 234 |
|
| 235 |
@tool
|
| 236 |
-
def update_sap_md61_tool(
|
| 237 |
-
forecast_json: str,
|
| 238 |
-
plant: str = "PLANT01",
|
| 239 |
-
uom: str = "EA",
|
| 240 |
-
mrp_area: str = ""
|
| 241 |
-
) -> str:
|
| 242 |
"""
|
| 243 |
Prepare an MD61-style demand upload (SIMULATION ONLY).
|
| 244 |
|
| 245 |
Args:
|
| 246 |
forecast_json (str): JSON string returned by forecast_tool.
|
| 247 |
plant (str): SAP plant (WERKS). Defaults to 'PLANT01'.
|
| 248 |
-
uom (str): Unit of measure
|
| 249 |
mrp_area (str): Optional MRP area.
|
| 250 |
|
| 251 |
Returns:
|
| 252 |
-
str: JSON
|
| 253 |
"""
|
| 254 |
rows = json.loads(forecast_json)
|
| 255 |
-
md61 = [
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
"MRP_Area": mrp_area,
|
| 261 |
-
"Req_Date": r["period_start"], # month start; in practice, align to bucket conventions
|
| 262 |
-
"Req_Qty": float(r["forecast_qty"]),
|
| 263 |
-
"UoM": uom,
|
| 264 |
-
"Version": "00" # demo default
|
| 265 |
-
})
|
| 266 |
df = pd.DataFrame(md61)
|
| 267 |
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
|
| 268 |
df.to_csv(tmp.name, index=False)
|
| 269 |
-
return json.dumps({
|
| 270 |
-
"status": "SIMULATED",
|
| 271 |
-
"csv_path": tmp.name,
|
| 272 |
-
"preview": df.head(5).to_dict(orient="records")
|
| 273 |
-
})
|
| 274 |
|
| 275 |
-
|
| 276 |
-
# Agent
|
| 277 |
-
# -----------------------------
|
| 278 |
def make_agent():
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
return CodeAgent(tools=tools, model=model, add_base_tools=False, stream_outputs=False)
|
| 286 |
|
| 287 |
SYSTEM_PLAN = (
|
| 288 |
-
"Run the
|
| 289 |
-
"1)
|
| 290 |
-
"2)
|
| 291 |
-
"3)
|
| 292 |
-
"
|
| 293 |
-
"Return final_answer as JSON with keys: 'forecast', 'plan', and 'md61'."
|
| 294 |
)
|
| 295 |
|
| 296 |
-
def
|
| 297 |
agent = make_agent()
|
| 298 |
if file_obj is not None:
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
f"- forecast_tool: horizon_months={int(horizon)}, use_demo=False, history_csv_path='{history_path}'\n"
|
| 304 |
-
f"- optimize_supply_tool: (use forecast JSON)\n"
|
| 305 |
-
f"- update_sap_md61_tool: plant='{plant}', uom='EA'\n"
|
| 306 |
-
f"Return the final JSON only."
|
| 307 |
-
)
|
| 308 |
else:
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
|
| 343 |
if __name__ == "__main__":
|
| 344 |
demo.launch()
|
|
|
|
| 1 |
+
import os, json, tempfile, logging
|
| 2 |
import gradio as gr
|
| 3 |
import pandas as pd
|
| 4 |
import numpy as np
|
| 5 |
|
| 6 |
+
# Quiet noisy logs
|
| 7 |
logging.getLogger("cmdstanpy").setLevel(logging.WARNING)
|
| 8 |
logging.getLogger("prophet").setLevel(logging.WARNING)
|
| 9 |
|
| 10 |
+
# ==== Tools (your @tool template) ============================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from smolagents import tool, CodeAgent, OpenAIServerModel
|
| 12 |
|
| 13 |
@tool
|
| 14 |
+
def forecast_tool(horizon_months: int = 1, use_demo: bool = True, history_csv_path: str = "") -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
Forecast monthly demand for finished goods using Prophet (demo-friendly).
|
| 17 |
|
| 18 |
Args:
|
| 19 |
+
horizon_months (int): Number of future months to forecast (>=1). Defaults to 1.
|
| 20 |
+
use_demo (bool): If True, generate synthetic history for FG100/FG200. Defaults to True.
|
| 21 |
+
history_csv_path (str): Optional CSV path with columns [product_id,date,qty] to override demo.
|
| 22 |
|
| 23 |
Returns:
|
| 24 |
+
str: JSON string list of {"product_id": str, "period_start": "YYYY-MM-01", "forecast_qty": float}.
|
|
|
|
| 25 |
"""
|
| 26 |
from prophet import Prophet
|
| 27 |
|
| 28 |
+
# 1) History
|
| 29 |
if use_demo or not history_csv_path:
|
| 30 |
rng = pd.date_range("2023-01-01", periods=24, freq="MS")
|
| 31 |
rows = []
|
|
|
|
| 38 |
df = pd.DataFrame(rows)
|
| 39 |
else:
|
| 40 |
df = pd.read_csv(history_csv_path)
|
| 41 |
+
assert {"product_id", "date", "qty"} <= set(df.columns), "CSV must have product_id,date,qty"
|
| 42 |
df["date"] = pd.to_datetime(df["date"], errors="coerce")
|
| 43 |
df = df.dropna(subset=["date"])
|
| 44 |
df["qty"] = pd.to_numeric(df["qty"], errors="coerce").fillna(0.0)
|
| 45 |
|
| 46 |
+
# 2) Forecast per product
|
| 47 |
out = []
|
| 48 |
+
H = max(1, int(horizon_months))
|
| 49 |
for pid, g in df.groupby("product_id"):
|
| 50 |
+
s = (g.set_index("date")["qty"].resample("MS").sum().asfreq("MS").fillna(0.0))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
m = Prophet(yearly_seasonality=True, weekly_seasonality=False, daily_seasonality=False, n_changepoints=10)
|
| 52 |
m.fit(pd.DataFrame({"ds": s.index, "y": s.values}))
|
| 53 |
+
future = m.make_future_dataframe(periods=H, freq="MS", include_history=False)
|
| 54 |
pred = m.predict(future)[["ds", "yhat"]]
|
| 55 |
for _, r in pred.iterrows():
|
| 56 |
+
out.append({"product_id": str(pid), "period_start": r["ds"].strftime("%Y-%m-%d"), "forecast_qty": float(r["yhat"])})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
return json.dumps(out)
|
| 58 |
|
| 59 |
|
| 60 |
@tool
|
| 61 |
+
def optimize_supply_tool(forecast_json: str) -> str:
|
|
|
|
|
|
|
| 62 |
"""
|
| 63 |
+
Optimize a single-month supply plan (LP) using the forecast.
|
| 64 |
|
| 65 |
Args:
|
| 66 |
forecast_json (str): JSON string returned by forecast_tool.
|
| 67 |
|
| 68 |
Returns:
|
| 69 |
+
str: JSON with summary + readable tables (not raw solver output).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
"""
|
| 71 |
+
from scipy.optimize import linprog
|
| 72 |
+
|
| 73 |
+
rows = json.loads(forecast_json)
|
| 74 |
# Use first month per product
|
| 75 |
demand = {}
|
| 76 |
+
for r in rows:
|
| 77 |
+
p = r["product_id"]
|
| 78 |
+
if p not in demand:
|
| 79 |
+
demand[p] = float(r["forecast_qty"])
|
| 80 |
|
| 81 |
+
P = sorted(demand.keys()) or ["FG100", "FG200"]
|
|
|
|
| 82 |
price = {"FG100": 98.0, "FG200": 120.0}
|
| 83 |
conv = {"FG100": 12.5, "FG200": 15.0}
|
| 84 |
r1 = {"FG100": 0.03, "FG200": 0.05}
|
| 85 |
r2 = {"FG100": 0.02, "FG200": 0.01}
|
| 86 |
+
|
| 87 |
RMs = ["RM_A", "RM_B"]
|
| 88 |
rm_cost = {"RM_A": 20.0, "RM_B": 30.0}
|
| 89 |
rm_start = {"RM_A": 1000.0, "RM_B": 100.0}
|
| 90 |
+
rm_cap = {"RM_A": 5000.0, "RM_B": 5000.0}
|
| 91 |
bom = {
|
| 92 |
+
"FG100": {"RM_A": 0.8, "RM_B": 0.2 * 1.02},
|
| 93 |
"FG200": {"RM_A": 1.0, "RM_B": 0.1},
|
| 94 |
}
|
| 95 |
r1_cap, r2_cap = 320.0, 480.0
|
|
|
|
|
|
|
| 96 |
|
|
|
|
|
|
|
| 97 |
nP, nR = len(P), len(RMs)
|
| 98 |
pidx = {p:i for i,p in enumerate(P)}
|
| 99 |
ridx = {r:i for i,r in enumerate(RMs)}
|
|
|
|
| 108 |
c = np.zeros(n_vars)
|
| 109 |
bounds = [None]*n_vars
|
| 110 |
|
|
|
|
| 111 |
for p in P:
|
| 112 |
c[i_prod(p)] += conv[p]
|
| 113 |
c[i_sell(p)] -= price[p]
|
| 114 |
c[i_einv(p)] += 0.0
|
| 115 |
bounds[i_prod(p)] = (0, None)
|
| 116 |
+
bounds[i_sell(p)] = (0, demand[p])
|
| 117 |
+
bounds[i_einv(p)] = (0, None)
|
| 118 |
for r in RMs:
|
| 119 |
c[i_pur(r)] += rm_cost[r]
|
| 120 |
c[i_einr(r)] += 0.0
|
| 121 |
bounds[i_pur(r)] = (0, rm_cap[r])
|
| 122 |
bounds[i_einr(r)] = (0, None)
|
| 123 |
|
| 124 |
+
# Equalities
|
| 125 |
Aeq, beq = [], []
|
|
|
|
| 126 |
for p in P:
|
| 127 |
+
row = np.zeros(n_vars); row[i_prod(p)]=1; row[i_sell(p)]=-1; row[i_einv(p)]=-1
|
| 128 |
+
Aeq.append(row); beq.append(0.0) # start_inv=0 in this demo
|
|
|
|
|
|
|
| 129 |
for r in RMs:
|
| 130 |
+
row = np.zeros(n_vars); row[i_pur(r)]=1; row[i_einr(r)]=-1
|
| 131 |
+
for p in P: row[i_prod(p)] -= bom[p].get(r,0.0)
|
|
|
|
|
|
|
| 132 |
Aeq.append(row); beq.append(-rm_start[r])
|
| 133 |
|
| 134 |
+
# Inequalities (resources)
|
|
|
|
|
|
|
| 135 |
Aub, bub = [], []
|
| 136 |
+
row = np.zeros(n_vars); [row.__setitem__(i_prod(p), r1[p]) for p in P]; Aub.append(row); bub.append(r1_cap)
|
| 137 |
+
row = np.zeros(n_vars); [row.__setitem__(i_prod(p), r2[p]) for p in P]; Aub.append(row); bub.append(r2_cap)
|
| 138 |
+
|
| 139 |
+
res = linprog(c, A_ub=np.array(Aub), b_ub=np.array(bub), A_eq=np.array(Aeq), b_eq=np.array(beq),
|
| 140 |
+
bounds=bounds, method="highs")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
if not res.success:
|
| 142 |
return json.dumps({"status": "FAILED", "message": res.message})
|
| 143 |
|
| 144 |
x = res.x
|
| 145 |
def v(idx): return float(x[idx])
|
| 146 |
|
| 147 |
+
# Compose human-friendly tables
|
| 148 |
+
prod_tbl = []
|
| 149 |
+
revenue = 0.0; conv_cost = 0.0
|
| 150 |
for p in P:
|
| 151 |
+
produce = v(i_prod(p)); sell = v(i_sell(p))
|
| 152 |
+
prod_tbl.append({"Product": p, "Produce": produce, "Sell": sell, "Unit Price": price[p], "Conv. Cost/u": conv[p]})
|
| 153 |
+
revenue += sell*price[p]; conv_cost += produce*conv[p]
|
| 154 |
+
|
| 155 |
+
raw_tbl = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
rm_purch_cost = 0.0
|
| 157 |
for r in RMs:
|
| 158 |
purchase = v(i_pur(r))
|
| 159 |
+
cons = float(sum(bom[p].get(r,0.0)*v(i_prod(p)) for p in P))
|
| 160 |
+
cost = purchase*rm_cost[r]; rm_purch_cost += cost
|
| 161 |
+
raw_tbl.append({"Raw": r, "Purchase": purchase, "Consume": cons, "Cost/u": rm_cost[r], "Total Cost": cost})
|
| 162 |
+
|
| 163 |
+
r1_used = float(sum(r1[p]*v(i_prod(p)) for p in P))
|
| 164 |
+
r2_used = float(sum(r2[p]*v(i_prod(p)) for p in P))
|
| 165 |
+
res_tbl = [
|
| 166 |
+
{"Resource": "R1", "Used": r1_used, "Cap": r1_cap, "Slack": r1_cap - r1_used},
|
| 167 |
+
{"Resource": "R2", "Used": r2_used, "Cap": r2_cap, "Slack": r2_cap - r2_used},
|
| 168 |
+
]
|
| 169 |
profit = revenue - conv_cost - rm_purch_cost
|
| 170 |
|
| 171 |
out = {
|
| 172 |
"status": "OPTIMAL",
|
| 173 |
+
"kpis": {"Profit": profit, "Revenue": revenue, "Conv. Cost": conv_cost, "RM Purchase Cost": rm_purch_cost},
|
| 174 |
+
"products": prod_tbl,
|
| 175 |
+
"raw_materials": raw_tbl,
|
| 176 |
+
"resources": res_tbl
|
|
|
|
|
|
|
|
|
|
| 177 |
}
|
| 178 |
return json.dumps(out)
|
| 179 |
|
| 180 |
|
| 181 |
@tool
|
| 182 |
+
def update_sap_md61_tool(forecast_json: str, plant: str = "PLANT01", uom: str = "EA", mrp_area: str = "") -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
"""
|
| 184 |
Prepare an MD61-style demand upload (SIMULATION ONLY).
|
| 185 |
|
| 186 |
Args:
|
| 187 |
forecast_json (str): JSON string returned by forecast_tool.
|
| 188 |
plant (str): SAP plant (WERKS). Defaults to 'PLANT01'.
|
| 189 |
+
uom (str): Unit of measure. Defaults to 'EA'.
|
| 190 |
mrp_area (str): Optional MRP area.
|
| 191 |
|
| 192 |
Returns:
|
| 193 |
+
str: JSON with {"status":"SIMULATED","csv_path":"...","preview":[...]}.
|
| 194 |
"""
|
| 195 |
rows = json.loads(forecast_json)
|
| 196 |
+
md61 = [{
|
| 197 |
+
"Material": r["product_id"], "Plant": plant, "MRP_Area": mrp_area,
|
| 198 |
+
"Req_Date": r["period_start"], "Req_Qty": float(r["forecast_qty"]),
|
| 199 |
+
"UoM": uom, "Version": "00"
|
| 200 |
+
} for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
df = pd.DataFrame(md61)
|
| 202 |
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
|
| 203 |
df.to_csv(tmp.name, index=False)
|
| 204 |
+
return json.dumps({"status": "SIMULATED", "csv_path": tmp.name, "preview": md61[:5]})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
+
|
| 207 |
+
# ==== Agent (end-to-end) ======================================================
|
|
|
|
| 208 |
def make_agent():
|
| 209 |
+
api_key = os.environ.get("OPENAI_API_KEY", "")
|
| 210 |
+
if not api_key:
|
| 211 |
+
raise RuntimeError("OPENAI_API_KEY not set. Add it as a Space secret.")
|
| 212 |
+
model = OpenAIServerModel(model_id="gpt-4o-mini", api_key=api_key, temperature=0)
|
| 213 |
+
return CodeAgent(tools=[forecast_tool, optimize_supply_tool, update_sap_md61_tool],
|
| 214 |
+
model=model, add_base_tools=False, stream_outputs=False)
|
|
|
|
| 215 |
|
| 216 |
SYSTEM_PLAN = (
|
| 217 |
+
"Run the pipeline and return one JSON:\n"
|
| 218 |
+
"1) forecast_tool(...)\n"
|
| 219 |
+
"2) optimize_supply_tool(forecast_json)\n"
|
| 220 |
+
"3) update_sap_md61_tool(forecast_json, ...)\n"
|
| 221 |
+
"Return: {'forecast': <json>, 'plan': <json>, 'md61': <json>}"
|
|
|
|
| 222 |
)
|
| 223 |
|
| 224 |
+
def run_agentic(horizon, plant, use_demo, file_obj):
|
| 225 |
agent = make_agent()
|
| 226 |
if file_obj is not None:
|
| 227 |
+
path = file_obj.name
|
| 228 |
+
prompt = (f"{SYSTEM_PLAN}\n"
|
| 229 |
+
f"Use forecast_tool(horizon_months={int(horizon)}, use_demo=False, history_csv_path='{path}'). "
|
| 230 |
+
f"Then run the other two steps as specified. Return only the final JSON.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
else:
|
| 232 |
+
prompt = (f"{SYSTEM_PLAN}\n"
|
| 233 |
+
f"Use forecast_tool(horizon_months={int(horizon)}, use_demo={bool(use_demo)}). "
|
| 234 |
+
f"Then run the other two steps as specified. Return only the final JSON.")
|
| 235 |
+
return agent.run(prompt)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ==== UI Helpers (pretty outputs) ============================================
|
| 239 |
+
def parse_forecast(json_str):
|
| 240 |
+
df = pd.DataFrame(json.loads(json_str))
|
| 241 |
+
return df[["product_id","period_start","forecast_qty"]].rename(columns={
|
| 242 |
+
"product_id":"Product","period_start":"Period Start","forecast_qty":"Forecast Qty"
|
| 243 |
+
})
|
| 244 |
+
|
| 245 |
+
def parse_plan(json_str):
|
| 246 |
+
d = json.loads(json_str)
|
| 247 |
+
kpis = pd.DataFrame([d["kpis"]]).rename(columns={
|
| 248 |
+
"Conv. Cost":"Conversion Cost", "RM Purchase Cost":"RM Purchase Cost"
|
| 249 |
+
})
|
| 250 |
+
prod = pd.DataFrame(d["products"])
|
| 251 |
+
raw = pd.DataFrame(d["raw_materials"])
|
| 252 |
+
res = pd.DataFrame(d["resources"])
|
| 253 |
+
return d["status"], kpis, prod, raw, res
|
| 254 |
+
|
| 255 |
+
def parse_md61(json_str):
|
| 256 |
+
d = json.loads(json_str)
|
| 257 |
+
prev = pd.DataFrame(d.get("preview", []))
|
| 258 |
+
path = d.get("csv_path", "")
|
| 259 |
+
return d.get("status",""), prev, path
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# ==== Gradio UI ==============================================================
|
| 263 |
+
with gr.Blocks(title="Forecast β Optimize β SAP MD61") as demo:
|
| 264 |
+
gr.Markdown("## π§ Workflow\n"
|
| 265 |
+
"### 1) **Forecast** β 2) **Optimize Supply** β 3) **Prepare MD61**\n"
|
| 266 |
+
"Run them **manually** below, or use the **agent** to do end-to-end in one click.")
|
| 267 |
+
|
| 268 |
+
with gr.Tab("Manual (Step-by-step)"):
|
| 269 |
+
with gr.Row():
|
| 270 |
+
horizon = gr.Number(label="Horizon (months)", value=1, precision=0)
|
| 271 |
+
plant = gr.Textbox(label="SAP Plant (WERKS)", value="PLANT01")
|
| 272 |
+
with gr.Row():
|
| 273 |
+
use_demo = gr.Checkbox(label="Use demo synthetic history", value=True)
|
| 274 |
+
file = gr.File(label="Or upload history.csv (product_id,date,qty)", file_types=[".csv"])
|
| 275 |
+
|
| 276 |
+
# States to pass data between steps
|
| 277 |
+
forecast_state = gr.State("")
|
| 278 |
+
plan_state = gr.State("")
|
| 279 |
+
md61_state = gr.State("")
|
| 280 |
+
|
| 281 |
+
gr.Markdown("### β€ Step 1: Forecast")
|
| 282 |
+
run_f = gr.Button("Run Step 1 β Forecast")
|
| 283 |
+
forecast_tbl = gr.Dataframe(label="Forecast (first horizon month per SKU)", interactive=False)
|
| 284 |
+
forecast_note = gr.Markdown("")
|
| 285 |
+
|
| 286 |
+
gr.Markdown("### β€ Step 2: Optimize Supply")
|
| 287 |
+
run_o = gr.Button("Run Step 2 β Optimize")
|
| 288 |
+
plan_status = gr.Markdown("")
|
| 289 |
+
plan_kpis = gr.Dataframe(label="KPIs", interactive=False)
|
| 290 |
+
plan_prod = gr.Dataframe(label="Products Plan", interactive=False)
|
| 291 |
+
plan_raw = gr.Dataframe(label="Raw Materials", interactive=False)
|
| 292 |
+
plan_res = gr.Dataframe(label="Resources", interactive=False)
|
| 293 |
+
|
| 294 |
+
gr.Markdown("### β€ Step 3: Prepare MD61 (Simulated)")
|
| 295 |
+
run_m = gr.Button("Run Step 3 β MD61")
|
| 296 |
+
md61_status = gr.Markdown("")
|
| 297 |
+
md61_prev = gr.Dataframe(label="MD61 Preview", interactive=False)
|
| 298 |
+
md61_file = gr.File(label="Download CSV", interactive=False)
|
| 299 |
+
|
| 300 |
+
# Handlers
|
| 301 |
+
def do_forecast(h, demo_flag, f):
|
| 302 |
+
hist_path = "" if (f is None) else f.name
|
| 303 |
+
fj = forecast_tool(horizon_months=int(h), use_demo=(f is None) and bool(demo_flag),
|
| 304 |
+
history_csv_path=hist_path)
|
| 305 |
+
df = parse_forecast(fj)
|
| 306 |
+
return fj, df, f"Forecast generated for {df['Product'].nunique()} product(s)."
|
| 307 |
+
|
| 308 |
+
run_f.click(do_forecast, inputs=[horizon, use_demo, file], outputs=[forecast_state, forecast_tbl, forecast_note])
|
| 309 |
+
|
| 310 |
+
def do_optimize(fj):
|
| 311 |
+
if not fj:
|
| 312 |
+
return "", pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), "β οΈ Run Step 1 first."
|
| 313 |
+
pj = optimize_supply_tool(fj)
|
| 314 |
+
status, kpis, prod, raw, res = parse_plan(pj)
|
| 315 |
+
return pj, kpis, prod, raw, res, f"Optimization status: **{status}**"
|
| 316 |
+
|
| 317 |
+
run_o.click(do_optimize, inputs=[forecast_state],
|
| 318 |
+
outputs=[plan_state, plan_kpis, plan_prod, plan_raw, plan_res, plan_status])
|
| 319 |
+
|
| 320 |
+
def do_md61(fj, plant):
|
| 321 |
+
if not fj:
|
| 322 |
+
return "", pd.DataFrame(), None, "β οΈ Run Step 1 first."
|
| 323 |
+
mj = update_sap_md61_tool(fj, plant=plant, uom="EA")
|
| 324 |
+
status, prev, path = parse_md61(mj)
|
| 325 |
+
return mj, prev, path, f"MD61 status: **{status}**"
|
| 326 |
+
|
| 327 |
+
run_m.click(do_md61, inputs=[forecast_state, plant], outputs=[md61_state, md61_prev, md61_file, md61_status])
|
| 328 |
+
|
| 329 |
+
with gr.Tab("Agentic (End-to-end)"):
|
| 330 |
+
gr.Markdown("One click: the agent runs all three steps with OpenAI.")
|
| 331 |
+
with gr.Row():
|
| 332 |
+
a_horizon = gr.Number(label="Horizon (months)", value=1, precision=0)
|
| 333 |
+
a_plant = gr.Textbox(label="SAP Plant (WERKS)", value="PLANT01")
|
| 334 |
+
with gr.Row():
|
| 335 |
+
a_demo = gr.Checkbox(label="Use demo synthetic history", value=True)
|
| 336 |
+
a_file = gr.File(label="Or upload history.csv", file_types=[".csv"])
|
| 337 |
+
run_all = gr.Button("Run End-to-end (Agent)")
|
| 338 |
+
out_json = gr.Textbox(label="Agent Raw JSON (for inspection)", lines=6)
|
| 339 |
+
with gr.Accordion("Pretty Outputs", open=True):
|
| 340 |
+
a_forecast_tbl = gr.Dataframe(label="Forecast", interactive=False)
|
| 341 |
+
a_plan_kpis = gr.Dataframe(label="KPIs", interactive=False)
|
| 342 |
+
a_plan_prod = gr.Dataframe(label="Products Plan", interactive=False)
|
| 343 |
+
a_plan_raw = gr.Dataframe(label="Raw Materials", interactive=False)
|
| 344 |
+
a_plan_res = gr.Dataframe(label="Resources", interactive=False)
|
| 345 |
+
a_md61_prev = gr.Dataframe(label="MD61 Preview", interactive=False)
|
| 346 |
+
a_md61_file = gr.File(label="Download MD61 CSV", interactive=False)
|
| 347 |
+
|
| 348 |
+
def do_agent(h, p, demo_flag, f):
|
| 349 |
+
try:
|
| 350 |
+
res = run_agentic(h, p, demo_flag, f)
|
| 351 |
+
out = json.loads(res)
|
| 352 |
+
f_df = parse_forecast(out["forecast"])
|
| 353 |
+
_, kpis, prod, raw, res_tbl = parse_plan(out["plan"])
|
| 354 |
+
_, prev, csv_path = parse_md61(out["md61"])
|
| 355 |
+
return (json.dumps(out, indent=2), f_df, kpis, prod, raw, res_tbl, prev, csv_path)
|
| 356 |
+
except Exception as e:
|
| 357 |
+
return (f"Agent error: {e}", pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(),
|
| 358 |
+
pd.DataFrame(), pd.DataFrame(), None)
|
| 359 |
+
|
| 360 |
+
run_all.click(do_agent, inputs=[a_horizon, a_plant, a_demo, a_file],
|
| 361 |
+
outputs=[out_json, a_forecast_tbl, a_plan_kpis, a_plan_prod, a_plan_raw, a_plan_res, a_md61_prev, a_md61_file])
|
| 362 |
|
| 363 |
if __name__ == "__main__":
|
| 364 |
demo.launch()
|