Release 0.0.2 Deep Dive

by Kevin Fasusi |

Release 0.0.2 sees the introduction of the Monte Carlo simulation as a feature of the library. Using historical data supplied from a .csv, probable transactions are simulated. The simulation uses normally distributed random demand, to decrement stock over a given period. Inventory movement trigger consumption of stock and affect the transactions for opening stock, closing stock, backlog and purchase orders.

Running the Monte Carlo simulation is as simple as:

from decimal import Decimal
from supplychainpy import simulate
from supplychainpy import model_inventory

orders_analysis = model_inventory.analyse_orders_abcxyz_from_file(file_path="data.csv",
z_value=Decimal(1.28),
reorder_cost=Decimal(5000),
file_type="csv")

sim = simulate.run_monte_carlo(orders_analysis=orders_analysis.orders, runs=10, period_length=12)

sim_window = simulate.summarize_window(simulation_frame=sim, period_length=12)

sim_frame = simulate.summarise_frame(sim_window)

optimised = simulate.optimise_service_level(service_level=90.0,
frame_summary=sim_frame,
orders_analysis=orders_analysis.orders,
runs=10, percentage_increase=3.0)

Running the Simulation

Before running the simulation, an analysis of the inventory profile is required. The series supplied by the .csv file, an example of the format is here. A detailed breakdown of the model_inventory.analyse_orders_abcxyz_from_file function can be found in the release-0.0.1 deep dive.

Now that we have the demand profile analysed, we can run the Monte Carlo. The interface for the Monte Carlo is in the simulate.py module. In the simulate.run_monte_carlo function, simulation instantiates SetupMonteCarlo class.

from decimal import Decimal

from supplychainpy import simulate
from supplychainpy import model_inventory

orders_analysis = model_inventory.analyse_orders_abcxyz_from_file(file_path="data.csv",
z_value=Decimal(1.28),
reorder_cost=Decimal(5000),
file_type="csv")

sim = simulate.run_monte_carlo(orders_analysis=orders_analysis.orders, runs=10, period_length=12)

Generate a Random Normal Distribution for Demand

The generate_normal_random_distribution method, generates the normally distributed random demand for each sku.

def generate_normal_random_distribution(self, period_length: int) -> list:
""" Generates the random demand for a given sku.
For each sku a set of random demands are calculated based on the normal distribution of
demand for this product.

Args:
period_length (int): length of window e.g. 12 weeks for a quarter etc.

Returns:
list: A list of randomly generated demand.

Raises:
ValueError:
"""

orders_normal_distribution = {}
random_orders_generator = []
final_random_orders_generator = []
for sku in self._analysed_orders:
for i in range(0, period_length):
nrd_orders = np.random.normal(loc=sku.average_orders,
scale=sku.standard_deviation,
size=sku.order_count)

random_orders_generator.append(abs(np.array(nrd_orders)).tolist())
orders_normal_distribution[sku.sku_id] = random_orders_generator
random_orders_generator = []
final_random_orders_generator.append(orders_normal_distribution)
return final_random_orders_generator

Build Transaction Summary

The function simulation.build_window is a generator that yields the simulation_window.MonteCarloWindow object. The run_monte_carlo method is displayed below.

def run_monte_carlo(orders_analysis: list, runs: int, period_length: int = 12) -> list:

Transaction_report = []
# add shortage cost,
for k in range(0, runs):
simulation = monte_carlo.SetupMonteCarlo(analysed_orders=orders_analysis)
random_demand = simulation.generate_normal_random_distribution(period_length=period_length)
for sim_window in simulation.build_window(random_normal_demand=random_demand,
period_length=period_length):
sim_dict = {"index": "{}".format(sim_window.index),
"period": "{}".format(sim_window.position),
"sku_id": sim_window.sku_id,
"opening_stock": "{}".format(round(sim_window.opening_stock)),
"demand": "{}".format(round(sim_window.demand)),
"closing_stock": "{}".format(round(sim_window.closing_stock)),
"delivery": "{}".format(round(sim_window.purchase_order_receipt_qty)),
"backlog": "{:.0f}".format(sim_window.backlog),
"po_raised": "{}".format(sim_window.po_number_raised),
"po_received": "{}".format(sim_window.po_number_received),
"po_quantity": "{:.0f}".format(int(sim_window.purchase_order_raised_qty)),
"shortage_cost": "{:.0f}".format(Decimal(sim_window.shortage_cost)),
"revenue": "{:.0f}".format(sim_window.revenue),
"quantity_sold": "{:0.0f}".format(sim_window.sold),
"shortage_units": "{:.0f}".format(sim_window.shortage_units)}
Transaction_report.append([sim_dict])

return Transaction_report

Most of the transaction logic for the inventory occurs in the build_window generator. The generator iterates over each SKU in the list of UncertainDemand objects containing the inventory analysis.

Much of the logic presented below is expressed as lambdas in the build_window generator and assigned to attributes in the sim_window a MonteCarloWindow object.

# instantiate sim_window
sim_window = simulation_window.MonteCarloWindow

The full method is here. Below all the transaction logic in the build_window method is walked through case by case. The calculations below share a similar implementation, using lambda functions to assign a value to attributes of the sim_window object (an instance of the MonteCarloWindow class).

Opening Stock

