PD03 commited on
Commit
7c5d537
·
verified ·
1 Parent(s): bd80cb5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +96 -69
app.py CHANGED
@@ -445,64 +445,58 @@ def plan_explainer_tool(plan_json: str) -> str:
445
  def bottleneck_search_tool(forecast_json: str, policy_json: str = "") -> str:
446
  """
447
  Find the best practical lever to relax (within ~1 month) via small scenario probes.
 
448
 
449
  Args:
450
  forecast_json (str): JSON from forecast_tool (first month per SKU used).
451
- policy_json (str): Optional JSON with relaxable levers and costs.
452
- Defaults include overtime and RM expedite; blocks factory expansion.
453
 
454
  Returns:
455
- str: JSON {"status":"OK","recommendations":[{lever, recommended, est_profit_lift, est_net_gain, rationale}]}
456
  """
 
 
 
457
  # --- Defaults (edit to your reality) ---
458
  policy = {
459
- "R1_overtime": {"type":"resource","target":"R1","step":10.0,"max_delta":80.0,"unit_cost":600.0},
460
- "R2_overtime": {"type":"resource","target":"R2","step":10.0,"max_delta":40.0,"unit_cost":800.0},
461
  "RM_A_expedite": {"type":"rm_expedite","target":"RM_A","step":200.0,"max_delta":1500.0,"unit_premium":8.0},
462
  "RM_B_expedite": {"type":"rm_expedite","target":"RM_B","step":100.0,"max_delta":800.0,"unit_premium":12.0},
463
  "Factory_expansion": {"type":"blocked","reason":"Not relaxable within 1 month"}
 
 
464
  }
465
  if policy_json:
466
- try:
467
- policy.update(json.loads(policy_json))
468
- except Exception:
469
- pass
470
-
471
- # --- 1) Baseline solve ---
472
- base_plan = json.loads(optimize_supply_tool(forecast_json))
473
- if base_plan.get("status") != "OPTIMAL":
474
- return json.dumps({"status":"FAILED","message":"Baseline plan not optimal."})
475
- base_profit = float(base_plan["kpis"]["Profit"])
476
-
477
- # --- 2) Helper to run a perturbed solve ---
478
- def run_with(delta_map):
479
- from scipy.optimize import linprog
480
 
481
- rows = json.loads(forecast_json)
482
- demand = {}
483
- for r in rows:
484
- p = r["product_id"]
485
- if p not in demand:
486
- demand[p] = float(r["forecast_qty"])
487
- P = sorted(demand.keys()) or ["FG100","FG200"]
488
- price = {"FG100":98.0,"FG200":120.0}
489
- conv = {"FG100":12.5,"FG200":15.0}
490
- r1 = {"FG100":0.03,"FG200":0.05}
491
- r2 = {"FG100":0.02,"FG200":0.01}
492
- RMs = ["RM_A","RM_B"]
493
- rm_cost = {"RM_A":20.0,"RM_B":30.0}
494
- rm_start = {"RM_A":1000.0,"RM_B":100.0}
495
- rm_cap = {"RM_A":5000.0,"RM_B":5000.0}
496
- bom = {"FG100":{"RM_A":0.8,"RM_B":0.204},"FG200":{"RM_A":1.0,"RM_B":0.1}}
497
-
498
- # Apply deltas
499
- r1_cap = 320.0 + delta_map.get("R1_cap",0.0)
500
- r2_cap = 480.0 + delta_map.get("R2_cap",0.0)
501
- rm_cap_adj = {
502
- "RM_A": rm_cap["RM_A"] + delta_map.get("RM_A_cap",0.0),
503
- "RM_B": rm_cap["RM_B"] + delta_map.get("RM_B_cap",0.0),
504
- }
505
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  nP, nR = len(P), len(RMs)
507
  pidx = {p:i for i,p in enumerate(P)}; ridx = {r:i for i,r in enumerate(RMs)}
508
  def i_prod(p): return pidx[p]
@@ -510,6 +504,7 @@ def bottleneck_search_tool(forecast_json: str, policy_json: str = "") -> str:
510
  def i_einv(p): return 2*nP + pidx[p]
511
  def i_pur(r): return 3*nP + ridx[r]
512
  def i_einr(r): return 3*nP + nR + ridx[r]
 
513
  n_vars = 3*nP + 2*nR
514
  c = np.zeros(n_vars); bounds=[None]*n_vars
515
 
@@ -517,7 +512,7 @@ def bottleneck_search_tool(forecast_json: str, policy_json: str = "") -> str:
517
  c[i_prod(p)] += conv[p]
518
  c[i_sell(p)] -= price[p]
519
  bounds[i_prod(p)] = (0,None)
520
- bounds[i_sell(p)] = (0,demand[p])
521
  bounds[i_einv(p)] = (0,None)
522
  for r in RMs:
523
  c[i_pur(r)] += rm_cost[r]
@@ -543,54 +538,86 @@ def bottleneck_search_tool(forecast_json: str, policy_json: str = "") -> str:
543
  return None
544
  x = res.x
545
  def v(idx): return float(x[idx])
 
546
  revenue = sum(price[p]*v(i_sell(p)) for p in P)
547
  conv_cost = sum(conv[p]*v(i_prod(p)) for p in P)
548
  rm_purch_cost = sum(v(i_pur(r))*rm_cost[r] for r in RMs)
549
- profit = revenue - conv_cost - rm_purch_cost
550
- return profit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
 
552
- # --- 3) Probe levers; compute net gain ---
553
  recs = []
554
  for name, cfg in policy.items():
555
  if cfg.get("type") == "blocked":
556
  recs.append({"lever":name,"recommended":0,"est_profit_lift":0,"est_net_gain":0,
557
  "rationale":cfg.get("reason","Not feasible in horizon")})
558
  continue
559
- step = float(cfg["step"]); maxd = float(cfg["max_delta"])
560
- best_gain = 0.0; best_delta = 0.0; best_profit_lift = 0.0
 
561
  d = 0.0
562
  while d <= maxd + 1e-6:
563
- delta_map = {}
 
 
564
  relax_cost = 0.0
565
- if cfg["type"] == "resource" and cfg["target"]=="R1":
566
- delta_map["R1_cap"] = d; relax_cost = d * float(cfg["unit_cost"])
567
- elif cfg["type"] == "resource" and cfg["target"]=="R2":
568
- delta_map["R2_cap"] = d; relax_cost = d * float(cfg["unit_cost"])
569
- elif cfg["type"] == "rm_expedite":
570
- key = "RM_A_cap" if cfg["target"]=="RM_A" else "RM_B_cap"
571
- delta_map[key] = d; relax_cost = d * float(cfg["unit_premium"])
 
 
 
 
 
 
 
 
 
 
572
  else:
573
  d += step; continue
574
 
575
- prof = run_with(delta_map)
576
- if prof is None:
577
  d += step; continue
578
- lift = prof - base_profit
579
  net = lift - relax_cost
580
  if net > best_gain:
581
- best_gain, best_delta, best_profit_lift = net, d, lift
582
  d += step
583
 
584
- rationale = ("Beneficial at small Δ" if best_gain>0 else "No positive net gain within feasible range")
585
- recs.append({"lever":name,"recommended":best_delta,"est_profit_lift":best_profit_lift,
586
- "est_net_gain":best_gain,"rationale":rationale})
 
 
 
 
587
 
588
  recs.sort(key=lambda r: r["est_net_gain"], reverse=True)
589
- for r in recs:
590
- for k in ["recommended","est_profit_lift","est_net_gain"]:
591
- if r[k] is not None:
592
- r[k] = round(float(r[k]), 2)
593
- return json.dumps({"status":"OK","recommendations":recs})
594
 
595
  # ==== Agent (end-to-end) ======================================================
596
  def make_agent():
 
445
  def bottleneck_search_tool(forecast_json: str, policy_json: str = "") -> str:
446
  """
447
  Find the best practical lever to relax (within ~1 month) via small scenario probes.
448
+ Supports resource overtime, RM expedite (cap), and demand_lift (promo ROI-checked).
449
 
450
  Args:
451
  forecast_json (str): JSON from forecast_tool (first month per SKU used).
452
+ policy_json (str): Optional JSON with levers and costs.
 
453
 
454
  Returns:
455
+ str: JSON {"status":"OK","diagnostics":{...},"recommendations":[...]}
456
  """
457
+ import json, numpy as np
458
+ from scipy.optimize import linprog
459
+
460
  # --- Defaults (edit to your reality) ---
461
  policy = {
462
+ "R1_overtime": {"type":"resource","target":"R1","step":10.0,"max_delta":80.0,"unit_cost":600.0},
463
+ "R2_overtime": {"type":"resource","target":"R2","step":10.0,"max_delta":40.0,"unit_cost":800.0},
464
  "RM_A_expedite": {"type":"rm_expedite","target":"RM_A","step":200.0,"max_delta":1500.0,"unit_premium":8.0},
465
  "RM_B_expedite": {"type":"rm_expedite","target":"RM_B","step":100.0,"max_delta":800.0,"unit_premium":12.0},
466
  "Factory_expansion": {"type":"blocked","reason":"Not relaxable within 1 month"}
467
+ # You can add demand levers via policy_json:
468
+ # "promo_FG100": {"type":"demand_lift","target":"FG100","step":100,"max_delta":600,"unit_cost":10}
469
  }
470
  if policy_json:
471
+ try: policy.update(json.loads(policy_json))
472
+ except Exception: pass
 
 
 
 
 
 
 
 
 
 
 
 
473
 
474
+ # --- Problem primitives (same as your optimizer) ---
475
+ rows = json.loads(forecast_json)
476
+ demand = {}
477
+ for r in rows:
478
+ p = r["product_id"]
479
+ if p not in demand:
480
+ demand[p] = float(r["forecast_qty"])
481
+ P = sorted(demand.keys()) or ["FG100","FG200"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
 
483
+ price = {"FG100":98.0,"FG200":120.0}
484
+ conv = {"FG100":12.5,"FG200":15.0}
485
+ r1 = {"FG100":0.03,"FG200":0.05}
486
+ r2 = {"FG100":0.02,"FG200":0.01}
487
+ RMs = ["RM_A","RM_B"]
488
+ rm_cost = {"RM_A":20.0,"RM_B":30.0}
489
+ rm_start = {"RM_A":1000.0,"RM_B":100.0}
490
+ rm_cap = {"RM_A":5000.0,"RM_B":5000.0}
491
+ bom = {"FG100":{"RM_A":0.8,"RM_B":0.204},"FG200":{"RM_A":1.0,"RM_B":0.1}}
492
+ r1_cap0, r2_cap0 = 320.0, 480.0
493
+
494
+ # Helpful unit margin for demand_lift (approx variable cost)
495
+ unit_var_cost = {p: conv[p] + sum(bom[p].get(rm,0.0)*rm_cost[rm] for rm in RMs) for p in P}
496
+ unit_margin = {p: price[p] - unit_var_cost[p] for p in P}
497
+
498
+ # --- LP builder (inner solve) ---
499
+ def solve(dem, r1_cap, r2_cap, rm_cap_adj):
500
  nP, nR = len(P), len(RMs)
501
  pidx = {p:i for i,p in enumerate(P)}; ridx = {r:i for i,r in enumerate(RMs)}
502
  def i_prod(p): return pidx[p]
 
504
  def i_einv(p): return 2*nP + pidx[p]
505
  def i_pur(r): return 3*nP + ridx[r]
506
  def i_einr(r): return 3*nP + nR + ridx[r]
507
+
508
  n_vars = 3*nP + 2*nR
509
  c = np.zeros(n_vars); bounds=[None]*n_vars
510
 
 
512
  c[i_prod(p)] += conv[p]
513
  c[i_sell(p)] -= price[p]
514
  bounds[i_prod(p)] = (0,None)
515
+ bounds[i_sell(p)] = (0, dem[p])
516
  bounds[i_einv(p)] = (0,None)
517
  for r in RMs:
518
  c[i_pur(r)] += rm_cost[r]
 
538
  return None
539
  x = res.x
540
  def v(idx): return float(x[idx])
541
+
542
  revenue = sum(price[p]*v(i_sell(p)) for p in P)
543
  conv_cost = sum(conv[p]*v(i_prod(p)) for p in P)
544
  rm_purch_cost = sum(v(i_pur(r))*rm_cost[r] for r in RMs)
545
+ r1_used = sum(r1[p]*v(i_prod(p)) for p in P)
546
+ r2_used = sum(r2[p]*v(i_prod(p)) for p in P)
547
+ return {
548
+ "profit": revenue - conv_cost - rm_purch_cost,
549
+ "r1_used": r1_used, "r2_used": r2_used,
550
+ }
551
+
552
+ # Baseline
553
+ base = solve(demand, r1_cap0, r2_cap0, rm_cap.copy())
554
+ if base is None:
555
+ return json.dumps({"status":"FAILED","message":"Baseline infeasible."})
556
+ base_profit = float(base["profit"])
557
+ diag = {
558
+ "demand_met": True, # sells hit demand ceiling in this model
559
+ "r1_slack": round(r1_cap0 - base["r1_used"], 2),
560
+ "r2_slack": round(r2_cap0 - base["r2_used"], 2),
561
+ "rm_caps_hit": {rm: False for rm in RMs}
562
+ }
563
+ # If everything is slack, tell the user plainly
564
+ all_slack = (diag["r1_slack"] > 1e-3 and diag["r2_slack"] > 1e-3)
565
 
566
+ # --- Probe levers ---
567
  recs = []
568
  for name, cfg in policy.items():
569
  if cfg.get("type") == "blocked":
570
  recs.append({"lever":name,"recommended":0,"est_profit_lift":0,"est_net_gain":0,
571
  "rationale":cfg.get("reason","Not feasible in horizon")})
572
  continue
573
+
574
+ best_gain = 0.0; best_delta = 0.0; best_lift = 0.0
575
+ step = float(cfg.get("step",0)); maxd = float(cfg.get("max_delta",0))
576
  d = 0.0
577
  while d <= maxd + 1e-6:
578
+ dem = demand.copy()
579
+ r1_cap = r1_cap0; r2_cap = r2_cap0
580
+ rm_cap_adj = rm_cap.copy()
581
  relax_cost = 0.0
582
+
583
+ t = cfg["type"]
584
+ if t == "resource" and cfg["target"] == "R1":
585
+ r1_cap += d; relax_cost = d * float(cfg["unit_cost"])
586
+ elif t == "resource" and cfg["target"] == "R2":
587
+ r2_cap += d; relax_cost = d * float(cfg["unit_cost"])
588
+ elif t == "rm_expedite":
589
+ key = "RM_A" if cfg["target"]=="RM_A" else "RM_B"
590
+ rm_cap_adj[key] = rm_cap[key] + d
591
+ relax_cost = d * float(cfg["unit_premium"])
592
+ elif t == "demand_lift":
593
+ sku = cfg["target"]
594
+ if sku in dem:
595
+ dem[sku] = dem[sku] + d
596
+ relax_cost = d * float(cfg["unit_cost"])
597
+ else:
598
+ d += step; continue
599
  else:
600
  d += step; continue
601
 
602
+ res = solve(dem, r1_cap, r2_cap, rm_cap_adj)
603
+ if res is None:
604
  d += step; continue
605
+ lift = float(res["profit"] - base_profit)
606
  net = lift - relax_cost
607
  if net > best_gain:
608
+ best_gain, best_delta, best_lift = net, d, lift
609
  d += step
610
 
611
+ rationale = ("Beneficial at small Δ" if best_gain>0 else
612
+ ("Capacity not binding; try demand/cost levers" if all_slack else
613
+ "No positive net gain within feasible range"))
614
+ recs.append({"lever":name,"recommended":round(best_delta,2),
615
+ "est_profit_lift":round(best_lift,2),
616
+ "est_net_gain":round(best_gain,2),
617
+ "rationale":rationale})
618
 
619
  recs.sort(key=lambda r: r["est_net_gain"], reverse=True)
620
+ return json.dumps({"status":"OK","diagnostics":diag,"recommendations":recs})
 
 
 
 
621
 
622
  # ==== Agent (end-to-end) ======================================================
623
  def make_agent():