Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
mobook
GitHub Repository: mobook/mo-book
Path: blob/main/notebooks/07/02-bim-robustness-analysis.ipynb
663 views
Kernel: Python 3 (ipykernel)

7.2 Robustness analysis of BIM production plan via simulations

Preamble: Install Pyomo and a solver

The following cell sets and verifies a global SOLVER for the notebook. If run on Google Colab, the cell installs Pyomo and the HiGHS solver, while, if run elsewhere, it assumes Pyomo and HiGHS have been previously installed. It then sets to use HiGHS as solver via the appsi module and a test is performed to verify that it is available. The solver interface is stored in a global object SOLVER for later use.

import sys if 'google.colab' in sys.modules: %pip install pyomo >/dev/null 2>/dev/null %pip install highspy >/dev/null 2>/dev/null solver = 'appsi_highs' import pyomo.environ as pyo SOLVER = pyo.SolverFactory(solver) assert SOLVER.available(), f"Solver {solver} is not available."

Problem description

This example is a continuation of the BIM chip production problem illustrated here. Recall hat BIM produces logic and memory chips using copper, silicon, germanium, and plastic and that each chip requires the following quantities of raw materials:

chipcoppersilicongermaniumplastic
logic0.41-1
memory0.2-11

BIM needs to carefully manage the acquisition and inventory of these raw materials based on the forecasted demand for the chips. Data analysis led to the following prediction of monthly demands:

chipJanFebMarAprMayJunJulAugSepOctNovDec
logic88125260217238286248238265293259244
memory4762816595118868982828466

At the beginning of the year, BIM has the following stock:

coppersilicongermaniumplastic
480100015001750

The company would like to have at least the following stock at the end of the year:

coppersilicongermaniumplastic
2005005001000

Each raw material can be acquired at each month, but the unit prices vary as follows:

productJanFebMarAprMayJunJulAugSepOctNovDec
copper111223322112
silicon433355654335
germanium555333323456
plastic0.10.10.10.10.10.10.10.10.10.10.10.1

The inventory is limited by a capacity of a total of 9000 units per month, regardless of the type of material of products in stock. The holding costs of the inventory are 0.05 per unit per month regardless of the material type. Due to budget constraints, BIM cannot spend more than 5000 per month on acquisition.

BIM aims at minimizing the acquisition and holding costs of the materials while meeting the required quantities for production. The production is made to order, meaning that no inventory of chips is kept.

Let us model the material acquisition planning and solve it optimally based on the forecasted chip demand above.