The sim_window.opening_stock is set to reorder-level (safety sock + lead-time demand):

$$ RL = LT \times D + Z \times \sigma \times \sqrt{LT} $$

Where: Z = service level, LT = Lead-time, D = Demand.

Opening stock is set at the reorder level in the first period for each SKU. In subsequently the the opening stock is the previous closing stock.

closing_stock = lambda opening_stock, orders, deliveries, backlog: Decimal(
(Decimal(opening_stock) - Decimal(orders)) + Decimal(
deliveries)) - Decimal(backlog) if Decimal((Decimal(opening_stock) - Decimal(orders)) +
Decimal(deliveries)) - Decimal(backlog) > 0 else 0

sim_window.closing_stock = closing_stock(opening_stock=sim_window.opening_stock,
orders=demand,
deliveries=sim_window.purchase_order_receipt_qty,
backlog=sim_window.backlog)

Closing Stock

The sim_window.closing stock is the non-negative result of the sum of \( demand \) (orders) and \( backlog \) subtracted from the sum of \( opening \ stock \) and \( deliveries \). If the result is negative, the absolute value of the negative closing stock will be added to backlog value.

sim_window.closing_stock = closing_stock(opening_stock=sim_window.opening_stock,
orders=demand,
deliveries=sim_window.purchase_order_receipt_qty,
backlog=sim_window.backlog)

closing_stock = lambda opening_stock, orders, deliveries, backlog: Decimal(
(Decimal(opening_stock) - Decimal(orders)) + Decimal(
deliveries)) - Decimal(backlog) if Decimal((Decimal(opening_stock) - Decimal(orders)) +
Decimal(deliveries)) - Decimal(backlog) > 0 else 0

Backlog

The current backlog for any given period is calculated as the absolute value of \( (demand + opening \ stock) - orders \), if the result is less than 0. The following lambda expression is used to assign a value to sim_window.backlog in the build_window.

backlog = lambda opening_stock, deliveries, demand: Decimal(abs(
(Decimal(opening_stock + deliveries)) - Decimal(demand))) if \
Decimal((opening_stock + deliveries)) - Decimal(demand) < 0 else 0

sim_window.backlog = backlog(opening_stock=sim_window.opening_stock, deliveries=sim_window.purchase_order_receipt_qty,
demand=demand) + previous_backlog

Shortages

Shortages in units are quantity of unmet demand in a given period; excluding earlier backlog.

    shortages = lambda opening_stock, orders, deliveries: abs(
(Decimal(opening_stock) - Decimal(orders)) + Decimal(deliveries)) if \
((Decimal(opening_stock) - Decimal(orders)) + Decimal(deliveries)) < 0 else 0

sim_window.shortage_units = shortages(opening_stock=sim_window.opening_stock,
orders=demand,
deliveries=sim_window.purchase_order_receipt_qty)

Purchase order quantity

The amount raised on the purchase order, is based on the EOQ and lead time demand. In future, the purchase order amount raised will use the forecasting method used. More information will be available, like simulated forecast accuracy, when implemented in the simulation. Currently, the lambda function assigning the value looks like:

po_qty = lambda eoq, reorder_lvl, backlog, cls_stock: Decimal(eoq) + Decimal(backlog) + Decimal(
(Decimal(reorder_lvl) - Decimal(cls_stock))) if Decimal(eoq) + Decimal(backlog) + Decimal(
(Decimal(reorder_lvl) - Decimal(cls_stock))) > 0 else 0

po_qty_raised = po_qty(eoq=sku.economic_order_qty,
reorder_lvl=sku.reorder_level,
backlog=sim_window.backlog,
cls_stock=sim_window.closing_stock)

When a purchase order amount is above zero, a purchase order is raised. Logging the makes it easier to follow the transactions over the simulated period.

Units Sold

To calculate the revenue generated the units sold for each period are calculate. Oversight in this release means that the retail price is an omitted parameter, only the taking unit cost. The _units_sold method, is a private attribute.

units_sold = self._units_sold(backlog=sim_window.backlog, opening_stock=sim_window.opening_stock,
delivery=sim_window.purchase_order_receipt_qty, demand=sim_window.demand)


def _units_sold(self, backlog, opening_stock, delivery, demand):

# check if opening_stock + closing_stock = 0
if int(opening_stock) + int(delivery) == 0:
sold = 0.00
else:
sold = (opening_stock + delivery) - Decimal(demand) - Decimal(backlog)

if sold < 0:
units_sold = Decimal(demand) + Decimal(backlog) - Decimal(abs(sold))
else:
units_sold = Decimal(sold)

return units_sold

Conclusion

All releases so far have been planning releases. There are still issues surrounding implementation and speed. Using Cython for some of the EOQ and simulation logic helped to speed things up. Python is not normally the go-to language of choice for programming simulations, so ultimately there is a trade-off. Some C++ wrappers are in development. However, Python is preferable if more Pythonic code and better implementation can reduce the bottlenecks.

Release-0.0.3 will not have a deep-dive. The new package was a result of testing for cross platform compilation of the Cython modules, on Windows, Mac, and Linux. To target a Windows machine compile the library from source using:

python setup.py build_ext -i

A second post, continuing the release-0.0.2 deep dive, will continue with the summarize_window, summarise.frame and optimise_service_level methods.

Read More: