Multi-Product Simulation¶
Stockpyl supports simulating systems with multiple products. This page describes how to create and manage products, as well as simulate multi-product systems. If you haven’t already read the the tutorial page for simulation, read that first.
Note
The terms “node” and “stage” are used interchangeably in the documentation.
Note
The notation and references (equations, sections, examples, etc.) used below refer to Snyder and Shen, Fundamentals of Supply Chain Theory (FoSCT), 2nd edition (2019).
See also
For more details, see the API documentation for the sim, sim_io, and supply_chain_product modules.
Contents
Products¶
The primary class for handling products is the SupplyChainProduct. A SupplyChainProduct object
is typically added to one or more SupplyChainNode objects; those nodes are then said to
“handle” the product. Most attributes (local_holding_cost, stockout_cost,
demand_source, inventory_policy, etc.) may be specified either at the node level
(same value for all products at the node), at the product level (same value for all nodes that handle
the product), or at the node-product level (separate value for the node-product pair).
Products are related to each other via a bill of materials (BOM). The BOM specifies the number of units of an upstream product (raw material) that are required to make one unit of a downstream product (finished goods). For example, the BOM might specify that 5 units of product A and 2 units of product B are required to make 1 unit of product C at a downstream node. The raw materials are products A and B, and the finished good is product C.
Note
“Raw materials” and “finished goods” are SupplyChainProduct objects. They are not separate
classes. Moreover, a finished good at one node may be a raw material at another node; for example,
node 1 might produce product A as its finished good, which it then ships to node 2, where it is
used as a raw material to produce product B.
Dummy Products¶
Every node has at least one product. If your code does not explicltly create products or add them to nodes, Stockpyl automatically creates and manages “dummy” products at each node. This means that you can ignore products entirely if you do not need them, and you can build and simulate networks just as you did in versions prior to Stockpyl prior to v1.0 (when products where introduced).
When a product is added to a node, the dummy product is removed. If all “real” products are removed, a dummy product is added back.
Dummy products are identifiable as such because they have negative indices, or because their
is_dummy attribute is set to True.
Basic Multi-Product Example¶
This tutorial will use the following network:
In the diagram:
The squares represent nodes. The number in the top-left corner of a node is its index.
The circles represent products. The number in a product is its index.
The lines from products 20 and 30 to product 10 indicate that products 20 and 30 are raw materials that are used to make product 10. To make 1 unit of product 10 requires 5 units of product 20 and 2 units of product 30, as indicated by
x5andx3on the lines.The arrow from node 2 to node 1 indicates that node 2 ships items to node 1. The lead time for these shipments is 1 period, as indicated by
L=1on the arrow.The arrow from node 1 represents the external demand, which follows a uniform discrete distribution on [1,5].
The arrow into node 2 represents the external supplier. The lead time for shipments from the external supplier is 2 periods, as indicated by
L=2on the arrow.
We’ll start building this network using the serial_system() function:
>>> from stockpyl.supply_chain_network import serial_system
>>> network = serial_system(
... num_nodes=2,
... node_order_in_system=[2, 1],
... node_order_in_lists=[1, 2],
... stockout_cost=[20, 0],
... demand_type='UD',
... lo=1,
... hi=5,
... shipment_lead_time=[1, 2]
... )
>>> # Build a dict for easier access to the nodes.
>>> nodes = {n.index: n for n in network.nodes}
Next, we’ll create the three products and add them to a dict whose keys are product indices and whose values are products, for easy access to the product objects. We’ll also set the BOM.
>>> from stockpyl.supply_chain_product import SupplyChainProduct
>>> products = {10: SupplyChainProduct(index=10), 20: SupplyChainProduct(index=20), 30: SupplyChainProduct(index=30)}
>>> products[10].set_bill_of_materials(raw_material=20, num_needed=5)
>>> products[10].set_bill_of_materials(raw_material=30, num_needed=3)
To add the products to the nodes, we use add_product() and
add_products():
>>> nodes[1].add_product(products[10])
>>> nodes[2].add_products([products[20], products[30]])
Assigning Attributes¶
Most attributes that apply to nodes (local_holding_cost, stockout_cost,
demand_source, inventory_policy, etc.) also apply to products. There are three ways
to assign attributes:
By setting it at a node, e.g.,
my_node.stockout_cost = 50By setting it at a product, e.g.,
my_product.stockout_cost = 50By setting the attribute at the node to a dict whose keys are product indices and whose values are the attribute values, e.g.,
my_node.stockout_cost = {my_product1.index: 50, my_product2.index: 70}This allows you to set (node, product)-specific values of the attribute
In our example network, since node 1 only handles one product (product 10), we can set
local_holding_cost directly at node 1. We’ll set local_holding_cost for products
20 and 30 in the product objects.
>>> nodes[1].local_holding_cost = 5
>>> products[20].local_holding_cost = 2
>>> products[30].local_holding_cost = 3
We need an inventory policy for each product. This attribute, too, can be set at the node, product, or (node, product) levels. We’ll set the policy for product 10 in the product object (we could instead set it at node 1). And we’ll set the policy for products 20 and 30 using a dict at node 2:
>>> from stockpyl.policy import Policy
>>> products[10].inventory_policy = Policy(type='BS', base_stock_level=6, node=nodes[1], product=products[10])
>>> nodes[2].inventory_policy = {
... 20: Policy(type='BS', base_stock_level=35, node=nodes[2], product=products[20]),
... 30: Policy(type='BS', base_stock_level=20, node=nodes[2], product=products[30])
... }
Accessing Attributes¶
It is possible to access attributes in the same way they were assigned:
>>> nodes[1].local_holding_cost
5
>>> products[20].local_holding_cost
2
>>> products[30].local_holding_cost
3
>>> products[10].inventory_policy
Policy(BS: base_stock_level=6.00)
>>> nodes[2].inventory_policy[20]
Policy(BS: base_stock_level=35.00)
But it can be annoying to access them this way, because you need to know whether the attribute was originally assigned to the node, to the product, or to the node as a dict.
Instead, use the get_attribute() method,
which figures out where the attribute is set and returns the appropriate value. In particular,
the method attempts to access the attribute in the following order:
As a dict in the node object (meaning there is a (node, product)-specific value)
As a singleton in the product object (meaning there is a product-specific value)
As a singleton in the node object (meaning there is a node-specific value)
(If none of these, an exception is raised)
During a simulation, Stockpyl uses get_attribute()
to access all attributes, so the simulation will pull attributes from nodes and products
using the same logic as above.
>>> nodes[1].get_attribute('local_holding_cost', product=10)
5
>>> nodes[2].get_attribute('local_holding_cost', product=20)
2
>>> nodes[2].get_attribute('local_holding_cost', product=30)
3
>>> # You can omit the `product` argument if the node has a single product.
>>> nodes[1].get_attribute('inventory_policy')
Policy(BS: base_stock_level=6.00)
>>> nodes[2].get_attribute('inventory_policy', product=20)
Policy(BS: base_stock_level=35.00)
Bill of Materials¶
The number of units of product A required to make 1 unit of product B is called the BOM number for products A and B. The BOM number is specified at the product level, not the node level: If the BOM number for products A and B is 5, then it is 5 no matter what nodes are under consideration.
The set_bill_of_materials()
method is used to set the BOM relationships between pairs of products. We already used the
following code to set the BOM for our example network:
>>> products[10].set_bill_of_materials(raw_material=20, num_needed=5)
>>> products[10].set_bill_of_materials(raw_material=30, num_needed=3)
We can access the BOM number using get_bill_of_materials(),
or the shortcut method BOM():
>>> products[10].get_bill_of_materials(raw_material=20)
5
>>> products[10].BOM(30)
3
In a Stockpyl simulation, every network must have external supply—nodes can’t
just create a product with no raw materials. (See
External Suppliers.)
To specify that a node receives external supply, you set that node’s supply_type
attribute to 'U' (for “unlimited”), or to anything other than None. The
serial_system() function automatically sets
supply_type = 'U' for the upstream-most node, which means that node 2 in
our network has external supply.
External suppliers provide raw materials, even though they are not created explictly
as SupplyChainProduct objects. The BOM for such raw materials is therefore also not
specified explicitly. Instead, such relationships are governed by the
network bill of materials (NBOM), which assigns default values to certain
pairs of nodes/products based on the structure of the network. The basic rule is:
Network Bill of Materials (NBOM)
If node A is a predecessor to node B, and there are no BOM relationships specified between any product at node A and any product at node B, then every product at node B is assumed to require 1 unit of every product at node A as a raw material.
In the case of our example network, that means that product 20 and product 30 require 1 unit of the product provided by the external supplier. (That item is a “dummy” product assigned to the supplier.)
We don’t set the NBOM explicitly—we only set the BOM, and Stockpyl automatically adds the
network-based relationships as needed. We can query the NBOM using
get_network_bill_of_materials()
(or its shortcut, NBOM()),
which returns the BOM relationship for a given (node, product) and a given
(predecessor, raw material). If the BOM is set explicitly,
get_network_bill_of_materials()
returns that number, and if it’s implicit from the network structure, it returns
that number. If there is no BOM relationship (either explicit or implied), it returns 0.
If an NBOM relationship is implied by the network structure, the NBOM always equals 1. If you want it to equal something else (e.g., if we wanted to say that 4 units of the external supplier product are required to make 1 unit of product 30), you would need to explicitly create a node that’s a predecessor to node 2, create a product at that node that’s a raw material for product 30, and set the BOM explicitly.
>>> # Get the NBOM for node 1, product 10 with node 2, product 20.
>>> nodes[1].NBOM(product=10, predecessor=2, raw_material=20)
5
>>> # Get the NBOM for node 2, product 20 with the external supplier's dummy product.
>>> nodes[2].NBOM(product=20, predecessor=None, raw_material=None)
1
Raw Material Inventory¶
Every node has a raw material inventory for every product that it uses as a raw material. So, in our example, node 1 has raw material inventory for products 20 and 30, and node 2 has raw material inventory for the dummy product from the external supplier. The holding cost rate for raw material inventory is the same as the holding cost for the same product at the node that supplies it. (This node is chosen arbitrarily if there are multiple such nodes.)
Raw material inventories are by product only, not by (product, predecessor). There are two important implications of this:
If a node has multiple suppliers that provide the same raw material, those supplies are pooled into a single raw material inventory.
If a node has multiple products that use the same raw material, they share the same raw material inventory.
The second bullet is relevant for our example network, because both product 20 and product 30 use the dummy product from the external supplier as a raw material, so they both draw their raw materials from the same inventory.
Multi-Product Simulation Output¶
This section discusses the simulation output for a multi-product network, i.e., a network
in which one or more SupplyChainProduct objects have been added explicitly.
(See Simulation Output for an overview of the sim_io module
and the simulation output in the context of a single-product network.)
The write_results() function displays the results of the simulation
in a table. The table has the following format for multi-product networks:
Each row corresponds to a period in the simulation.
Each node is represented by a group of columns.
The node number is indicated in the first column in the group (i.e., i=1).
(node, product) pairs are indicated by a vertical line, so ‘2|20’ means node 2, product 20.
The columns for each node are as follows:
i=<node index>: label for the column group
DISR: was the node disrupted in the period? (True/False)
IO:s|prod: inbound order for productprodreceived from successors
IOPL:s|prod: inbound order pipeline for productprodfrom successors: a list of order quantities arriving from succesorsinrperiods from the period, forr= 1, …,order_lead_time
OQ:p|rm: order quantity placed to predecessorpfor raw materialrm
OQFG:prod: order quantity of finished goodprod(this “order” is never actually placed—only the raw material orders inOQare placed; butOQFGcan be useful for debugging)
OO:p:rm: on-order quantity (items of raw materialrmthat have been ordered from successorpbut not yet received)
IS:p|rm: inbound shipment of raw materialrmreceived from predecessorp
ISPL:p|rm: inbound shipment pipeline for raw materialrmfrom predecessorp: a list of shipment quantities arriving from predecessorpinrperiods from the period, forr= 1, …,shipment_lead_time
IDI:p|rm: inbound disrupted items: number of items of raw materialrmfrom predecessorpthat cannot be received due to a type-RP disruption at the node
RM:rm: number of items of raw materialrmin raw-material inventory at node
PFG:prod: number of items of productprodthat are pending, waiting to be processed from raw materials
OS:s|prod: outbound shipment of productprodto successors
DMFS|prod: demand of productprodmet from stock at the node in the current period
FR|prod: fill rate of productprod; cumulative from start of simulation to the current period
IL|prod: inventory level of productprod(positive, negative, or zero) at node
BO:s|prod: backorders of productprodowed to successors
ODI:s|prod: outbound disrupted items of productprod: number of items held for successorsdue to a type-SP disruption ats
HC: holding cost incurred at the node in the period
SC: stockout cost incurred at the node in the period
ITHC: in-transit holding cost incurred for items in transit to all successors of the node
REV: revenue (Note: not currently supported)
TC: total cost incurred at the node (holding, stockout, and in-transit holding)For state variables that are indexed by successor, if
s=EXT, the column refers to the node’s external customerFor state variables that are indexed by predecessor, if
p=EXT, the column refers to the node’s external supplierNegative product indices are “dummy products”
Example: The code below simulates our example network for 10 periods and displays the results.
It sets the rand_seed parameter to allow the results to be reproduced.
>>> simulation(network=network, num_periods=10, rand_seed=17)
>>> write_results(network, num_periods=10, columns_to_print=['basic', 'costs', 'RM', 'ITHC'])
The results are shown in the table below. In period 0:
We start with
IL:10= 6 at node 1,IL:20= 35 andIL:30= 20 at node 2. (By default, the initial inventory level equals the base-stock level.) These numbers aren’t displayed in the table below, only the ending ILs are.Node 1 receives a demand of 2 for product 10 (
IO:EXT|10= 2). Its inventory position (IP) is now 6 - 2 = 4 and its base-stock level is 6, so it needs to order 2 units’ worth of raw materials. Expressed in the units of the raw materials, that means it needs to order 10 units of product 20 (because BOM = 5) and 6 of product 30 (because BOM = 3). In the table,OQ:2|20= 10,OQ:2|30= 6.Node 1 has sufficient inventory to fulfill the demand of 2, so it does (
OS:EXT|10= 2).Node 1 ends the period with
IL:10= 4, and incurs a holding cost of 20 since the per-unit holding cost is 5. There is no stockout cost in this period, so we haveHC= 20,SC= 0,TC= 20.Node 2 receives an inbound order of 10 units for product 20 and 6 units for product 3 (
IO:1|20= 10,IO:1|30= 6). Its inventory positions are nowIP:20= 35 - 10 = 25,IP:30= 20 - 6 = 14 and its base-stock levels are 35 and 20, respectively. So it needs to order 10 units of the raw material from the external supplier for product 20, and another 6 units of raw material for product 30. (Remember that the NBOM = 1 for these pairs.) So,OQ:EXT|-5= 16. (-5 is the index of the dummy product at the external supplier.) Of those 16 units, 6 are “earmarked” for product 20 and 10 are for product 30.Node 2 has sufficient inventory to satisfy demand for both products, so it ships 10 units of product 20 and 6 units of product 30 (
OS:1|20= 10,OS:1|30= 6).Node 2 ends the period with
IL:20= 25,IL:30= 14, soHC= 25 * 2 + 14 * 3 = 92 andSC= 0. Node 2 also incurs the in-transit holding cost for items that it shipped to node 1 that have not arrived yet; there are 10 units of product 20 and 6 units of product 30, and the holding cost rates are 2 and 3, soITHC= 10 * 2 + 6 * 3 = 38; andTC= 92 + 38 = 130.
In period 1:
Node 1 starts period 1 with an IL of 4, and has 10 units of product 20 and 6 units of product 30 on order (
OO:2|20= 10 andOO:2|30= 10 at the end of period 0). The on-order units are sufficient to produce 2 units of product 10, so its starting IP is 4 + 2 = 6.Node 1 receives a demand of 2 again (
IO:EXT|10= 2), so its new IP is 4, and it again orders 10 units of product 20 and 6 of product 30 (OQ:2|20= 10,OQ:2|30= 6).Node 1 again meets the demand in full (
OS:EXT|10= 2), ends the period withIL:10= 4, and has costsHC= 20,SC= 0, andTC= 20.Node 2 starts period 1 with
IL:20= 25. It has 16 units of its raw material on order from the external supplier (OO:EXT|-5= 16 at the end of period 0), 10 of which were “earmarked” for product 20, so its starting IP for product 20 is 25 + 10 = 35. Node 1 ordered 10 units of product 20, so its new IP is 35 - 10 = 25. Its base-stock level for product 20 is 35, so it will need to order 10 units of the raw material from the external supplier for product 20.For product 30, the starting IL is 14, the on-order inventory is 10 (of which 6 were “earmarked” for product 30), the demand is 6, so the IP is 14 + 6 - 6 = 14. The base-stock level for product 30 is 20, so the node needs to order 6 units of the raw material. Therefore, node 2 places an order for a total of 16 units of the raw material from the external supplier (OQ:EXT|-5 = 16).
Node 2 again has sufficient inventory to meet its full demand, so
OS:1|20= 10 andOS:1|30= 6.Node 2 ends period 1 with
IL:20= 25 - 10 = 15 andIL:30= 14 - 6 = 8, soHC= 15 * 2 + 8 * 3 = 54. There are 10 units of product 20 and 6 of product 30 in transit to node 1, soITHC= 10 * 2 + 6 * 3 = 38; andTC= 54 + 38 = 92.
Here’s an explanation of the fractional order quantities at node 1 in period 5:
Node 1 starts period 5 (ends period 4) with 1 unit of the finished good, product 10 (
IL:10= 1 in period 4), and 0 units of both product 20 and product 30 in raw material inventory (RM:20= 0,RM:30= 0 in period 4).Node 1 receives 10 units of product 20 and 5 of product 30 in period 5 (
IS:2|20= 10,IS:2|30= 5).Now node 1 has 10 units of product 20 and 5 of product 30 on hand, which is enough to make 1.6667 units of product 10 at node 1. Doing so uses up all units of product 30 and 5 * 1.6667 = 8.3333 units of product 20, leaving 1.6667 remaining units of product 20.
Therefore, node 1 ends period 5 with
RM:20 = 1.6667andRM:30 = 0.The demand for product 10 in period 5 is 5 (
IO:EXT|10= 5). Node 1 began the period with 1 unit of product 10 and then producted 1.6667 additional units; it ships these 2.6667 units to the external customer (OS:EXT|10= 2.6667) and ends the period with 2.3333 backorders (IL:10= -2.3333). It incurs a holding cost on the raw material inventory of product 20, at the per-unit local holding cost rate for product 20, i.e., 2. SoHC= 2 * 1.6667 = 3.3333. It incurs a stockout cost of 20 per backorder, soSC= 20 * 2.3333 = 46.6667, andTC= 3.3333 + 46.6667 = 50.
t |
i=1 |
IO:EXT|10 |
OQ:2|20 |
OQ:2|30 |
OO:2|20 |
OO:2|30 |
IS:2|20 |
IS:2|30 |
RM:20 |
RM:30 |
OS:EXT|10 |
IL:10 |
HC |
SC |
ITHC |
TC |
i=2 |
IO:1|20 |
IO:1|30 |
OQ:EXT|-5 |
OO:EXT|-5 |
IS:EXT|-5 |
RM:-5 |
OS:1|20 |
OS:1|30 |
IL:20 |
IL:30 |
HC |
SC |
ITHC |
TC |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 |
2 |
10 |
6 |
10 |
6 |
0 |
0 |
0 |
0 |
2 |
4 |
20 |
0 |
0 |
20 |
10 |
6 |
16 |
16 |
0 |
0 |
10 |
6 |
25 |
14 |
92 |
0 |
38 |
130 |
||
1 |
2 |
10 |
6 |
10 |
6 |
10 |
6 |
0 |
0 |
2 |
4 |
20 |
0 |
0 |
20 |
10 |
6 |
16 |
32 |
0 |
0 |
10 |
6 |
15 |
8 |
54 |
0 |
38 |
92 |
||
2 |
1 |
5 |
3 |
5 |
3 |
10 |
6 |
0 |
0 |
1 |
5 |
25 |
0 |
0 |
25 |
5 |
3 |
8 |
24 |
16 |
0 |
5 |
3 |
20 |
11 |
73 |
0 |
19 |
92 |
||
3 |
5 |
25 |
15 |
25 |
15 |
5 |
3 |
0 |
0 |
5 |
1 |
5 |
0 |
0 |
5 |
25 |
15 |
40 |
48 |
16 |
0 |
25 |
15 |
5 |
2 |
16 |
0 |
95 |
111 |
||
4 |
5 |
25 |
15 |
25 |
15 |
25 |
15 |
0 |
0 |
5 |
1 |
5 |
0 |
0 |
5 |
25 |
15 |
40 |
80 |
8 |
0 |
10 |
5 |
-15 |
-10 |
0 |
0 |
35 |
35 |
||
5 |
5 |
25 |
15 |
40 |
25 |
10 |
5 |
1.6667 |
0 |
2.6667 |
-2.3333 |
3.3333 |
46.6667 |
0 |
50 |
25 |
15 |
40 |
80 |
40 |
0 |
25 |
15 |
-15 |
-10 |
0 |
0 |
95 |
95 |
||
6 |
2 |
10 |
6 |
25 |
16 |
25 |
15 |
1.6667 |
0 |
4.3333 |
0.6667 |
6.6667 |
0 |
0 |
6.6667 |
10 |
6 |
16 |
56 |
40 |
0 |
25 |
15 |
0 |
-1 |
0 |
0 |
95 |
95 |
||
7 |
2 |
10 |
6 |
10 |
7 |
25 |
15 |
1.6667 |
0 |
2 |
3.6667 |
21.6667 |
0 |
0 |
21.6667 |
10 |
6 |
16 |
32 |
40 |
0 |
10 |
7 |
15 |
8 |
54 |
0 |
41 |
95 |
||
8 |
2 |
10 |
6 |
10 |
6 |
10 |
7 |
0 |
0 |
2 |
4 |
20 |
0 |
0 |
20 |
10 |
6 |
16 |
32 |
16 |
0 |
10 |
6 |
15 |
8 |
54 |
0 |
38 |
92 |
||
9 |
1 |
5 |
3 |
5 |
3 |
10 |
6 |
0 |
0 |
1 |
5 |
25 |
0 |
0 |
25 |
5 |
3 |
8 |
24 |
16 |
0 |
5 |
3 |
20 |
11 |
73 |
0 |
19 |
92 |
Accessing the State Variables¶
In addition to viewing the results in tabular form, you can also query a NodeStateVars object
to get values of individual state variables, using methods such as
get_inventory_level(),
get_order_quantity(), etc. The arguments of these
methods are the relevant nodes/products, but these arguments can be omitted if they are inferrable
(e.g., if the node has a single predecessor, or a single product, etc.).