from io import StringIO import pandas as pd import numpy as np import matplotlib.pyplot as plt demand_data = """ chip, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec logic, 88, 125, 260, 217, 238, 286, 248, 238, 265, 293, 259, 244 memory, 47, 62, 81, 65, 95, 118, 86, 89, 82, 82, 84, 66 """ demand_chips = pd.read_csv(StringIO(demand_data), index_col="chip") display(demand_chips) price_data = """ product, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec copper, 1, 1, 1, 2, 2, 3, 3, 2, 2, 1, 1, 2 silicon, 4, 3, 3, 3, 5, 5, 6, 5, 4, 3, 3, 5 germanium, 5, 5, 5, 3, 3, 3, 3, 2, 3, 4, 5, 6 plastic, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1 """ price = pd.read_csv(StringIO(price_data), index_col="product") display(price) use = dict() use["logic"] = {"silicon": 1, "plastic": 1, "copper": 4} use["memory"] = {"germanium": 1, "plastic": 1, "copper": 2} use = pd.DataFrame.from_dict(use).fillna(0).astype(int) material_demand = use.dot(demand_chips) existing = pd.Series( {"silicon": 1000, "germanium": 1500, "plastic": 1750, "copper": 4800} ) eot_inventory = pd.Series( {"silicon": 500, "germanium": 500, "plastic": 1000, "copper": 2000} )
# we store all the problem data in one object to easily perform folding-horizon simulations def initialize_problem_data(): problem_data = { "price": price.copy(deep=True), "inventory_cost": 0.05, "material_demand": material_demand.copy(deep=True), "demand_chips_ref": demand_chips.copy(deep=True), "demand_chips_simulation": demand_chips.copy(deep=True), "use": use.copy(deep=True), "existing": existing.copy(deep=True), "eot_inventory": eot_inventory.copy(deep=True), "stock_limit": 9000, "month_budget": 2500, } return problem_data def ShowTableOfPyomoVariables(X, I, J): return pd.DataFrame.from_records( [[pyo.value(X[i, j]) for j in J] for i in I], index=I, columns=J ).round(decimals=2) def BIMProductAcquisitionAndInventory(problem_data): demand = problem_data["use"].dot(problem_data["demand_chips_ref"]) acquisition_price = problem_data["price"] existing = problem_data["existing"] desired = problem_data["eot_inventory"] stock_limit = problem_data["stock_limit"] month_budget = problem_data["month_budget"] m = pyo.ConcreteModel("Product acquisition and inventory") periods = demand.columns products = demand.index first = periods[0] prev = {j: i for i, j in zip(periods, periods[1:])} last = periods[-1] m.T = pyo.Set(initialize=periods) m.P = pyo.Set(initialize=products) m.PT = m.P * m.T # to avoid internal set bloat m.x = pyo.Var(m.PT, domain=pyo.NonNegativeReals) m.s = pyo.Var(m.PT, domain=pyo.NonNegativeReals) @m.Param(m.PT) def pi(m, p, t): return acquisition_price.loc[p][t] @m.Param(m.PT) def h(m, p, t): return 0.05 # the holding cost @m.Param(m.PT) def delta(m, p, t): return demand.loc[p, t] @m.Expression() def acquisition_cost(m): return pyo.quicksum(m.pi[p, t] * m.x[p, t] for p in m.P for t in m.T) @m.Expression() def inventory_cost(m): return pyo.quicksum(m.h[p, t] * m.s[p, t] for p in m.P for t in m.T) @m.Objective(sense=pyo.minimize) def total_cost(m): return m.acquisition_cost + m.inventory_cost @m.Constraint(m.PT) def balance(m, p, t): if t == first: return existing[p] + m.x[p, t] == m.delta[p, t] + m.s[p, t] else: return m.x[p, t] + m.s[p, prev[t]] == m.delta[p, t] + m.s[p, t] @m.Constraint(m.P) def finish(m, p): return m.s[p, last] >= desired[p] @m.Constraint(m.T) def inventory(m, t): return pyo.quicksum(m.s[p, t] for p in m.P) <= stock_limit @m.Constraint(m.T) def budget(m, t): return pyo.quicksum(m.pi[p, t] * m.x[p, t] for p in m.P) <= month_budget return m problem_data = initialize_problem_data() m = BIMProductAcquisitionAndInventory(problem_data) SOLVER.solve(m) print(f"The optimal solution yields a total cost of {pyo.value(m.total_cost):.2f}\n") problem_data["purchases"] = ShowTableOfPyomoVariables(m.x, m.P, m.T) problem_data["stock"] = ShowTableOfPyomoVariables(m.s, m.P, m.T) display(problem_data["purchases"]) display(problem_data["stock"])
The optimal solution yields a total cost of 23580.34

Actual performance of the robust solution

We now perform a stochastic simulation to assess the performance of the robust solutions that we found earlier.

