# ===============================================================================
# stockpyl - eoq Module
# -------------------------------------------------------------------------------
# Author: Larry Snyder
# License: GPLv3
# ===============================================================================
"""
.. include:: ../../globals.inc
Overview
--------
The |mod_eoq| module contains code for solving the economic order quantity
(EOQ) problem and some of its variants.
.. note:: |fosct_notation|
.. seealso::
For an overview of single-echelon inventory optimization in |sp|,
see the :ref:`tutorial page for single-echelon inventory optimization<tutorial_seio_page>`.
API Reference
-------------
"""
import numpy as np
import math
[docs]def economic_order_quantity(fixed_cost, holding_cost, demand_rate, order_quantity=None):
"""Solve the economic order quantity (EOQ) problem, or (if
``order_quantity`` is supplied) calculate cost of given solution.
Parameters
----------
fixed_cost : float
Fixed cost per order. [:math:`K`]
holding_cost : float
Holding cost per item per unit time. [:math:`h`]
demand_rate : float
Demand (items) per unit time. [:math:`\\lambda`]
order_quantity : float, optional
Order quantity for cost evaluation. If supplied, no
optimization will be performed. [:math:`Q`]
Returns
-------
order_quantity : float
Optimal order quantity (or order quantity supplied) (items). [:math:`Q^*`]
cost : float
Cost per unit time attained by ``order_quantity``. [:math:`g^*`]
**Equations Used** (equations (3.4) and (3.5)):
.. math::
Q^* = \\sqrt{\\frac{2K\\lambda}{h}}
g^* = \\sqrt{2K\\lambda h}
or
.. math::
g(Q) = \\frac{K\\lambda}{Q} + \\frac{hQ}{2}
**Example** (Example 3.1):
.. testsetup:: *
from stockpyl.eoq import *
.. doctest::
>>> economic_order_quantity(8, 0.225, 1300)
(304.0467800264368, 68.41052550594829)
"""
# Check that parameters are non-negative/positive.
if fixed_cost < 0: raise ValueError("fixed_cost must be non-negative.")
if holding_cost <= 0: raise ValueError( "holding_cost must be positive.")
if demand_rate < 0: raise ValueError( "demand_rate must be non-negative.")
if order_quantity is not None and order_quantity <= 0: raise ValueError("order_quantity must be positive.")
# Is Q provided?
if order_quantity is None:
# Calculate optimal order quantity and cost.
order_quantity = math.sqrt(2 * fixed_cost * demand_rate / holding_cost)
cost = order_quantity * holding_cost
else:
# Calculate cost.
cost = fixed_cost * demand_rate / order_quantity + holding_cost * order_quantity / 2
return order_quantity, cost
[docs]def economic_order_quantity_with_backorders(fixed_cost, holding_cost, stockout_cost, demand_rate, order_quantity=None, stockout_fraction=None):
"""Solve the economic order quantity with backorders (EOQB) problem, or (if ``order_quantity`` and ``stockout_fraction`` are supplied) calculate cost of given solution.
Parameters
----------
fixed_cost : float
Fixed cost per order. [:math:`K`]
holding_cost : float
Holding cost per item per unit time. [:math:`h`]
stockout_cost : float
Stockout cost per item per unit time. [:math:`p`]
demand_rate : float
Demand (items) per unit time. [:math:`\\lambda`]
order_quantity : float, optional
Order quantity for cost evaluation. If supplied, no
optimization will be performed. [:math:`Q`]
stockout_fraction : float, optional
Stockout fraction for cost evaluation. If supplied, no
optimization will be performed. [:math:`x`]
Returns
-------
order_quantity : float
Optimal order quantity (or order quantity supplied) (items). [:math:`Q^*`]
stockout_fraction : float
Optimal stockout fraction (or stockout fraction supplied) (items). [:math:`x^*`]
cost : float
Cost per unit time attained by ``order_quantity`` and ``stockout_fraction``. [:math:`g^*`]
**Equations Used** (equations (3.27)--(3.29)):
.. math::
Q^* = \\sqrt{\\frac{2K\\lambda(h+p)}{hp}}
x^* = \\frac{h}{h+p}
g^* = \\sqrt{\\frac{2K\\lambda hp}{h+p}}
or
.. math::
g(Q,x) = \\frac{hQ(1-x)^2}{2} + \\frac{pQx^2}{2} + \\frac{K\lambda}{Q}
**Example** (Example 3.8):
.. testsetup:: *
from stockpyl.eoq import *
.. doctest::
>>> economic_order_quantity_with_backorders(8, 0.225, 5, 1300)
(310.81255515896464, 0.0430622009569378, 66.92136355097325)
"""
# Check that parameters are positive.
if fixed_cost < 0: raise ValueError("fixed_cost must be non-negative.")
if holding_cost <= 0: raise ValueError( "holding_cost must be positive.")
if stockout_cost <= 0: raise ValueError( "stockout_cost must be positive.")
if demand_rate < 0: raise ValueError( "demand_rate must be non-negative.")
if order_quantity is not None and order_quantity <= 0: raise ValueError("order_quantity must be positive.")
if stockout_fraction is not None and (stockout_fraction < 0 or stockout_fraction > 1): raise ValueError("stockout_fraction must be between 0 and 1.")
# Check that both or neither order_quantity and stockout_fraction are provided.
if (order_quantity is None and stockout_fraction is not None) or (order_quantity is not None and stockout_fraction is None): raise ValueError("You must provide both order_quantity and stockout_fraction or neither.")
# Is Q provided?
if order_quantity is None:
# Calculate optimal order quantity, stockout fraction, and cost.
order_quantity = math.sqrt(2 * fixed_cost * demand_rate * (holding_cost + stockout_cost)
/ (holding_cost * stockout_cost))
stockout_fraction = holding_cost / (holding_cost + stockout_cost)
cost = order_quantity * (holding_cost * stockout_cost) / (holding_cost + stockout_cost)
else:
# Caclulate cost.
cost = holding_cost * order_quantity * (1 - stockout_fraction) ** 2 / 2 \
+ stockout_cost * order_quantity * stockout_fraction ** 2 / 2 \
+ fixed_cost * demand_rate / order_quantity
return order_quantity, stockout_fraction, cost
[docs]def economic_production_quantity(fixed_cost, holding_cost, demand_rate, production_rate, order_quantity=None):
"""Solve the economic production quantity (EPQ) problem, or (if ``order_quantity`` is supplied) calculate cost of given solution.
Parameters
----------
fixed_cost : float
Fixed cost per order. [:math:`K`]
holding_cost : float
Holding cost per item per unit time. [:math:`h`]
demand_rate : float
Demand (items) per unit time. [:math:`\\lambda`]
production_rate : float
Production quantity (items) per unit time. [:math:`\\mu`]
order_quantity : float, optional
Order quantity for cost evaluation. If supplied, no optimization will be performed. [:math:`Q`]
Returns
-------
order_quantity : float
Optimal order quantity (or order quantity supplied) (items). [:math:`Q^*`]
cost : float
Cost per unit time attained by ``order_quantity``. [:math:`g^*`]
**Equations Used** (equations (3.31) and (3.32)):
.. math::
Q^* = \\sqrt{\\frac{2K\\lambda}{h(1-\\rho)}}
g^* = \\sqrt{2K\\lambda h(1-\\rho)}
or
.. math::
g(Q) = \\frac{K\\lambda}{Q} + \\frac{h(1 - \\rho)Q}{2}
where :math:`\\rho = \\lambda/\\mu`.
**Example**:
.. testsetup:: *
from stockpyl.eoq import *
.. doctest::
>>> economic_production_quantity(8, 0.225, 1300, 1700)
(626.8084945889684, 33.183979125298336)
"""
# Check that parameters are non-negative/positive.
if fixed_cost < 0: raise ValueError("fixed_cost must be non-negative.")
if holding_cost <= 0: raise ValueError( "holding_cost must be positive.")
if demand_rate < 0: raise ValueError( "demand_rate must be non-negative.")
if production_rate <= 0: raise ValueError( "production_rate must be positive.")
if order_quantity is not None and order_quantity <= 0: raise ValueError("order_quantity must be positive.")
# Check that demand rate < production rate.
if demand_rate >= production_rate: raise ValueError("demand_rate must be less than production_rate.")
# Calculate rho.
rho = demand_rate / production_rate
# Is Q provided?
if order_quantity is None:
# Calculate optimal order quantity and cost.
order_quantity = math.sqrt(
2 * fixed_cost * demand_rate / (holding_cost * (1 - rho)))
cost = order_quantity * holding_cost * (1 - rho)
else:
# Calculate cost.
cost = fixed_cost * demand_rate / order_quantity + holding_cost * (1 - rho) * order_quantity / 2
return order_quantity, cost
[docs]def joint_replenishment_problem_silver_heuristic(shared_fixed_cost,
individual_fixed_costs,
holding_costs,
demand_rates):
"""Solve the joint replenishment problem (JRP) using Silver's (1976) heuristic.
Parameters
----------
shared_fixed_cost : float
Shared fixed cost per order. [:math:`K`]
individual_fixed_costs : list of floats
Individual fixed cost if product ``n`` is included in order. [:math:`k_n`]
holding_costs : list of floats
Holding cost per item per unit time for product ``n``. [:math:`h_n`]
demand_rates : list of floats
Demand (items) per unit time for product ``n``. [:math:`\\lambda_n`]
Returns
-------
order_quantities : list of floats
Order quantities (items). [:math:`Q_n`]
base_cycle_time : float
Interval between consecutive orders. [:math:`T`]
order_multiples : list of ints
Product ``n`` is included in every ``order_multiples[n]`` orders. [:math:`m_n`]
cost : float
Cost per unit time. [:math:`g`]
**Equations Used**:
.. math::
\\hat{n} = n \\text{ that minimizes } k_n / h_n\\lambda_n
m_{\\hat{n}} = 1
m_n = \\sqrt{\\frac{k_n}{h_n\\lambda_n} \\times \\frac{h_{\\hat{n}}\\lambda_{\\hat{n}}}{K+k_{\\hat{n}}}} \\text{ (rounded)}
T = \\sqrt{\\frac{2(K+\\sum_{n=1}^N \\frac{k_n}{m_n}}{\\sum_{n=1}^N h_nm_n\\lambda_n}}
Q_n = Tm_n\\lambda_n
g = \\frac{K + \\sum_{n=1}^N \\frac{k_n}{m_n}}{T} + \\frac{T}{2}\\sum_{n=1}^N h_nm_n\\lambda_n
**Example**:
.. testsetup:: *
from stockpyl.eoq import *
.. doctest::
>>> shared_fixed_cost = 600
>>> individual_fixed_costs = [120, 840, 300]
>>> holding_costs = [160, 20, 50]
>>> demand_rates = [1, 1, 1]
>>> joint_replenishment_problem_silver_heuristic(shared_fixed_cost, individual_fixed_costs, holding_costs, demand_rates)
([3.103164454170876, 9.309493362512628, 3.103164454170876], 3.103164454170876, [1, 3, 1], 837.8544026261366)
"""
# Check that parameters are non-negative/positive.
if shared_fixed_cost < 0: raise ValueError("shared_fixed_cost must be non-negative")
if not np.all(np.array(individual_fixed_costs) >= 0): raise ValueError("individual_fixed_costs must be non-negative.")
if not np.all(np.array(holding_costs) > 0): raise ValueError("holding_costs must be non-negative.")
if not np.all(np.array(demand_rates) > 0): raise ValueError("demand_rates must be non-negative.")
if len(individual_fixed_costs) != len(holding_costs) or len(holding_costs) != len(demand_rates): raise ValueError("all lists must have the same length")
# Determine number of products.
num_prod = len(individual_fixed_costs)
# Calculate ratios.
ratio = [individual_fixed_costs[n] / (holding_costs[n] * demand_rates[n]) for n in range(num_prod)]
# Determine product with min ratio.
min_ratio_prod = np.argmin(ratio)
# Calculate constant for min_ratio_prod.
const = holding_costs[min_ratio_prod] * demand_rates[min_ratio_prod] / \
(shared_fixed_cost + individual_fixed_costs[min_ratio_prod])
# Calculate order frequencies.
order_multiples = []
for n in range(num_prod):
if n == min_ratio_prod:
m = 1
else:
m = math.sqrt((individual_fixed_costs[n] / (holding_costs[n] * demand_rates[n])) * const)
m = max(1, int(round(m)))
order_multiples.append(m)
# Calculate a few terms we'll need below.
term1 = shared_fixed_cost + float(np.sum(np.divide(individual_fixed_costs, order_multiples)))
term2 = float(np.sum([holding_costs[n] * order_multiples[n] * demand_rates[n] for n in range(num_prod)]))
# Calculate base cycle time.
numer = 2 * term1
denom = term2
base_cycle_time = math.sqrt(numer / denom)
# Calculate order quantities.
order_quantities = [base_cycle_time * order_multiples[n] * demand_rates[n] for n in range(num_prod)]
# Calculate average cost.
avg_fixed_cost = term1 / base_cycle_time
avg_holding_cost = (base_cycle_time / 2) * term2
cost = avg_fixed_cost + avg_holding_cost
return order_quantities, base_cycle_time, order_multiples, cost