def minimize_missed_demand_in_period( inventory, missed_demand, purchases, existing, demand_chips, use, period=None ): m = pyo.ConcreteModel("Missed demand in period") periods = inventory.columns first = periods[0] prev = {j: i for i, j in zip(periods, periods[1:])} last = periods[-1] m.P = pyo.Set(initialize=list(use.columns)) m.M = pyo.Set(initialize=list(use.index)) # Decision variable: nb of chips to produce >= 0 m.x = pyo.Var(m.P, domain=pyo.NonNegativeReals) # Decision variable: missed demand m.s = pyo.Var(m.P, domain=pyo.NonNegativeReals) # Constraint: per resource we cannot use more than there is @m.Constraint(m.M) def resource_constraint(m, i): return ( pyo.quicksum(m.x[p] * use.loc[i, p] for p in m.P) <= inventory.loc[i, prev[period]] + purchases.loc[i, period] ) # Constraint: production + missed demand = total demand in this period @m.Constraint(m.P) def produced_plus_unmet(m, p): return m.x[p] + m.s[p] == demand_chips.loc[p, period] # Objective - minimize the missed demand @m.Objective(sense=pyo.minimize) def total_unmet(m): return pyo.quicksum(m.s[p] for p in m.P) SOLVER.solve(m) # update inventory for i in m.M: inventory.loc[i, period] = ( inventory.loc[i, prev[period]] + purchases.loc[i, period] - sum([pyo.value(m.x[p]) * use.loc[i, p] for p in m.P]) ) # update missed demand for p in m.P: missed_demand.loc[p, period] = pyo.value(m.s[p]) return 0 def simulation_per_trajectory(purchases, existing, demand_chips, use): # Set up the table to store inventory evolution inventory = pd.DataFrame(index=purchases.index, columns=purchases.columns) inventory = pd.concat( [pd.DataFrame(existing, index=existing.index, columns=["existing"]), inventory], axis=1, ) # Set up the DF to store missed demand information missed_demand = pd.DataFrame( np.zeros((len(demand_chips.index), len(purchases.columns))), index=demand_chips.index, columns=purchases.columns, ) # Proper simulation for period in inventory.columns[1:]: minimize_missed_demand_in_period( inventory, missed_demand, purchases, existing, demand_chips, use, period ) return inventory.iloc[:, 1:], missed_demand def simulate_performance(problem_data, n=50, rho=0.05, seed=0): rng = np.random.default_rng(seed) results = [] for i in range(n): perturbed_demand = problem_data["demand_chips_simulation"].applymap( lambda x: x * (1 + rho * (1 - 2 * rng.random())) ) inv, md = simulation_per_trajectory( problem_data["purchases"], problem_data["existing"], perturbed_demand, use ) results.append({"inventory": inv, "missing_demand": md}) MissingDemand = pd.concat( [i["missing_demand"] for i in results], keys=[i for i in range(len(results))] ) MissingDemand = MissingDemand.astype("float").swaplevel() InventoryEvolution = pd.concat( [i["inventory"] for i in results], keys=[i for i in range(len(results))] ) InventoryEvolution = InventoryEvolution.astype("float").swaplevel() return {"MissingDemand": MissingDemand, "InventoryEvolution": InventoryEvolution} def report(MissingDemand, InventoryEvolution, problem_data): # list to store DFs with per-group computed quantiles at various levels average_missed_demand = MissingDemand.groupby(level=0).mean().transpose() # figure settings colors = plt.cm.tab20c plt.rcParams.update({"font.size": 14}) # build a plot with as many subplots as there are chip types fig, axis = plt.subplots(figsize=(10, 5)) average_missed_demand.plot( ax=axis, drawstyle="steps-mid", grid=True, lw=2, color=[colors(0), colors(4)], alpha=0.9, ) plt.xticks( ticks=np.arange(len(average_missed_demand.index)), labels=average_missed_demand.index, ) # axis.set_title("Missed demand of chips under " + str(rho * 100) + "% uncertainty") axis.set_xlabel("Month") axis.set_ylabel("Average missed demand") axis.legend(["Logic chips", "Memory chips"], loc="upper left") plt.tight_layout() plt.savefig( "bim_robust_missed_demand.svg", format="svg", dpi=300, bbox_inches="tight" ) realized_inv_cost = ( InventoryEvolution.groupby(level=0).mean().sum(axis=1).sum() * problem_data["inventory_cost"] ) print( f'Purchasing cost: {(problem_data["price"] * problem_data["purchases"]).sum().sum():.2f}' ) print( f'Theoretical inventory cost: {(problem_data["stock"] * problem_data["inventory_cost"]).sum().sum():.2f}\n' ) print(f"Simulated realized inventory cost: {realized_inv_cost:.2f}") print( f"Simulated average missed demand for logic chips is {MissingDemand.groupby(level = 0).mean().sum(axis = 1)[0]:.3f} and for memory chips is {MissingDemand.groupby(level = 0).mean().sum(axis = 1)[1]:.3f}\n" ) print( "Plot of missed chips demand over time under " + str(rho * 100) + "% uncertainty\n" ) plt.show()

We now simulate 50 trajectories assuming there is a ρ=8%\rho=8\% uncertainty around the nominal demand.

rho = 0.08 N_sim = 50 problem_data["demand_chips_ref"] = demand_chips m = BIMProductAcquisitionAndInventory(problem_data) SOLVER.solve(m) problem_data["purchases"] = ShowTableOfPyomoVariables(m.x, m.P, m.T) problem_data["stock"] = ShowTableOfPyomoVariables(m.s, m.P, m.T) SimResults = simulate_performance(problem_data, N_sim, rho)

The simulation results report a sllighly higher inventory cost and a nonzero amount of missed chip demand.

report(SimResults["MissingDemand"], SimResults["InventoryEvolution"], problem_data)
Purchasing cost: 20309.77 Theoretical inventory cost: 3270.57 Simulated realized inventory cost: 3309.54 Simulated average missed demand for logic chips is 4.543 and for memory chips is 13.154 Plot of missed chips demand over time under 8.0% uncertainty
Image in a Jupyter notebook