SupplyNetPy Components.core Module

The Components.core module provides the foundational building blocks for modeling and simulating supply chain networks in SupplyNetPy. It defines key classes representing entities and their interactions within a supply chain.

SupplyNetPy.Components.core

NodeType

Bases: str, Enum

Canonical node types accepted by Node and create_sc_net.

NodeType is a str subclass, so every existing comparison against a literal string (node.node_type == "demand", "supplier" in node.node_type, node.node_type.lower()) continues to work unchanged. Users can pass either a plain string (case-insensitive, via _missing_) or a NodeType member:

scm.Supplier(env=env, ID="S1", name="S1", node_type="supplier")
scm.Supplier(env=env, ID="S1", name="S1", node_type=scm.NodeType.SUPPLIER)

Some types are different names for the same kind of node: "manufacturer" and "factory" both create a manufacturer, and "warehouse", "distributor", "retailer", "store" and "shop" all create an InventoryNode. "supplier" creates a raw-material supplier and "infinite_supplier" an infinite supplier.

create_sc_net looks up which class to create for each type in a table (_NODE_DISPATCH in utilities.py). To add a new node type, add it to this enum and add an entry to that table.

NamedEntity

The NamedEntity class provides a standardized way to display names of the objects in the supply chain model. When printed or displayed, the object will show its name (if defined), otherwise its ID, or the class name as a fallback. This improves the readability and interpretability of simulation outputs by ensuring objects are easily identifiable.

Methods:

Name Description
__str__

returns the name of the object if available, otherwise returns the class name

__repr__

returns the name of the object if available, otherwise returns the class name

__str__

__str__() -> str

Returns the name of the object if available, otherwise returns the class name.

Source code in src/SupplyNetPy/Components/core.py
264
265
266
def __str__(self) -> str:
    """Returns the name of the object if available, otherwise returns the class name."""
    return getattr(self, 'name', getattr(self, 'ID', self.__class__.__name__))

__repr__

__repr__() -> str

Returns the name of the object if available, otherwise returns the class name.

Source code in src/SupplyNetPy/Components/core.py
268
269
270
def __repr__(self) -> str:
    """Returns the name of the object if available, otherwise returns the class name."""
    return getattr(self, 'name', getattr(self, 'ID', self.__class__.__name__))

InfoMixin

The InfoMixin class allows objects to easily provide their key details and statistics as dictionaries. This helps in quickly summarizing, logging, or analyzing object data in a structured and consistent way across the simulation.

Attributes:
  • _info_keys (list) –

    list of keys to include in the info dictionary

  • _stats_keys (list) –

    list of keys to include in the statistics dictionary

Methods:

Name Description
get_info

returns a dictionary containing details of the object

get_statistics

returns a dictionary containing statistics of the object

get_info

get_info() -> dict

Returns a dictionary containing details of the object.

Returns:
  • dict( dict ) –

    dictionary containing details of the object

Source code in src/SupplyNetPy/Components/core.py
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
def get_info(self) -> dict:
    """
    Returns a dictionary containing details of the object.

    Parameters:
        None

    Attributes: 
        None

    Returns:
        dict: dictionary containing details of the object
    """
    if self._info_keys:
        return {key: getattr(self, key, None) for key in self._info_keys}
    return self.__dict__

get_statistics

get_statistics() -> dict

Returns a dictionary containing statistics of the object.

Returns:
  • dict( dict ) –

    dictionary containing statistics of the object

Source code in src/SupplyNetPy/Components/core.py
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
def get_statistics(self) -> dict:
    """
    Returns a dictionary containing statistics of the object.

    Parameters:
        None

    Attributes: 
        None   

    Returns:
        dict: dictionary containing statistics of the object
    """
    if self._stats_keys:
        return {key: getattr(self, key, None) for key in self._stats_keys}
    return self.__dict__

Statistics

Statistics(node: object, periodic_update: bool = False, period: float = 1)

Bases: InfoMixin

The Statistics class tracks and summarizes key performance indicators for each node in the supply chain. It monitors essential metrics such as demand, inventory levels, shortages, backorders, costs, revenue, and profit. The class supports both automatic periodic updates and manual updates through the update_stats method, which can be called at any point in the simulation to immediately record changes.

The _info_keys / _stats_keys / _cost_components declarations are class-level, making the tracked set of KPIs declaratively discoverable in one place. Statistics.__init__ copies the _stats_keys and _cost_components lists to the instance before any per-node extension runs (Supplier.__init__ and Manufacturer.__init__ append their own subclass-specific KPIs), so those appends never leak into the class-level list and cross-contaminate other Statistics instances.

Parameters:
  • node (object) –

    The node for which statistics are tracked.

  • periodic_update (bool, default: False ) –

    Whether to update statistics periodically. Default is False.

  • period (float, default: 1 ) –

    Time interval for periodic updates. Default is 1.

Attributes:
  • node (object) –

    The node to which this statistics object belongs.

  • name (str) –

    Name of the statistics object. By default, it is the node's name post-fix " statistics".

  • demand_placed (list) –

    Orders this node placed, as [number of orders, units].

  • fulfillment_received (list) –

    Orders this node received (they arrived), as [number of orders, units].

  • demand_received (list) –

    Orders placed with this node (replenishment orders and customer orders), as [number of orders, units].

  • demand_fulfilled (list) –

    Orders this node fulfilled (sent out in full), as [number of orders, units].

  • shortage (list) –

    How many units this node was missing when orders arrived, as [number of orders, units]. A running total that is never reduced. orders_shortage is kept as an old name for it (§6.6).

  • backorder (list) –

    Orders this node owes right now, as [number of orders, units].

  • disruption_lost_sales (list) –

    Customer orders this site lost because it was disrupted when they arrived, as [number of orders, units]. Kept apart from shortage, which is for units the site did not have.

  • start_time (float) –

    When counting began: the time these statistics were created, or the last reset.

  • elapsed_time (float) –

    Time since start_time. Worked out when read; every average below divides by it.

  • lost_sales (list) –

    Customer units this site never delivered, as [number of orders, units]: the whole order of a customer with no tolerance who found too few units, the rest of an order when the customer takes only what is there, an order too big for the site ever to hold, and what a waiting customer was still owed when their tolerance ran out. Unlike shortage, it counts only units that were never delivered. Customers turned away by a disruption are in disruption_lost_sales instead.

  • waited (list) –

    Orders that had to wait at this site, from customers and from other sites, as [number of orders, units].

  • waiting_time (float) –

    Total time those orders waited, until their units left the inventory.

  • avg_waiting_time (float) –

    Average wait per order fulfilled, with orders served at once counting as 0: waiting_time / demand_fulfilled[0]. Worked out when read.

  • blocked (list) –

    Replenishment orders this site could not place because every supplier it has (or the link to it) was disrupted, as [number of orders, units]. They are not lost: the site orders again when a supplier or a link recovers. A site with no supplier at all records nothing here; that is a mistake in how the network is built, and a warning says so.

  • dropped_qty (float) –

    Units of deliveries that did not fit in the inventory and that no waiting customer could take. The site does not pay for them.

  • inventory_level_time (float) –

    Running sum of inventory level × time (units × time units), up to now. Holding 10 units for 3 time units and then 4 units for 2 gives 38. The holding cost is this sum times the holding cost per unit per time unit.

  • avg_inventory_level (float) –

    Average inventory level over time, each level counted for as long as it lasted: inventory_level_time / elapsed_time (7.6 in the example above). Worked out when read.

  • zero_inventory_time (float) –

    Time the inventory level was 0, up to now.

  • zero_inventory_share (float) –

    Share of time the inventory level was 0: zero_inventory_time / elapsed_time. Worked out when read.

  • disruptions (int) –

    Number of disruptions of this node.

  • disrupted_time (float) –

    Time this node spent disrupted, including a disruption still going on when it is read.

  • availability (float) –

    Share of time this node was not disrupted: 1 - disrupted_time / elapsed_time. Worked out when read.

  • inventory_level (float) –

    The inventory level at the last update.

  • inventory_waste (float) –

    Expired units thrown away so far.

  • inventory_carry_cost (float) –

    Holding cost so far.

  • inventory_spend_cost (float) –

    What this node paid for the units it received.

  • transportation_cost (float) –

    Transportation cost of this node's orders.

  • destroyed_qty (float) –

    Units destroyed in disruptions (see Inventory.destroy).

  • destroyed_value (float) –

    Disposal cost of the destroyed units: destroyed_qty times the node's disposal_cost (0 unless set); part of node_cost. What the node paid for those units is not added again: it is already in inventory_spend_cost.

  • node_cost (float) –

    Everything this node spent (the sum of its costs).

  • revenue (float) –

    Sell price times the units this node fulfilled.

  • profit (float) –

    Revenue minus node cost.

  • _info_keys (list) –

    Keys to include in the info dictionary.

  • _stats_keys (list) –

    Keys to include in the statistics dictionary.

  • _live_keys (list) –

    Statistics that describe the present (backorder, inventory_level); reset keeps them.

Methods:

Name Description
reset

Starts the running totals again from zero (for example after a warm-up), keeping the values that describe the present.

update_stats

Updates statistics based on provided values.

update_stats_periodically

Periodically updates statistics during simulation.

disruption_started, disruption_ended

Called by the node when a disruption starts and ends.

Values worked out when read are None when there is nothing to divide by (no time has passed, or no order has been fulfilled yet). The four inventory values are None for an infinite inventory and for a customer demand, which has no inventory.

Initialize the statistics object.

Parameters:
  • node (object) –

    The node for which statistics are tracked.

  • periodic_update (bool, default: False ) –

    Whether to update statistics periodically. Default is False.

  • period (float, default: 1 ) –

    Time interval for periodic updates. Default is 1.

Attributes:
  • node (object) –

    The node to which this statistics object belongs.

  • name (str) –

    Name of the statistics object.

  • demand_placed (list) –

    Orders this node placed, as [number of orders, units].

  • fulfillment_received (list) –

    Orders this node received (they arrived), as [number of orders, units].

  • demand_received (list) –

    Orders placed with this node (replenishment orders and customer orders), as [number of orders, units].

  • demand_fulfilled (list) –

    Orders this node fulfilled (sent out in full), as [number of orders, units].

  • shortage (list) –

    How many units this node was missing when orders arrived, as [number of orders, units]. A running total that is never reduced. orders_shortage is kept as an old name for it (§6.6).

  • backorder (list) –

    Orders this node owes right now, as [number of orders, units].

  • disruption_lost_sales (list) –

    Customer orders this site lost because it was disrupted when they arrived, as [number of orders, units]. Kept apart from shortage, which is for units the site did not have.

  • start_time (float) –

    When counting began: the time these statistics were created, or the last reset.

  • elapsed_time (float) –

    Time since start_time. Worked out when read; every average below divides by it.

  • lost_sales (list) –

    Customer units this site never delivered, as [number of orders, units]: the whole order of a customer with no tolerance who found too few units, the rest of an order when the customer takes only what is there, an order too big for the site ever to hold, and what a waiting customer was still owed when their tolerance ran out. Unlike shortage, it counts only units that were never delivered. Customers turned away by a disruption are in disruption_lost_sales instead.

  • waited (list) –

    Orders that had to wait at this site, from customers and from other sites, as [number of orders, units].

  • waiting_time (float) –

    Total time those orders waited, until their units left the inventory.

  • avg_waiting_time (float) –

    Average wait per order fulfilled, with orders served at once counting as 0: waiting_time / demand_fulfilled[0]. Worked out when read.

  • blocked (list) –

    Replenishment orders this site could not place because every supplier it has (or the link to it) was disrupted, as [number of orders, units]. They are not lost: the site orders again when a supplier or a link recovers. A site with no supplier at all records nothing here; that is a mistake in how the network is built, and a warning says so.

  • dropped_qty (float) –

    Units of deliveries that did not fit in the inventory and that no waiting customer could take. The site does not pay for them.

  • inventory_level_time (float) –

    Running sum of inventory level × time (units × time units), up to now. Holding 10 units for 3 time units and then 4 units for 2 gives 38. The holding cost is this sum times the holding cost per unit per time unit.

  • avg_inventory_level (float) –

    Average inventory level over time, each level counted for as long as it lasted: inventory_level_time / elapsed_time (7.6 in the example above). Worked out when read.

  • zero_inventory_time (float) –

    Time the inventory level was 0, up to now.

  • zero_inventory_share (float) –

    Share of time the inventory level was 0: zero_inventory_time / elapsed_time. Worked out when read.

  • disruptions (int) –

    Number of disruptions of this node.

  • disrupted_time (float) –

    Time this node spent disrupted, including a disruption still going on when it is read.

  • availability (float) –

    Share of time this node was not disrupted: 1 - disrupted_time / elapsed_time. Worked out when read.

  • inventory_level (float) –

    The inventory level at the last update.

  • inventory_waste (float) –

    Expired units thrown away so far.

  • inventory_carry_cost (float) –

    Holding cost so far.

  • inventory_spend_cost (float) –

    What this node paid for the units it received.

  • transportation_cost (float) –

    Transportation cost of this node's orders.

  • node_cost (float) –

    Everything this node spent (the sum of its costs).

  • revenue (float) –

    Sell price times the units this node fulfilled.

  • profit (float) –

    Revenue minus node cost.

  • _info_keys (list) –

    Keys to include in the info dictionary.

  • _stats_keys (list) –

    Keys to include in the statistics dictionary.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
def __init__(self, node:object, periodic_update:bool=False, period:float=1):
    """
    Initialize the statistics object.

    Parameters:
        node (object): The node for which statistics are tracked.
        periodic_update (bool, optional): Whether to update statistics periodically. Default is False.
        period (float, optional): Time interval for periodic updates. Default is 1.

    Attributes:
        node (object): The node to which this statistics object belongs.
        name (str): Name of the statistics object.
        demand_placed (list): Orders this node placed, as [number of orders, units].
        fulfillment_received (list): Orders this node received (they arrived), as [number of orders, units].
        demand_received (list): Orders placed with this node (replenishment orders and customer orders), as [number of orders, units].
        demand_fulfilled (list): Orders this node fulfilled (sent out in full), as [number of orders, units].
        shortage (list): How many units this node was missing when orders arrived, as [number of orders, units]. A running total that is never reduced. ``orders_shortage`` is kept as an old name for it (§6.6).
        backorder (list): Orders this node owes right now, as [number of orders, units].
        disruption_lost_sales (list): Customer orders this site lost because it was disrupted when they arrived, as [number of orders, units]. Kept apart from ``shortage``, which is for units the site did not have.
        start_time (float): When counting began: the time these statistics were created, or the last ``reset``.
        elapsed_time (float): Time since ``start_time``. Worked out when read; every average below divides by it.
        lost_sales (list): Customer units this site never delivered, as [number of orders, units]: the whole order of a customer with no tolerance who found too few units, the rest of an order when the customer takes only what is there, an order too big for the site ever to hold, and what a waiting customer was still owed when their tolerance ran out. Unlike ``shortage``, it counts only units that were never delivered. Customers turned away by a disruption are in ``disruption_lost_sales`` instead.
        waited (list): Orders that had to wait at this site, from customers and from other sites, as [number of orders, units].
        waiting_time (float): Total time those orders waited, until their units left the inventory.
        avg_waiting_time (float): Average wait per order fulfilled, with orders served at once counting as 0: ``waiting_time / demand_fulfilled[0]``. Worked out when read.
        blocked (list): Replenishment orders this site could not place because every supplier it has (or the link to it) was disrupted, as [number of orders, units]. They are not lost: the site orders again when a supplier or a link recovers. A site with no supplier at all records nothing here; that is a mistake in how the network is built, and a warning says so.
        dropped_qty (float): Units of deliveries that did not fit in the inventory and that no waiting customer could take. The site does not pay for them.
        inventory_level_time (float): Running sum of inventory level × time (units × time units), up to now. Holding 10 units for 3 time units and then 4 units for 2 gives 38. The holding cost is this sum times the holding cost per unit per time unit.
        avg_inventory_level (float): Average inventory level over time, each level counted for as long as it lasted: ``inventory_level_time / elapsed_time`` (7.6 in the example above). Worked out when read.
        zero_inventory_time (float): Time the inventory level was 0, up to now.
        zero_inventory_share (float): Share of time the inventory level was 0: ``zero_inventory_time / elapsed_time``. Worked out when read.
        disruptions (int): Number of disruptions of this node.
        disrupted_time (float): Time this node spent disrupted, including a disruption still going on when it is read.
        availability (float): Share of time this node was not disrupted: ``1 - disrupted_time / elapsed_time``. Worked out when read.
        inventory_level (float): The inventory level at the last update.
        inventory_waste (float): Expired units thrown away so far.
        inventory_carry_cost (float): Holding cost so far.
        inventory_spend_cost (float): What this node paid for the units it received.
        transportation_cost (float): Transportation cost of this node's orders.
        node_cost (float): Everything this node spent (the sum of its costs).
        revenue (float): Sell price times the units this node fulfilled.
        profit (float): Revenue minus node cost.
        _info_keys (list): Keys to include in the info dictionary.
        _stats_keys (list): Keys to include in the statistics dictionary.

    Returns:
        None
    """
    # Clone the class-level declarative lists onto the instance so that
    # per-node extensions (e.g. ``Supplier`` appending "total_material_cost"
    # to its own ``self.stats._stats_keys``) do not leak into the shared
    # class attribute and pollute other Statistics instances.
    self._stats_keys = list(type(self)._stats_keys)
    self._cost_components = list(type(self)._cost_components)
    self.node = node # the node to which this statistics object belongs
    self.name = f"{self.node.ID} statistics"
    self.demand_placed = [0,0] # orders this node placed: [number of orders, units]
    self.fulfillment_received = [0,0] # orders that arrived at this node
    self.demand_received = [0,0] # orders placed with this node
    self.demand_fulfilled = [0,0] # orders this node sent out in full
    # ``shortage`` (was ``orders_shortage`` before §6.6) — renamed to
    # match ``supplychainnet["shortage"]`` so the same vocabulary applies
    # at both the node and network level. ``orders_shortage`` is kept as
    # a read-only alias / accepted ``update_stats`` kwarg for back-compat.
    self.shortage = [0,0] # units missing when orders arrived (running total)
    self.start_time = self.node.env.now # when counting began (moved by reset)
    self.lost_sales = [0,0] # customer units never delivered (other than through a disruption)
    self.disruption_lost_sales = [0,0] # customer orders lost because the site was disrupted when they arrived
    self.backorder = [0,0] # orders this node owes right now
    self.waited = [0,0] # orders that had to wait at this site
    self.waiting_time = 0 # total time those orders waited
    self.blocked = [0,0] # replenishment orders not placed because no supplier could take them
    self.dropped_qty = 0 # delivered units that did not fit in the inventory
    self.disruptions = 0 # number of disruptions of this node
    # Time spent disrupted, kept as the total of disruptions that have
    # ended plus the start of the one going on now (if any), so that
    # ``disrupted_time`` can include the ongoing part when it is read.
    self._disrupted_time = 0
    self._disrupted_since = None
    self.inventory_level = 0 # inventory level at the last update
    self.inventory_waste = 0 # expired units thrown away so far
    self.inventory_carry_cost = 0 # holding cost so far
    self.inventory_spend_cost = 0 # what this node paid for the units it received
    self.transportation_cost = 0 # transportation cost of this node's orders
    # Units destroyed in disruptions (for example, a flood at a
    # warehouse). ``destroyed_qty`` counts the units; ``destroyed_value``
    # is the cost of disposing of them, and is part of ``node_cost`` (see
    # _cost_components).
    self.destroyed_qty = 0
    self.destroyed_value = 0
    self.node_cost = 0 # everything this node spent
    self.revenue = 0 # sell price times the units this node fulfilled
    self.profit = 0 # revenue minus node cost

    if(periodic_update):
        self.node.env.process(self.update_stats_periodically(period=period))

elapsed_time property

elapsed_time

Time since counting began (start_time): the creation of these statistics, or the last reset.

disrupted_time property

disrupted_time

Time this node spent disrupted, including a disruption still going on now.

availability property

availability

Share of time this node was not disrupted: 1 - disrupted_time / elapsed_time. None if no time has passed.

avg_waiting_time property

avg_waiting_time

Average wait per order fulfilled, orders served at once counting as 0. None before any order is fulfilled.

inventory_level_time property

inventory_level_time

Running sum of inventory level × time, up to now (see the class docstring). None without a finite inventory.

zero_inventory_time property

zero_inventory_time

Time the inventory level was 0, up to now. None without a finite inventory.

avg_inventory_level property

avg_inventory_level

Average inventory level over time: inventory_level_time / elapsed_time. None if it cannot be worked out.

zero_inventory_share property

zero_inventory_share

Share of time the inventory level was 0: zero_inventory_time / elapsed_time. None if it cannot be worked out.

orders_shortage property writable

orders_shortage

Old name kept for compatibility — same as shortage. Returns the same list, so changing it also changes shortage.

reset

reset()

Start the statistics again from zero, for example after a warm-up.

A warm-up is an opening stretch of a run that is left out of the statistics, because a run starts in an unusual state (nothing in transit, no orders waiting). Run for the warm-up period, call reset() on each node, and carry on running. The simulation itself carries on unchanged; only the counting starts again:

  • Running totals (orders, units, costs, shortage, expired units and so on) are set to zero, including those a Supplier or Manufacturer adds.
  • Values that describe the present (_live_keys) are kept: backorder, what the node owes right now, and inventory_level, which is read again from the inventory.
  • The holding cost is brought up to date first, so that after the reset it covers only the time since the reset.
  • The once-per-time-unit record of the inventory level (inventory.instantaneous_levels) starts again.
  • The inventory itself (inventory level, units in transit, waiting orders) is not touched.

Each event is counted when it happens. So an order placed before the reset and received after it counts as received but not as placed, and units bought before the reset that expire after it count as expired units after it. A partial delivery to a customer takes 1 off the order count of demand_fulfilled when it is sent and adds 1 back when it arrives; if the reset falls in between, that customer order is counted twice in the order count (the units are counted correctly).

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
def reset(self):
    """
    Start the statistics again from zero, for example after a warm-up.

    A warm-up is an opening stretch of a run that is left out of the
    statistics, because a run starts in an unusual state (nothing in
    transit, no orders waiting). Run for the warm-up period, call
    ``reset()`` on each node, and carry on running. The simulation itself
    carries on unchanged; only the counting starts again:

    - Running totals (orders, units, costs, shortage, expired units and
      so on) are set to zero, including those a ``Supplier`` or
      ``Manufacturer`` adds.
    - Values that describe the present (``_live_keys``) are kept:
      ``backorder``, what the node owes right now, and ``inventory_level``,
      which is read again from the inventory.
    - The holding cost is brought up to date first, so that after the
      reset it covers only the time since the reset.
    - The once-per-time-unit record of the inventory level
      (``inventory.instantaneous_levels``) starts again.
    - The inventory itself (inventory level, units in transit, waiting
      orders) is not touched.

    Each event is counted when it happens. So an order placed before the
    reset and received after it counts as received but not as placed, and
    units bought before the reset that expire after it count as expired
    units after it. A partial delivery to a customer takes 1 off the order
    count of ``demand_fulfilled`` when it is sent and adds 1 back when it
    arrives; if the reset falls in between, that customer order is counted
    twice in the order count (the units are counted correctly).

    Parameters:
        None

    Returns:
        None
    """
    inventory = getattr(self.node, 'inventory', None)
    # An infinite inventory (an infinite supplier) has no holding cost and
    # no inventory level worth recording, as in update_stats.
    finite_inventory = inventory is not None and inventory.level != float('inf')
    if finite_inventory:
        # Charge the holding cost up to now, so that no time before the
        # reset is charged after it.
        inventory.update_carry_cost()
    # Pass 1: zero every statistic listed in _stats_keys (the list of
    # tracked statistics), except the values that describe the present and
    # the values worked out when read (properties such as
    # ``avg_inventory_level``), which follow from the totals.
    for key in self._stats_keys:
        if not hasattr(self, key) or key == "name" or key in self._live_keys:
            continue
        if isinstance(getattr(type(self), key, None), property):
            continue
        current = getattr(self, key)
        if isinstance(current, list):
            setattr(self, key, [0, 0])
        elif isinstance(current, (int, float)):
            setattr(self, key, 0)
    # Pass 2: a safety net for any counter that is not in the tracked list
    # but used to be reset by the old "reset everything" sweep. It looks
    # only at this Statistics object, never at the node's units or orders.
    for key, value in vars(self).items():
        if key.startswith("_") or key in self._stats_keys or key in self._live_keys:
            continue
        if isinstance(value, list):
            setattr(self, key, [0, 0])
        elif isinstance(value, (int, float)):
            setattr(self, key, 0)
    # Some counters live on the Inventory object, not on Statistics, so they
    # have to be reset here by hand. If you add a new Inventory counter that
    # should reset, add it to this block (or move the counter onto
    # Statistics by listing it in _stats_keys).
    if inventory is not None:
        inventory.carry_cost = 0
        inventory.waste = 0
        inventory.level_time = 0
        inventory.zero_level_time = 0
        inventory.instantaneous_levels = []
    if finite_inventory:
        self.inventory_level = inventory.level
    # The clock starts again, so every average covers only the time since
    # the reset. A disruption going on now is counted from now on; it does
    # not add to ``disruptions``, because it started before the reset.
    now = self.node.env.now
    self.start_time = now
    self._disrupted_time = 0
    if self._disrupted_since is not None:
        self._disrupted_since = now

disruption_started

disruption_started()

Record that this node's disruption starts now (called by Node.disruption).

Source code in src/SupplyNetPy/Components/core.py
683
684
685
686
def disruption_started(self):
    """Record that this node's disruption starts now (called by ``Node.disruption``)."""
    self.disruptions += 1
    self._disrupted_since = self.node.env.now

disruption_ended

disruption_ended()

Record that this node's disruption ends now (called by Node.disruption).

Source code in src/SupplyNetPy/Components/core.py
688
689
690
691
692
def disruption_ended(self):
    """Record that this node's disruption ends now (called by ``Node.disruption``)."""
    if self._disrupted_since is not None:
        self._disrupted_time += self.node.env.now - self._disrupted_since
        self._disrupted_since = None

update_stats

update_stats(**kwargs)

Update the statistics with the given keyword arguments.

Parameters:
  • **kwargs –

    keyword arguments containing the statistics to update. orders_shortage= is still accepted as the old name for shortage= (the attribute was renamed).

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
def update_stats(self,**kwargs):
    """
    Update the statistics with the given keyword arguments.

    Parameters:
        **kwargs: keyword arguments containing the statistics to update.
            ``orders_shortage=`` is still accepted as the old name for
            ``shortage=`` (the attribute was renamed).

    Attributes:
        None

    Returns:
        None
    """
    # ``orders_shortage`` was renamed to ``shortage``. Older code (and some
    # examples) still pass the old name, so map it onto the new one here and
    # let both names update the same counter. If a caller passes both, the
    # new name wins.
    if "orders_shortage" in kwargs and "shortage" not in kwargs:
        kwargs["shortage"] = kwargs.pop("orders_shortage")
    elif "orders_shortage" in kwargs:
        kwargs.pop("orders_shortage")
    for key, value in kwargs.items():
        if hasattr(self, key):
            attr = getattr(self, key)
            if isinstance(attr, list): # value = [v1,v2]
                attr[0] += value[0]
                attr[1] += value[1]
                setattr(self, key, attr) # update the attribute with the new value
            else:
                attr += value
                setattr(self, key, attr) # update the attribute with the new value
        else:
            # Most likely a typo in a custom policy or callback. The value is
            # ignored, so the user is warned (the text leaves out the node,
            # so each unknown name is shown once).
            global_logger.warning(f"{self.node.ID}: update_stats got an unknown statistic '{key}'; it is ignored.")
            warnings.warn(f"update_stats got an unknown statistic '{key}', so it is ignored. "
                          f"Check the name for a typo.", UserWarning, stacklevel=2)
    if hasattr(self.node, 'inventory'):
        if self.node.inventory.level != float('inf'):
            self.inventory_level = self.node.inventory.level if hasattr(self.node, 'inventory') else 0
            self.node.inventory.update_carry_cost()
            self.inventory_carry_cost = self.node.inventory.carry_cost
            self.inventory_waste = self.node.inventory.waste if hasattr(self.node.inventory, 'waste') else 0
    self.node_cost = sum(getattr(self, name) for name in self._cost_components)
    self.revenue = self.demand_fulfilled[1] * self.node.sell_price if hasattr(self.node, 'sell_price') else 0
    self.profit = self.revenue - self.node_cost

update_stats_periodically

update_stats_periodically(period)

Update the statistics periodically.

Parameters:
  • period (float) –

    period for periodic update of statistics

Returns:
  • generator –

    a generator that yields after the specified period

Source code in src/SupplyNetPy/Components/core.py
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
def update_stats_periodically(self, period):
    """
    Update the statistics periodically.

    Parameters:
        period (float): period for periodic update of statistics

    Attributes:
        None

    Returns:
        generator: a generator that yields after the specified period
    """
    while True:
        yield self.node.env.timeout(period)
        self.update_stats()

LinkStatistics

LinkStatistics(link)

Bases: InfoMixin

The LinkStatistics class records what flows over one link: the replenishment orders its buyer placed over it, what arrived, how long the orders waited and travelled, what they cost, and the link's own disruptions. Every link has one, as link.stats, created with the link.

Node statistics say how much a site ordered and received in total, but not from whom. A buyer with two suppliers has a link to each, and each link counts only the orders sent over it. A site finds the links it orders over in node.suppliers, and the links its buyers order over in node.buyers. The same events are counted in the node statistics too, so the totals over a buyer's links add up to the buyer's own figures. Customers do not order over links, so customer orders are not in any link (each Demand has its own statistics).

Running totals are kept, not a record of each order. Values worked out when read are properties, and are None when there is nothing to divide by (a fill rate of 0 would look like a very bad link).

Two spreads of order sizes. Both are sample standard deviations (how far values usually are from their average, dividing by one less than the number of values), and both are None with fewer than two values. order_size_std takes one value per order: the size of each order. units_per_time_unit_std takes one value per time unit: the units ordered in that time unit, with time units in which nothing was ordered counting as 0. The two can disagree completely. A distributor that orders 60 units at time 3 and 60 units at time 7, and nothing in between, has an order_size_std of 0 (every order is the same size) but a large units_per_time_unit_std (the orders come in bursts). The bullwhip effect (orders becoming more uneven as they pass from the customers towards the raw-material suppliers) is measured per time unit: compare a link's units_per_time_unit_std with the spread of customer demand per time unit.

Parameters:
  • link (Link) –

    The link whose statistics these are.

Attributes:
  • link (Link) –

    The link whose statistics these are.

  • name (str) –

    Name of the statistics object: the link's ID followed by " statistics".

  • start_time (float) –

    When counting began: the time the link was created, or the last reset.

  • elapsed_time (float) –

    Time since start_time. Worked out when read.

  • demand_placed (list) –

    Replenishment orders the buyer placed over the link, as [number of orders, units].

  • fulfillment_received (list) –

    Orders that arrived at the buyer, as [number of orders, units]. Only the units the buyer accepted are counted, as in the buyer's own statistics.

  • shortage (list) –

    How many units the supplier was missing when this buyer's orders reached it, as [number of orders, units]. A running total that is never reduced.

  • backorder (list) –

    This buyer's orders waiting at the supplier right now, as [number of orders, units].

  • waited (list) –

    Orders that had to wait at the supplier, as [number of orders, units]. Unlike backorder, it is a running total, so it still counts an order once the order is served.

  • blocked (list) –

    Orders the buyer was about to send over this link but could not place, because the link or the supplier's site was disrupted by then, as [number of orders, units]. Orders blocked before any link was chosen (every supplier disrupted) are counted only in the buyer's node statistics. Links from raw-material suppliers never record any.

  • transportation_cost (float) –

    Transportation cost of the orders placed over the link.

  • inventory_spend_cost (float) –

    What the buyer paid for the units it received over the link.

  • waiting_time (float) –

    Total time orders waited at the supplier, from being placed until their units left the supplier's inventory (orders served at once wait 0). Added when the order arrives.

  • lead_time (float) –

    Total lead time of the orders that arrived.

  • avg_waiting_time (float) –

    Average wait per order received: waiting_time / fulfillment_received[0]. Worked out when read.

  • avg_lead_time (float) –

    Average lead time per order received: lead_time / fulfillment_received[0]. Worked out when read.

  • avg_order_cycle_time (float) –

    Average time from placing an order until it arrives: avg_waiting_time + avg_lead_time. Worked out when read.

  • fill_rate (float) –

    Share of the units ordered that the supplier sent at once: 1 - waited[1] / demand_placed[1]. An order that had to wait counts as not sent at once, even if part of it was there. Worked out when read.

  • order_fill_rate (float) –

    The same share counted in orders: 1 - waited[0] / demand_placed[0]. Worked out when read.

  • orders_per_time_unit (float) –

    Orders placed per time unit: demand_placed[0] / elapsed_time. Worked out when read.

  • units_per_time_unit (float) –

    Units ordered per time unit: demand_placed[1] / elapsed_time. Worked out when read.

  • order_size_mean (float) –

    Average size of the orders placed. Worked out when read.

  • order_size_std (float) –

    Standard deviation of the size of the orders placed (see above). Worked out when read.

  • units_per_time_unit_std (float) –

    Standard deviation of the units ordered in each time unit (see above). Time units are counted from start_time and are 1 long. The time unit still going on is left out. Worked out when read.

  • disruptions (int) –

    Number of disruptions of the link.

  • disrupted_time (float) –

    Time the link spent disrupted, including a disruption still going on when it is read. A link set to "inactive" by hand, rather than by its disruption process, is not counted.

  • availability (float) –

    Share of time the link was not disrupted: 1 - disrupted_time / elapsed_time. Worked out when read.

  • _info_keys (list) –

    Keys to include in the info dictionary.

  • _stats_keys (list) –

    Keys to include in the statistics dictionary.

  • _live_keys (list) –

    Statistics that describe the present (backorder); reset keeps them.

Methods:

Name Description
update_stats

Adds the given amounts to the running totals.

record_order

Adds the size of one order placed to the two spreads of order sizes.

disruption_started, disruption_ended

Called by the link when a disruption starts and ends.

reset

Starts the running totals again from zero (for example after a warm-up), keeping the orders still waiting.

Initialize the statistics object.

Parameters:
  • link (Link) –

    The link whose statistics these are.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
def __init__(self, link):
    """
    Initialize the statistics object.

    Parameters:
        link (Link): The link whose statistics these are.

    Returns:
        None
    """
    self.link = link
    self.name = f"{link.ID} statistics"
    self._clear_totals()
    self.backorder = [0, 0] # this buyer's orders waiting at the supplier right now
    self.start_time = link.env.now # when counting began (moved by reset)
    # Time spent disrupted, kept as the total of disruptions that have
    # ended plus the start of the one going on now (if any), so that
    # ``disrupted_time`` can include the ongoing part when it is read.
    self._disrupted_since = None

elapsed_time property

elapsed_time

Time since counting began (start_time): the creation of the link, or the last reset.

disrupted_time property

disrupted_time

Time the link spent disrupted, including a disruption still going on now.

availability property

availability

Share of time the link was not disrupted: 1 - disrupted_time / elapsed_time. None if no time has passed.

avg_waiting_time property

avg_waiting_time

Average wait at the supplier per order received. None before any order arrives.

avg_lead_time property

avg_lead_time

Average lead time per order received. None before any order arrives.

avg_order_cycle_time property

avg_order_cycle_time

Average time from placing an order until it arrives: waiting time plus lead time. None before any order arrives.

fill_rate property

fill_rate

Share of the units ordered that the supplier sent at once, without the order waiting. None before any order.

order_fill_rate property

order_fill_rate

Share of the orders that the supplier sent at once, without waiting. None before any order.

orders_per_time_unit property

orders_per_time_unit

Orders placed per time unit. None if no time has passed.

units_per_time_unit property

units_per_time_unit

Units ordered per time unit. None if no time has passed.

order_size_mean property

order_size_mean

Average size of the orders placed. None before any order.

order_size_std property

order_size_std

Standard deviation of the size of the orders placed. None with fewer than two orders.

units_per_time_unit_std property

units_per_time_unit_std

Standard deviation of the units ordered in each finished time unit. None with fewer than two finished time units.

record_order

record_order(quantity)

Add the size of one order placed over the link to the two spreads of order sizes (see the class description). Called together with update_stats(demand_placed=...).

Parameters:
  • quantity (float) –

    Units ordered.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
def record_order(self, quantity):
    """
    Add the size of one order placed over the link to the two spreads of
    order sizes (see the class description). Called together with
    ``update_stats(demand_placed=...)``.

    Parameters:
        quantity (float): Units ordered.

    Returns:
        None
    """
    self._order_sizes = self._add_value(self._order_sizes, quantity)
    now = self._time_unit_now()
    if now > self._current_time_unit:
        # A new time unit has started since the last order: store the
        # finished ones and start counting this one.
        self._per_time_unit = self._per_time_unit_up_to_now()
        self._current_time_unit = now
        self._current_units = 0
    self._current_units += quantity

update_stats

update_stats(**kwargs)

Add the given amounts to the running totals, for example update_stats(demand_placed=[1, 50], transportation_cost=5). [number of orders, units] pairs are added pair by pair.

Parameters:
  • **kwargs –

    The statistics to add to, by name, with the amounts to add.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
def update_stats(self, **kwargs):
    """
    Add the given amounts to the running totals, for example
    ``update_stats(demand_placed=[1, 50], transportation_cost=5)``.
    ``[number of orders, units]`` pairs are added pair by pair.

    Parameters:
        **kwargs: The statistics to add to, by name, with the amounts to add.

    Returns:
        None
    """
    for key, value in kwargs.items():
        stored = (key in self._stats_keys and key != "name"
                  and not isinstance(getattr(type(self), key, None), property))
        if not stored:
            # Most likely a typo. The value is ignored, so the user is
            # warned (the text leaves out the link, so each unknown name
            # is shown once).
            global_logger.warning(f"{self.link.ID}: update_stats got an unknown statistic '{key}'; it is ignored.")
            warnings.warn(f"update_stats got an unknown statistic '{key}', so it is ignored. "
                          f"Check the name for a typo.", UserWarning, stacklevel=2)
            continue
        current = getattr(self, key)
        if isinstance(current, list):
            current[0] += value[0]
            current[1] += value[1]
        else:
            setattr(self, key, current + value)

disruption_started

disruption_started()

Record that the link's disruption starts now (called by Link.disruption).

Source code in src/SupplyNetPy/Components/core.py
1024
1025
1026
1027
def disruption_started(self):
    """Record that the link's disruption starts now (called by ``Link.disruption``)."""
    self.disruptions += 1
    self._disrupted_since = self.link.env.now

disruption_ended

disruption_ended()

Record that the link's disruption ends now (called by Link.disruption).

Source code in src/SupplyNetPy/Components/core.py
1029
1030
1031
1032
1033
def disruption_ended(self):
    """Record that the link's disruption ends now (called by ``Link.disruption``)."""
    if self._disrupted_since is not None:
        self._disrupted_time += self.link.env.now - self._disrupted_since
        self._disrupted_since = None

reset

reset()

Start the statistics again from zero, for example after a warm-up (see Statistics.reset). The simulation carries on unchanged; only the counting starts again:

  • Running totals and both spreads of order sizes are set to zero.
  • backorder is kept: orders waiting at the reset are still waiting after it.
  • start_time becomes now, so every average and every value per time unit covers only the time since the reset.
  • A disruption going on now is counted from now on. It does not add to disruptions, because it started before the reset.

Each event is counted when it happens. So an order placed before the reset and received after it counts as received but not as placed. The averages stay correct, because each one divides by a count recorded at the same moment as its total.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
def reset(self):
    """
    Start the statistics again from zero, for example after a warm-up (see
    ``Statistics.reset``). The simulation carries on unchanged; only the
    counting starts again:

    - Running totals and both spreads of order sizes are set to zero.
    - ``backorder`` is kept: orders waiting at the reset are still waiting
      after it.
    - ``start_time`` becomes now, so every average and every value per time
      unit covers only the time since the reset.
    - A disruption going on now is counted from now on. It does not add to
      ``disruptions``, because it started before the reset.

    Each event is counted when it happens. So an order placed before the
    reset and received after it counts as received but not as placed. The
    averages stay correct, because each one divides by a count recorded at
    the same moment as its total.

    Parameters:
        None

    Returns:
        None
    """
    self._clear_totals()
    now = self.link.env.now
    self.start_time = now
    if self._disrupted_since is not None:
        self._disrupted_since = now

RawMaterial

RawMaterial(ID: str, name: str, extraction_quantity: float, extraction_time: float, mining_cost: float, cost: float)

Bases: NamedEntity, InfoMixin

The RawMaterial class represents a raw material: what a raw-material supplier mines and a manufacturer uses up. A raw-material supplier mines extraction_quantity units every extraction_time time units, at mining_cost per unit, and sells them at cost per unit. (The parameter names say "extraction"; it means the same as mining.)

Parameters:
  • ID (str) –

    ID of the raw material.

  • name (str) –

    Name of the raw material.

  • extraction_quantity (float) –

    Units mined in each round of mining.

  • extraction_time (float) –

    Time units each round of mining takes.

  • mining_cost (float) –

    Cost of mining one unit.

  • cost (float) –

    The raw-material supplier's sell price per unit (what a buyer pays for one unit).

Attributes:
  • _info_keys (list) –

    Keys to include in the info dictionary.

  • _stats_keys (list) –

    Keys to include in the statistics dictionary.

  • ID (str) –

    ID of the raw material.

  • name (str) –

    Name of the raw material.

  • extraction_quantity (float) –

    Units mined in each round of mining.

  • extraction_time (float) –

    Time units each round of mining takes.

  • mining_cost (float) –

    Cost of mining one unit.

  • cost (float) –

    The raw-material supplier's sell price per unit.

Methods:

Name Description

Initialize the raw material object.

Parameters:
  • ID (str) –

    ID of the raw material (alphanumeric)

  • name (str) –

    name of the raw material

  • extraction_quantity (float) –

    units mined in each round of mining

  • extraction_time (float) –

    time units each round of mining takes

  • mining_cost (float) –

    cost of mining one unit

  • cost (float) –

    the raw-material supplier's sell price per unit

Attributes:
  • _info_keys (list) –

    list of keys to include in the info dictionary

  • _stats_keys (list) –

    list of keys to include in the statistics dictionary

  • ID (str) –

    ID of the raw material (alphanumeric)

  • name (str) –

    name of the raw material

  • extraction_quantity (float) –

    units mined in each round of mining

  • extraction_time (float) –

    time units each round of mining takes

  • mining_cost (float) –

    cost of mining one unit

  • cost (float) –

    the raw-material supplier's sell price per unit

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
def __init__(self,
             ID: str,
             name: str,
             extraction_quantity: float,
             extraction_time: float,
             mining_cost: float,
             cost: float) -> None:
    """
    Initialize the raw material object.

    Parameters:
        ID (str): ID of the raw material (alphanumeric)
        name (str): name of the raw material
        extraction_quantity (float): units mined in each round of mining
        extraction_time (float): time units each round of mining takes
        mining_cost (float): cost of mining one unit
        cost (float): the raw-material supplier's sell price per unit

    Attributes:
        _info_keys (list): list of keys to include in the info dictionary
        _stats_keys (list): list of keys to include in the statistics dictionary
        ID (str): ID of the raw material (alphanumeric)
        name (str): name of the raw material
        extraction_quantity (float): units mined in each round of mining
        extraction_time (float): time units each round of mining takes
        mining_cost (float): cost of mining one unit
        cost (float): the raw-material supplier's sell price per unit

    Returns:
        None
    """
    validate_positive("Extraction quantity", extraction_quantity)
    validate_non_negative("Extraction time", extraction_time)
    validate_non_negative("Mining Cost", mining_cost)
    validate_positive("Cost", cost)
    self.ID = ID # ID of the raw material (alphanumeric)
    self.name = name # name of the raw material
    self.extraction_quantity = extraction_quantity # units mined in each round of mining
    self.extraction_time = extraction_time # time units each round of mining takes
    self.mining_cost = mining_cost # cost of mining one unit
    self.cost = cost # the raw-material supplier's sell price per unit

Product

Product(ID: str, name: str, manufacturing_cost: float, manufacturing_time: float, sell_price: float, raw_materials: list, batch_size: int, buy_price: float = 0)

Bases: NamedEntity, InfoMixin

The Product class represents the product that a manufacturer makes and other sites buy and sell. It defines the manufacturing cost, the manufacturing time, the sell price, and the raw materials needed to make one unit. A manufacturer makes it in production batches: at most batch_size units at a time, each production batch taking manufacturing_time.

Parameters:
  • ID (str) –

    ID of the product.

  • name (str) –

    Name of the product.

  • manufacturing_cost (float) –

    Manufacturing cost per unit.

  • manufacturing_time (float) –

    Time units to make one production batch.

  • sell_price (float) –

    Sell price per unit.

  • raw_materials (list) –

    List of (raw material, quantity) tuples: the raw materials needed to make one unit.

  • batch_size (int) –

    The most units made in one production batch.

  • buy_price (float, default: 0 ) –

    Buy price per unit (default is 0).

Attributes:
  • _info_keys (list) –

    Keys to include in the info dictionary.

  • _stats_keys (list) –

    Keys to include in the statistics dictionary.

  • ID (str) –

    ID of the product.

  • name (str) –

    Name of the product.

  • manufacturing_cost (float) –

    Manufacturing cost per unit.

  • manufacturing_time (float) –

    Time units to make one production batch.

  • sell_price (float) –

    Sell price per unit.

  • buy_price (float) –

    Buy price per unit.

  • raw_materials (list) –

    List of (raw material, quantity) tuples needed to make one unit.

  • batch_size (int) –

    The most units made in one production batch.

Methods:

Name Description

Initialize the product object.

Performs input validation for positive and non-negative values, and ensures raw materials are provided.

Parameters:
  • ID (str) –

    ID of the product (alphanumeric)

  • name (str) –

    Name of the product

  • manufacturing_cost (float) –

    Manufacturing cost per unit

  • manufacturing_time (float) –

    Time units to make one production batch

  • sell_price (float) –

    Sell price per unit

  • buy_price (float, default: 0 ) –

    Buy price per unit (default is 0)

  • raw_materials (list) –

    List of (raw material, quantity) tuples: the raw materials needed to make one unit

  • batch_size (int) –

    The most units made in one production batch

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary

  • _stats_keys (list) –

    List of keys to include in the statistics dictionary

  • ID (str) –

    ID of the product

  • name (str) –

    Name of the product

  • manufacturing_cost (float) –

    Manufacturing cost per unit

  • manufacturing_time (float) –

    Time units to make one production batch

  • sell_price (float) –

    Sell price per unit

  • buy_price (float) –

    Buy price per unit (default is 0)

  • raw_materials (list) –

    List of (raw material, quantity) tuples needed to make one unit

  • batch_size (int) –

    The most units made in one production batch

Returns:
  • None –

    None

Raises:
  • ValueError –

    If validations fail for positive values, non-negative values, or empty raw materials list.

Source code in src/SupplyNetPy/Components/core.py
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
def __init__(self,
             ID: str,
             name: str,
             manufacturing_cost: float,
             manufacturing_time: float,
             sell_price: float,
             raw_materials: list,
             batch_size: int,
             buy_price: float = 0) -> None:
    """
    Initialize the product object.

    Performs input validation for positive and non-negative values, and ensures raw materials are provided.

    Parameters:
        ID (str): ID of the product (alphanumeric)
        name (str): Name of the product
        manufacturing_cost (float): Manufacturing cost per unit
        manufacturing_time (float): Time units to make one production batch
        sell_price (float): Sell price per unit
        buy_price (float, optional): Buy price per unit (default is 0)
        raw_materials (list): List of (raw material, quantity) tuples: the raw materials needed to make one unit
        batch_size (int): The most units made in one production batch

    Attributes:
        _info_keys (list): List of keys to include in the info dictionary
        _stats_keys (list): List of keys to include in the statistics dictionary
        ID (str): ID of the product
        name (str): Name of the product
        manufacturing_cost (float): Manufacturing cost per unit
        manufacturing_time (float): Time units to make one production batch
        sell_price (float): Sell price per unit
        buy_price (float): Buy price per unit (default is 0)
        raw_materials (list): List of (raw material, quantity) tuples needed to make one unit
        batch_size (int): The most units made in one production batch

    Returns:
        None

    Raises:
        ValueError: If validations fail for positive values, non-negative values, or empty raw materials list.
    """
    validate_positive("Manufacturing cost", manufacturing_cost)
    validate_non_negative("Manufacturing time", manufacturing_time)
    validate_positive("Sell price", sell_price)
    validate_non_negative("Buy price", buy_price)
    validate_positive("Units per cycle", batch_size)
    if raw_materials is None or len(raw_materials) == 0:
        global_logger.error("Raw materials cannot be empty.")
        raise ValueError("Raw materials cannot be empty.")
    for raw_mat in raw_materials:
        if not isinstance(raw_mat[0], RawMaterial):
            raise ValueError("Invalid raw material.")
        if raw_mat[1] <= 0:
            raise ValueError("Invalid quantity for raw material.")

    self.ID = ID # ID of the product (alphanumeric)
    self.name = name # name of the product
    self.manufacturing_cost = manufacturing_cost # manufacturing cost of the product (per unit)
    self.manufacturing_time = manufacturing_time # time units to make one production batch
    self.sell_price = sell_price # sell price per unit
    self.buy_price = buy_price # buy price per unit (default: 0)
    self.raw_materials = raw_materials # (raw material, quantity) tuples needed to make one unit
    self.batch_size = batch_size # the most units made in one production batch

InventoryReplenishment

InventoryReplenishment(env: Environment, node: object, params: dict)

Bases: InfoMixin, NamedEntity

The InventoryReplenishment class is the base for all replenishment policies in SupplyNetPy: the rules a site follows to decide when to place a replenishment order and how many units to order.

This class is not used directly. Each policy is a subclass, such as the (s, S) policy (SSReplenishment), the (R, Q) policy (RQReplenishment) and the periodic policy (PeriodicReplenishment).

A subclass overrides the run method with its rule. run runs as a SimPy process for the whole simulation. A policy usually checks the node's inventory position each time the node's inventory drops (the inventory_drop event), or at fixed intervals.

How a custom policy should talk to its node. A policy should use these four helper methods instead of reaching into the node's internal attributes:

  • self.node.position() — the inventory position: inventory level plus units in transit, minus units the node still owes (to other sites and to waiting customers).
  • self.node.place_order(quantity) — picks a supplier using the node's supplier-selection policy and places the replenishment order. This replaces calling selection_policy.select(...) and env.process(process_order(...)) yourself.
  • self.node.wait_for_drop() — used as yield from self.node.wait_for_drop() to wait until the inventory drops; on wake-up it safely replaces the inventory_drop event with a fresh one.
  • Link.available_quantity() — lets a supplier-selection policy check a supplier's inventory level without reading link.source.inventory.level directly.

Sticking to these helpers keeps a policy from depending on how a particular node is built, so it works with any future Node type that offers the same methods.

Parameters:
  • env (Environment) –

    Simulation environment.

  • node (object) –

    Node to which this policy applies.

  • params (dict) –

    Parameters for the replenishment policy.

Parameters a policy does not use. Each built-in policy lists the keys it reads from params in _param_keys. Any other key is ignored by the policy, so a warning names it and asks whether another policy was meant (for example, 'T' is read only by the periodic policy). A custom policy can set its own _param_keys to get the same check; if it does not, nothing is checked.

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • _param_keys (tuple) –

    Keys this policy reads from params, or None if it does not say.

  • env (Environment) –

    Simulation environment.

  • node (object) –

    Node to which this policy applies.

  • params (dict) –

    Parameters for the replenishment policy.

Methods:

Name Description
run

Placeholder method to be overridden by subclasses.

Initialize the replenishment policy object.

Parameters:
  • env (Environment) –

    simulation environment

  • node (object) –

    node to which this policy applies

  • params (dict) –

    parameters for the replenishment policy

Attributes:
  • _info_keys (list) –

    list of keys to include in the info dictionary

  • env (Environment) –

    simulation environment

  • node (object) –

    node to which this policy applies

  • params (dict) –

    parameters for the replenishment policy

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
def __init__(self,
             env: simpy.Environment,
             node: object,
             params: dict) -> None:
    """
    Initialize the replenishment policy object.

    Parameters:
        env (simpy.Environment): simulation environment
        node (object): node to which this policy applies
        params (dict): parameters for the replenishment policy

    Attributes:
        _info_keys (list): list of keys to include in the info dictionary
        env (simpy.Environment): simulation environment
        node (object): node to which this policy applies
        params (dict): parameters for the replenishment policy

    Returns:
        None
    """
    if not isinstance(env, simpy.Environment):
        raise ValueError("Invalid environment. Provide a valid SimPy environment.")
    self.env = env  # simulation environment
    self.node = node  # node to which this policy applies
    self.params = params  # parameters for the replenishment policy
    self._warn_unused_params()

run

run()

The policy's rule. Each subclass overrides this method.

Source code in src/SupplyNetPy/Components/core.py
1425
1426
1427
1428
1429
def run(self):
    """
    The policy's rule. Each subclass overrides this method.
    """
    pass

SSReplenishment

SSReplenishment(env, node, params)

Bases: InventoryReplenishment

Implements the (s, S) replenishment policy, with an optional safety stock.

When the inventory position falls to the reorder point s or below, the node places a replenishment order big enough to bring its inventory position back up to the order-up-to level S. If a safety stock is given, it is added to both s and S. The inventory position is checked each time the node's inventory drops, or every period time units if a period is given, starting after an optional first review delay. The supplier is chosen by the node's supplier-selection policy.

Parameters:
  • env (Environment) –

    Simulation environment.

  • node (object) –

    Node to which this policy applies.

  • params (dict) –

    Replenishment policy parameters (s, S) and optional parameters (safety_stock, first_review_delay, period).

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • env (Environment) –

    Simulation environment.

  • node (object) –

    Node to which this policy applies.

  • params (dict) –

    Replenishment policy parameters.

  • name (str) –

    Replenishment policy name.

  • first_review_delay (float) –

    Time units to wait before the first check of the inventory position.

  • period (float) –

    Time units between checks. 0 (the default) means checking each time the inventory drops.

Methods:

Name Description
run

Checks the inventory position and places replenishment orders by the (s, S) rule.

Initialize the replenishment policy object.

Parameters:
  • env (Environment) –

    simulation environment

  • node (object) –

    node to which this policy applies

  • params (dict) –

    parameters for the replenishment policy (s, S)

Attributes:
  • _info_keys (list) –

    list of keys to include in the info dictionary

  • env (Environment) –

    simulation environment

  • node (object) –

    node to which this policy applies

  • params (dict) –

    parameters for the replenishment policy (s, S)

  • name (str) –

    replenishment policy name

  • first_review_delay (float) –

    time units to wait before the first check of the inventory position

  • period (float) –

    time units between checks; 0 means checking each time the inventory drops

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
def __init__(self, env, node, params):
    """ 
    Initialize the replenishment policy object.

    Parameters:
        env (simpy.Environment): simulation environment
        node (object): node to which this policy applies
        params (dict): parameters for the replenishment policy (s, S)

    Attributes:
        _info_keys (list): list of keys to include in the info dictionary
        env (simpy.Environment): simulation environment
        node (object): node to which this policy applies
        params (dict): parameters for the replenishment policy (s, S)
        name (str): replenishment policy name
        first_review_delay (float): time units to wait before the first check of the inventory position
        period (float): time units between checks; 0 means checking each time the inventory drops

    Returns:
        None
    """
    validate_non_negative("Reorder point (s)", params['s']) # s must not be negative
    validate_positive("Order-up-to level (S)", params['S']) # S must be positive
    if 's' not in params or 'S' not in params:
        raise ValueError("Parameters 's' and 'S' must be provided for the (s, S) replenishment policy.")
    if params['s'] > params['S']:
        raise ValueError("Reorder point (s) must be less than or equal to order-up-to level (S).")
    super().__init__(env, node, params)
    self.name = "min-max replenishment (s, S)"
    self.first_review_delay = params.get('first_review_delay', 0)
    self.period = params.get('period',0)

run

run()

Runs the (s, S) policy for the whole simulation: checks the inventory position and places replenishment orders.

Attributes:
  • s (float) –

    reorder point

  • S (float) –

    order-up-to level

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
def run(self):
    """
    Runs the (s, S) policy for the whole simulation: checks the inventory position and places
    replenishment orders.

    Parameters:
        None

    Attributes: 
        s (float): reorder point
        S (float): order-up-to level

    Returns:
        None    
    """
    s, S = self.params['s'], self.params['S']  # get the reorder point and order-up-to level
    # (__init__ has already checked that s is not above S.)

    if 'safety_stock' in self.params: # check if safety_stock is specified
        validate_positive("Safety stock", self.params['safety_stock'])
        self.name = "min-max with safety replenishment (s, S, safety_stock)"
        s += self.params['safety_stock']
        S += self.params['safety_stock']

    if self.first_review_delay > 0: # wait before the first check, if a first review delay is given
        yield self.env.timeout(self.first_review_delay)

    while True: # check for the whole simulation
        self.node.logger.info(f"{self.env.now:.4f}:{self.node.ID}: Inventory levels:{self.node.inventory.level}, on hand:{self.node.inventory.on_hand}")
        position = self.node.position()
        if position <= s:
            self.node.place_order(S - position)

        if self.period == 0: # no period: check again when the inventory drops
            yield from self.node.wait_for_drop()
        elif self.period: # a period is given: check again after it
            yield self.env.timeout(self.period)

RQReplenishment

RQReplenishment(env, node, params)

Bases: InventoryReplenishment

Implements the (R, Q) replenishment policy.

When the inventory position falls to the reorder point R or below, the node places a replenishment order of a fixed Q units.

The inventory position is checked each time the node's inventory drops if period is 0 (the default), or every period time units if a positive period is given. An optional first review delay sets how long to wait before the first check.

The supplier is chosen by the node's supplier-selection policy. If the supplier cannot fill the order at once, a shortage is recorded at the supplier.

Parameters:
  • env (Environment) –

    Simulation environment.

  • node (object) –

    Node to which this policy applies.

  • params (dict) –

    Parameters for the replenishment policy: R, Q, and the optional first_review_delay and period.

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • env (Environment) –

    Simulation environment.

  • node (object) –

    Node to which this policy applies.

  • params (dict) –

    Replenishment policy parameters (R, Q, optional delays and period).

  • name (str) –

    Replenishment policy name.

  • first_review_delay (float) –

    Time units to wait before the first check of the inventory position.

  • period (float) –

    Time units between checks. 0 (the default) means checking each time the inventory drops.

Methods:

Name Description
run

Checks the inventory position and places a replenishment order when it reaches the reorder point.

Initialize the (R, Q) replenishment policy object.

Parameters:
  • env (Environment) –

    Simulation environment.

  • node (object) –

    Node to which this policy applies.

  • params (dict) –

    Replenishment policy parameters: R, Q, and the optional first_review_delay and period.

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • env (Environment) –

    Simulation environment.

  • node (object) –

    Node to which this policy applies.

  • params (dict) –

    Replenishment policy parameters (R, Q, optional delays and period).

  • name (str) –

    Replenishment policy name.

  • first_review_delay (float) –

    Time units to wait before the first check of the inventory position.

  • period (float) –

    Time units between checks. 0 means checking each time the inventory drops.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
def __init__(self, env, node, params):
    """ 
    Initialize the (R, Q) replenishment policy object.

    Parameters:
        env (simpy.Environment): Simulation environment.
        node (object): Node to which this policy applies.
        params (dict): Replenishment policy parameters: R, Q, and the optional first_review_delay and period.

    Attributes:
        _info_keys (list): List of keys to include in the info dictionary.
        env (simpy.Environment): Simulation environment.
        node (object): Node to which this policy applies.
        params (dict): Replenishment policy parameters (R, Q, optional delays and period).
        name (str): Replenishment policy name.
        first_review_delay (float): Time units to wait before the first check of the inventory position.
        period (float): Time units between checks. 0 means checking each time the inventory drops.

    Returns:
        None
    """
    validate_non_negative("Reorder point (R)", params['R']) # R must not be negative
    validate_positive("Order quantity (Q)", params['Q'])  # Q must be positive
    super().__init__(env, node, params)
    self.name = "RQ replenishment (R, Q)"
    self.first_review_delay = params.get('first_review_delay', 0)
    self.period = params.get('period', 0)

run

run()

Runs the (R, Q) policy for the whole simulation: places a replenishment order of Q units whenever the inventory position is at the reorder point R or below.

If a period is given, the inventory position is checked every period time units. Otherwise it is checked each time the node's inventory drops.

Attributes:
  • R (float) –

    Reorder point.

  • Q (float) –

    Units in each replenishment order.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
def run(self):
    """
    Runs the (R, Q) policy for the whole simulation: places a replenishment order of `Q` units whenever
    the inventory position is at the reorder point `R` or below.

    If a period is given, the inventory position is checked every `period` time units. Otherwise it is
    checked each time the node's inventory drops.

    Parameters:
        None

    Attributes:
        R (float): Reorder point.
        Q (float): Units in each replenishment order.

    Returns:
        None
    """
    R, Q = self.params['R'], self.params['Q']

    if self.first_review_delay > 0:
        yield self.env.timeout(self.first_review_delay)

    while True:
        self.node.logger.info(f"{self.env.now:.4f}:{self.node.ID}: Inventory levels: {self.node.inventory.level}, on hand: {self.node.inventory.on_hand}")
        if self.node.position() <= R:
            self.node.place_order(Q)

        if self.period == 0:
            yield from self.node.wait_for_drop()
        else:
            yield self.env.timeout(self.period)

PeriodicReplenishment

PeriodicReplenishment(env, node, params)

Bases: InventoryReplenishment

Implements the periodic replenishment policy: every T time units, the node places a replenishment order of Q units, whatever its inventory position. If a safety stock is given and the inventory level is below it, the order also includes the units needed to bring the inventory level back up to the safety stock. An optional first review delay sets how long to wait before the first order.

The supplier is chosen by the node's supplier-selection policy. If the supplier cannot fill the order at once, a shortage is recorded at the supplier.

Parameters:
  • env (Environment) –

    Simulation environment.

  • node (object) –

    Node to which this policy applies.

  • params (dict) –

    Dictionary containing replenishment parameters: T, Q, and optional parameters (safety_stock, first_review_delay).

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • env (Environment) –

    Simulation environment.

  • node (object) –

    Node to which this policy applies.

  • params (dict) –

    Parameters for the replenishment policy.

  • name (str) –

    Replenishment policy name.

  • first_review_delay (float) –

    Time units to wait before the first replenishment order.

Methods:

Name Description
run

Places a replenishment order of Q units every T time units.

Initialize the replenishment policy object.

Parameters:
  • env (Environment) –

    simulation environment

  • node (object) –

    node to which this policy applies

  • params (dict) –

    parameters for the replenishment policy (T, Q), and optional parameters (safety_stock, first_review_delay).

Attributes:
  • _info_keys (list) –

    list of keys to include in the info dictionary

  • env (Environment) –

    simulation environment

  • node (object) –

    node to which this policy applies

  • params (dict) –

    parameters for the replenishment policy (T, Q)

  • name (str) –

    replenishment policy name

  • first_review_delay (float) –

    time units to wait before the first replenishment order

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
def __init__(self, env, node, params):
    """ 
    Initialize the replenishment policy object.

    Parameters:
        env (simpy.Environment): simulation environment
        node (object): node to which this policy applies
        params (dict): parameters for the replenishment policy (T, Q), and optional parameters (safety_stock, first_review_delay).

    Attributes:
        _info_keys (list): list of keys to include in the info dictionary
        env (simpy.Environment): simulation environment
        node (object): node to which this policy applies
        params (dict): parameters for the replenishment policy (T, Q)
        name (str): replenishment policy name
        first_review_delay (float): time units to wait before the first replenishment order

    Returns:
        None
    """
    validate_positive("Replenishment period (T)", params['T'])  # T must be positive
    validate_positive("Replenishment quantity (Q)", params['Q'])  # Q must be positive
    super().__init__(env, node, params)
    self.name = "Periodic replenishment (T, Q)"
    self.first_review_delay = params.get('first_review_delay', 0)

run

run()

Runs the periodic policy for the whole simulation: places a replenishment order every T time units.

Attributes:
  • T (float) –

    time units between replenishment orders

  • Q (float) –

    units in each replenishment order

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
def run(self):
    """
    Runs the periodic policy for the whole simulation: places a replenishment order every `T` time units.

    Parameters:
        None

    Attributes:
        T (float): time units between replenishment orders
        Q (float): units in each replenishment order

    Returns:
        None
    """
    T, Q = self.params['T'], self.params['Q']  # the period and the order size
    ss = 0
    if 'safety_stock' in self.params: # check if safety_stock is specified
        validate_non_negative("Safety stock", self.params['safety_stock'])
        self.name = "Periodic with safety replenishment (T, Q, safety_stock)"
        ss = self.params['safety_stock']

    if self.first_review_delay > 0:
        yield self.env.timeout(self.first_review_delay)

    while True:
        self.node.logger.info(f"{self.env.now:.4f}:{self.node.ID}: Inventory levels:{self.node.inventory.level}, on hand:{self.node.inventory.on_hand}")
        reorder_quantity = Q
        if self.node.inventory.level < ss:
            reorder_quantity += ss - self.node.inventory.level
        self.node.place_order(reorder_quantity)
        yield self.env.timeout(T) # wait for the next period

SupplierSelectionPolicy

SupplierSelectionPolicy(node, mode='fixed', params=None)

Bases: InfoMixin, NamedEntity

The base for all supplier-selection policies: the rules a site follows to choose which of its suppliers receives a replenishment order.

A site reaches each of its suppliers over a link, so the policy works with links: the site's suppliers are the links in node.suppliers, and select returns the link to the chosen supplier (the supplier itself is link.source).

There are two modes: (1) "dynamic": the supplier is chosen afresh for every replenishment order. (2) "fixed": the first supplier chosen is remembered and used for every later order (another one is used only while the remembered supplier or the link to it is disrupted).

Every policy skips a supplier whose site or link is disrupted (see Link.can_take_order). Node.place_order calls select only when at least one supplier can take the order.

Each policy is a subclass that overrides the select method with its own rule.

A site gives its policy the mode and the settings (supplier_selection_param) only when the user gives them, so each policy keeps its own default mode. A policy lists the settings it reads in _param_keys; any other setting is ignored, so a warning names it.

Parameters:
  • node (object) –

    Node for which the supplier selection policy is applied.

  • mode (str, default: 'fixed' ) –

    Supplier selection mode. Must be "dynamic" or "fixed".

  • params (dict, default: None ) –

    Settings for the policy (supplier_selection_param of the site), or None.

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • _param_keys (tuple) –

    Settings this policy reads from params, or None if it does not say.

  • node (object) –

    Node for which the supplier selection policy is applied.

  • mode (str) –

    Supplier selection mode ("dynamic" or "fixed").

  • params (dict) –

    The settings given to the policy, or None.

  • fixed_supplier (Link) –

    In "fixed" mode, the link to the remembered supplier (None until the first choice).

Methods:

Name Description
select

The policy's rule for choosing a supplier; each subclass overrides it.

validate_suppliers

Checks that the node has at least one supplier.

check_links

Checks the policy's settings against the site's links; does nothing here.

Initialize the supplier selection policy object.

Parameters:
  • node (object) –

    Node for which the supplier selection policy is applied.

  • mode (str, default: 'fixed' ) –

    Supplier selection mode ("dynamic" or "fixed").

  • params (dict, default: None ) –

    Settings for the policy (supplier_selection_param of the site), or None.

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • node (object) –

    Node for which the supplier selection policy is applied.

  • mode (str) –

    Supplier selection mode ("dynamic" or "fixed").

  • params (dict) –

    The settings given to the policy, or None.

  • fixed_supplier (Link) –

    In "fixed" mode, the link to the remembered supplier (None until the first choice).

Returns:
  • –

    None

Raises:
  • ValueError –

    If the mode is not "dynamic" or "fixed".

  • TypeError –

    If the node is not an instance of Node class.

Source code in src/SupplyNetPy/Components/core.py
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
def __init__(self, node, mode="fixed", params=None):
    """
    Initialize the supplier selection policy object.

    Parameters:
        node (object): Node for which the supplier selection policy is applied.
        mode (str): Supplier selection mode ("dynamic" or "fixed").
        params (dict): Settings for the policy (``supplier_selection_param`` of the site), or None.

    Attributes:
        _info_keys (list): List of keys to include in the info dictionary.
        node (object): Node for which the supplier selection policy is applied.
        mode (str): Supplier selection mode ("dynamic" or "fixed").
        params (dict): The settings given to the policy, or None.
        fixed_supplier (Link): In "fixed" mode, the link to the remembered supplier (None until the first choice).

    Returns:
        None

    Raises:
        ValueError: If the mode is not "dynamic" or "fixed".
        TypeError: If the node is not an instance of Node class.
    """
    if mode not in ["dynamic", "fixed"]:
        global_logger.error(f"Invalid mode: {mode}. Mode must be either 'dynamic' or 'fixed'.")
        raise ValueError("Mode must be either 'dynamic' or 'fixed'.")
    if not isinstance(node, Node):
        global_logger.error("Node must be an instance of Node class.")
        raise TypeError("Node must be an instance of Node class.")
    self.node = node
    self.mode = mode
    self.params = params
    self.fixed_supplier = None
    self._warn_unused_params()
check_links()

Check the policy's settings against the links to the site's suppliers. create_sc_net calls it once every link is created. The built in policies need no such check, so here it does nothing; a policy whose settings name the suppliers (SelectProbabilistic) overrides it.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
def check_links(self):
    """
    Check the policy's settings against the links to the site's suppliers.
    ``create_sc_net`` calls it once every link is created. The built in
    policies need no such check, so here it does nothing; a policy whose
    settings name the suppliers (``SelectProbabilistic``) overrides it.

    Returns:
        None
    """
    return None

select

select(order_quantity)

The policy's rule for choosing a supplier. Each subclass overrides this method.

Parameters:
  • order_quantity (float) –

    Units in the replenishment order.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
def select(self, order_quantity):
    """
    The policy's rule for choosing a supplier. Each subclass overrides this method.

    Parameters:
        order_quantity (float): Units in the replenishment order.

    Returns:
        None
    """
    raise NotImplementedError("Subclasses must implement this method.")

validate_suppliers

validate_suppliers()

Checks that the node has at least one supplier (a link into it).

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
def validate_suppliers(self):
    """
    Checks that the node has at least one supplier (a link into it).

    Returns:
        None
    """
    if not self.node.suppliers:
        global_logger.error(f"{self.node.ID} must have at least one supplier.")
        raise ValueError(f"{self.node.ID} must have at least one supplier.")

SelectFirst

SelectFirst(node, mode='fixed', params=None)

Bases: SupplierSelectionPolicy

A supplier-selection policy that chooses the first supplier in the node's list of suppliers (the order in which the links were created), skipping suppliers whose site or link is disrupted.

In dynamic mode, the choice is made afresh for each replenishment order. In fixed mode (the default for this policy), the first choice is remembered and used for later orders.

Parameters:
  • node (object) –

    Node to which this supplier selection policy applies.

  • mode (str, default: 'fixed' ) –

    Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").

  • params (dict, default: None ) –

    Settings for the policy; this policy takes none.

Attributes:
  • node (object) –

    Node to which this policy applies.

  • mode (str) –

    Supplier selection mode.

  • fixed_supplier (Link) –

    In "fixed" mode, the link to the remembered supplier.

  • name (str) –

    Name of the selection policy.

  • _info_keys (list) –

    List of keys to include in the info dictionary.

Methods:

Name Description
select

Chooses the first supplier that can take the order.

Initialize the supplier selection policy object.

Parameters:
  • node (object) –

    Node to which this supplier selection policy applies.

  • mode (str, default: 'fixed' ) –

    Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").

  • params (dict, default: None ) –

    Settings for the policy; this policy takes none.

Attributes:
  • name (str) –

    Name of the selection policy.

  • _info_keys (list) –

    List of keys to include in the info dictionary.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
def __init__(self, node, mode="fixed", params=None):
    """
    Initialize the supplier selection policy object.

    Parameters:
        node (object): Node to which this supplier selection policy applies.
        mode (str): Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").
        params (dict, optional): Settings for the policy; this policy takes none.

    Attributes:
        name (str): Name of the selection policy.
        _info_keys (list): List of keys to include in the info dictionary.

    Returns:
        None
    """
    super().__init__(node, mode, params)
    self.name = "First fixed supplier"

select

select(order_quantity)

Chooses the first supplier that can take the order right now.

Suppliers whose site or link is disrupted are skipped.

In dynamic mode, the choice is made afresh for each order. In fixed mode, the first choice is remembered and used for later orders; while it or its link is disrupted, the policy uses another supplier, without forgetting the remembered one.

Parameters:
  • order_quantity (float) –

    Units in the replenishment order.

Returns:
  • Link –

    The link to the chosen supplier.

Source code in src/SupplyNetPy/Components/core.py
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
def select(self, order_quantity):
    """
    Chooses the first supplier that can take the order right now.

    Suppliers whose site or link is disrupted are skipped.

    In dynamic mode, the choice is made afresh for each order. In fixed
    mode, the first choice is remembered and used for later orders; while
    it or its link is disrupted, the policy uses another supplier, without
    forgetting the remembered one.

    Parameters:
        order_quantity (float): Units in the replenishment order.

    Returns:
        Link: The link to the chosen supplier.
    """
    self.validate_suppliers()
    candidates = self._active_suppliers()
    selected = candidates[0]
    return self._apply_mode(selected)

SelectAvailable

SelectAvailable(node, mode='fixed', params=None)

Bases: SupplierSelectionPolicy

A supplier-selection policy that chooses the first supplier whose inventory level can fill the whole order.

If no supplier can fill the whole order, it chooses the first supplier in the list. Suppliers whose site or link is disrupted are skipped. In dynamic mode the choice is made afresh for each replenishment order; in fixed mode the first choice is remembered and used for later orders.

Parameters:
  • node (object) –

    Node to which this supplier selection policy applies.

  • mode (str, default: 'fixed' ) –

    Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").

  • params (dict, default: None ) –

    Settings for the policy; this policy takes none.

Attributes:
  • node (object) –

    Node to which this policy applies.

  • mode (str) –

    Supplier selection mode.

  • fixed_supplier (Link) –

    In "fixed" mode, the link to the remembered supplier.

  • name (str) –

    Name of the selection policy.

  • _info_keys (list) –

    List of keys to include in the info dictionary.

Methods:

Name Description
select

Chooses the first supplier whose inventory level can fill the whole order.

Initialize the supplier selection policy object.

Parameters:
  • node (object) –

    Node to which this supplier selection policy applies.

  • mode (str, default: 'fixed' ) –

    Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").

  • params (dict, default: None ) –

    Settings for the policy; this policy takes none.

Attributes:
  • name (str) –

    Name of the selection policy.

  • _info_keys (list) –

    List of keys to include in the info dictionary.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
def __init__(self, node, mode="fixed", params=None):
    """
    Initialize the supplier selection policy object.

    Parameters:
        node (object): Node to which this supplier selection policy applies.
        mode (str): Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").
        params (dict, optional): Settings for the policy; this policy takes none.

    Attributes:
        name (str): Name of the selection policy.
        _info_keys (list): List of keys to include in the info dictionary.

    Returns:
        None
    """
    super().__init__(node, mode, params)
    self.name = "First available supplier"

select

select(order_quantity)

Chooses the first supplier whose inventory level can fill the whole order.

Suppliers whose site or link is disrupted are skipped first. Among those left, it chooses the first one whose inventory level is at least order_quantity; if none is, it chooses the first of them (the order then waits at that supplier until enough units arrive, and the supplier records a shortage and a backorder).

In fixed mode, the first choice is remembered and used for later orders. While it or its link is disrupted, the policy uses another supplier, without forgetting the remembered one.

Parameters:
  • order_quantity (float) –

    Units in the replenishment order.

Returns:
  • Link –

    The link to the chosen supplier.

Source code in src/SupplyNetPy/Components/core.py
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
def select(self, order_quantity):
    """
    Chooses the first supplier whose inventory level can fill the whole order.

    Suppliers whose site or link is disrupted are skipped first. Among
    those left, it chooses the first one whose inventory level is at least
    ``order_quantity``; if none is, it chooses the first of them (the order
    then waits at that supplier until enough units arrive, and the supplier
    records a shortage and a backorder).

    In fixed mode, the first choice is remembered and used for later
    orders. While it or its link is disrupted, the policy uses another
    supplier, without forgetting the remembered one.

    Parameters:
        order_quantity (float): Units in the replenishment order.

    Returns:
        Link: The link to the chosen supplier.
    """
    self.validate_suppliers()
    candidates = self._active_suppliers()
    available = [s for s in candidates if s.available_quantity() >= order_quantity]
    selected = available[0] if available else candidates[0]
    return self._apply_mode(selected)

SelectCheapest

SelectCheapest(node, mode='fixed', params=None)

Bases: SupplierSelectionPolicy

A supplier-selection policy that chooses the supplier with the lowest transportation cost (the cost of the link to it).

Suppliers whose site or link is disrupted are skipped. In dynamic mode the choice is made afresh for each replenishment order; in fixed mode the first choice is remembered and used for later orders.

Parameters:
  • node (object) –

    Node to which this supplier selection policy applies.

  • mode (str, default: 'fixed' ) –

    Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").

  • params (dict, default: None ) –

    Settings for the policy; this policy takes none.

Attributes:
  • node (object) –

    Node to which this policy applies.

  • mode (str) –

    Supplier selection mode.

  • fixed_supplier (Link) –

    In "fixed" mode, the link to the remembered supplier.

  • name (str) –

    Name of the selection policy.

  • _info_keys (list) –

    List of keys to include in the info dictionary.

Methods:

Name Description
select

Chooses the supplier with the lowest transportation cost.

Initialize the supplier selection policy object.

Parameters:
  • node (object) –

    Node to which this supplier selection policy applies.

  • mode (str, default: 'fixed' ) –

    Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").

  • params (dict, default: None ) –

    Settings for the policy; this policy takes none.

Attributes:
  • name (str) –

    Name of the selection policy.

  • _info_keys (list) –

    List of keys to include in the info dictionary.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
def __init__(self, node, mode="fixed", params=None):
    """
    Initialize the supplier selection policy object.

    Parameters:
        node (object): Node to which this supplier selection policy applies.
        mode (str): Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").
        params (dict, optional): Settings for the policy; this policy takes none.

    Attributes:
        name (str): Name of the selection policy.
        _info_keys (list): List of keys to include in the info dictionary.

    Returns:
        None
    """
    super().__init__(node, mode, params)
    self.name = "Cheapest supplier (Transportation cost)"

select

select(order_quantity)

Chooses the supplier with the lowest transportation cost.

Suppliers whose site or link is disrupted are skipped first.

In fixed mode, the first choice is remembered and used for later orders. While it or its link is disrupted, the policy uses another supplier, without forgetting the remembered one.

Parameters:
  • order_quantity (float) –

    Units in the replenishment order.

Returns:
  • Link –

    The link to the chosen supplier.

Source code in src/SupplyNetPy/Components/core.py
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
def select(self, order_quantity):
    """
    Chooses the supplier with the lowest transportation cost.

    Suppliers whose site or link is disrupted are skipped first.

    In fixed mode, the first choice is remembered and used for later
    orders. While it or its link is disrupted, the policy uses another
    supplier, without forgetting the remembered one.

    Parameters:
        order_quantity (float): Units in the replenishment order.

    Returns:
        Link: The link to the chosen supplier.
    """
    self.validate_suppliers()
    candidates = self._active_suppliers()
    selected = min(candidates, key=lambda s: s.cost)
    return self._apply_mode(selected)

SelectFastest

SelectFastest(node, mode='fixed', params=None)

Bases: SupplierSelectionPolicy

A supplier-selection policy that chooses the supplier with the shortest lead time.

The lead time of each link is drawn once when the choice is made, so if lead times are random, the choice can differ from one order to the next. Suppliers whose site or link is disrupted are skipped. In dynamic mode the choice is made afresh for each replenishment order; in fixed mode the first choice is remembered and used for later orders.

Parameters:
  • node (object) –

    Node to which this supplier selection policy applies.

  • mode (str, default: 'fixed' ) –

    Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").

  • params (dict, default: None ) –

    Settings for the policy; this policy takes none.

Attributes:
  • node (object) –

    Node to which this policy applies.

  • mode (str) –

    Supplier selection mode ("dynamic" or "fixed").

  • fixed_supplier (Link) –

    In "fixed" mode, the link to the remembered supplier.

  • name (str) –

    Name of the selection policy.

  • _info_keys (list) –

    List of keys to include in the info dictionary.

Methods:

Name Description
select

Chooses the supplier with the shortest lead time.

Initialize the supplier selection policy object.

Parameters:
  • node (object) –

    Node to which this supplier selection policy applies.

  • mode (str, default: 'fixed' ) –

    Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").

  • params (dict, default: None ) –

    Settings for the policy; this policy takes none.

Attributes:
  • name (str) –

    Name of the selection policy.

  • _info_keys (list) –

    List of keys to include in the info dictionary.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
def __init__(self, node, mode="fixed", params=None):
    """
    Initialize the supplier selection policy object.

    Parameters:
        node (object): Node to which this supplier selection policy applies.
        mode (str, optional): Supplier selection mode, either "dynamic" or "fixed" (default: "fixed").
        params (dict, optional): Settings for the policy; this policy takes none.

    Attributes:
        name (str): Name of the selection policy.
        _info_keys (list): List of keys to include in the info dictionary.

    Returns:
        None
    """
    super().__init__(node, mode, params)
    self.name = "Fastest supplier (Lead time)"

select

select(order_quantity)

Chooses the supplier with the shortest lead time.

Suppliers whose site or link is disrupted are skipped first.

In fixed mode, the first choice is remembered and used for later orders. While it or its link is disrupted, the policy uses another supplier, without forgetting the remembered one.

Parameters:
  • order_quantity (float) –

    Units in the replenishment order.

Returns:
  • Link –

    The link to the chosen supplier.

Source code in src/SupplyNetPy/Components/core.py
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
def select(self, order_quantity):
    """
    Chooses the supplier with the shortest lead time.

    Suppliers whose site or link is disrupted are skipped first.

    In fixed mode, the first choice is remembered and used for later
    orders. While it or its link is disrupted, the policy uses another
    supplier, without forgetting the remembered one.

    Parameters:
        order_quantity (float): Units in the replenishment order.

    Returns:
        Link: The link to the chosen supplier.
    """
    self.validate_suppliers()
    candidates = self._active_suppliers()
    selected = min(candidates, key=lambda s: s.lead_time())
    return self._apply_mode(selected)

SelectProbabilistic

SelectProbabilistic(node, mode='dynamic', params=None)

Bases: SupplierSelectionPolicy

Chooses the supplier of each replenishment order at random, with a probability for each supplier.

For example, a retailer that sends half of its orders to distributor D1, 30% to D2 and 20% to D3 is given supplier_selection_param={"probabilities": {"D1": 0.5, "D2": 0.3, "D3": 0.2}}. The probabilities are keyed by supplier ID (link.source.ID), so they can be given before the links exist, and they must sum to 1. A supplier with probability 0 is never chosen.

For every order the policy draws exactly one random number u between 0 and 1 and puts the probabilities side by side, in the order the links to the suppliers were created: with 0.5, 0.3 and 0.2, u below 0.5 chooses the first supplier, u from 0.5 to below 0.8 the second, and the rest the third. One number per order means that two runs with the same seed see the same numbers, so changing a probability a little changes only the few orders whose number falls near a boundary.

The probabilities are the shares of the orders while every supplier can take orders. When a supplier or the link to it is disrupted, on_disruption decides what happens:

  • "rescale" (the default): the order goes to one of the other suppliers, which keep their ratios. With 0.5, 0.3 and 0.2 and the first supplier disrupted, the other two get 0.6 and 0.4.
  • "block": the draw uses the given probabilities as they are. If it chooses a disrupted supplier, the order is not placed and is counted as blocked; the site orders again when a supplier or a link recovers, or when its inventory drops.

So with disruptions, the shares of the orders at the end of a run differ from the probabilities. The link statistics (link.stats) record the real shares. The stored probabilities never change.

The mode is always "dynamic" (a new choice for every order); "fixed" would keep the first random choice for ever, so it is rejected.

Parameters:
  • node (object) –

    The site that uses the policy.

  • mode (str, default: 'dynamic' ) –

    "dynamic" (the default and the only mode allowed).

  • params (dict, default: None ) –

    The settings, all optional: probabilities (dict from supplier ID to probability; may also be given later with set_probabilities), on_disruption ("rescale" or "block"), and seed (a number or a string) or rng (a random.Random) for the policy's own random numbers. With neither, a seed is drawn once from the site's random number generator (node.rng), so scm.set_seed still makes runs repeatable.

Attributes:
  • name (str) –

    Name of the selection policy.

  • probabilities (dict) –

    The probability of each supplier, by supplier ID (None until given).

  • on_disruption (str) –

    "rescale" or "block".

  • rng (Random) –

    The policy's own random number generator. Its numbers are used for nothing else.

  • _info_keys (list) –

    List of keys to include in the info dictionary.

Methods:

Name Description
set_probabilities

Sets or replaces the probabilities.

check_links

Checks the probabilities against the links to the site's suppliers.

select

Chooses the supplier of one replenishment order.

Initialize the policy.

Parameters:
  • node (object) –

    The site that uses the policy.

  • mode (str, default: 'dynamic' ) –

    "dynamic" (the default and the only mode allowed).

  • params (dict, default: None ) –

    The settings (see the class description), or None.

Returns:
  • –

    None

Raises:
  • ValueError –

    If the mode is "fixed", a setting has a wrong value, or both seed and rng are given.

Source code in src/SupplyNetPy/Components/core.py
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
def __init__(self, node, mode="dynamic", params=None):
    """
    Initialize the policy.

    Parameters:
        node (object): The site that uses the policy.
        mode (str): "dynamic" (the default and the only mode allowed).
        params (dict): The settings (see the class description), or None.

    Returns:
        None

    Raises:
        ValueError: If the mode is "fixed", a setting has a wrong value, or both ``seed`` and ``rng`` are given.
    """
    if mode == "fixed":
        message = (f"{getattr(node, 'ID', '?')}: SelectProbabilistic makes a new random choice for every order, "
                   f"so it cannot be used in 'fixed' mode. Leave supplier_selection_mode out, or use 'dynamic'.")
        global_logger.error(message)
        raise ValueError(message)
    super().__init__(node, mode, params)
    self.name = "Probability-based supplier"
    params = params if params is not None else {}
    if not isinstance(params, dict):
        global_logger.error(f"{node.ID}: supplier_selection_param must be a dict.")
        raise ValueError("supplier_selection_param must be a dict.")

    self.on_disruption = params.get("on_disruption", "rescale")
    if self.on_disruption not in ("rescale", "block"):
        message = f"{node.ID}: on_disruption must be 'rescale' or 'block', not {self.on_disruption!r}."
        global_logger.error(message)
        raise ValueError(message)

    # The policy's own random numbers, so its draws never shift any other
    # random numbers in the simulation (for example those of disruptions).
    # A seed or rng of None counts as not given, so a script that passes
    # seed=config.get("seed") without a seed still repeats with scm.set_seed.
    seed, rng = params.get("seed"), params.get("rng")
    if seed is not None and rng is not None:
        message = f"{node.ID}: give either seed or rng to SelectProbabilistic, not both."
        global_logger.error(message)
        raise ValueError(message)
    if rng is not None:
        if not isinstance(rng, random.Random):
            message = f"{node.ID}: rng must be a random.Random object."
            global_logger.error(message)
            raise ValueError(message)
        self.rng = rng
    elif seed is not None:
        if isinstance(seed, numbers.Integral):
            seed = int(seed)  # a numpy integer, for example, becomes a Python int
        if not isinstance(seed, (int, float, str, bytes, bytearray)):
            message = f"{node.ID}: seed must be a number or a string, not {seed!r}."
            global_logger.error(message)
            raise ValueError(message)
        self.rng = random.Random(seed)
    else:
        # One number from the site's generator (the library's, unless the
        # site has its own), so scm.set_seed still makes the run repeatable.
        self.rng = random.Random(self.node.rng.getrandbits(64))

    self.probabilities = None
    self._links_checked = False
    if params.get("probabilities") is not None:
        self.set_probabilities(params["probabilities"])

set_probabilities

set_probabilities(probabilities)

Set or replace the probabilities of the suppliers.

The links are checked against them at the next order (not at once, because more links may still be added).

Parameters:
  • probabilities (dict) –

    The probability of each supplier, by supplier ID. None may be negative, at least one must be above 0, and together they must sum to 1.

Returns:
  • –

    None

Raises:
  • ValueError –

    If the probabilities are not a dict of numbers that sum to 1, or one is negative.

Source code in src/SupplyNetPy/Components/core.py
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
def set_probabilities(self, probabilities):
    """
    Set or replace the probabilities of the suppliers.

    The links are checked against them at the next order (not at once,
    because more links may still be added).

    Parameters:
        probabilities (dict): The probability of each supplier, by supplier ID. None may be negative, at
            least one must be above 0, and together they must sum to 1.

    Returns:
        None

    Raises:
        ValueError: If the probabilities are not a dict of numbers that sum to 1, or one is negative.
    """
    def fail(reason):
        message = f"{self.node.ID}: the probabilities of SelectProbabilistic {reason}."
        global_logger.error(message)
        raise ValueError(message)

    if not isinstance(probabilities, dict):
        fail("must be a dict from supplier ID to probability")
    for supplier_id, p in probabilities.items():
        if isinstance(p, bool) or not isinstance(p, numbers.Real) or not math.isfinite(p):
            fail(f"must be numbers ({supplier_id!r} has {p!r})")
        if p < 0:
            fail(f"cannot be negative ({supplier_id!r} has {p})")
    total = sum(probabilities.values())
    if not any(p > 0 for p in probabilities.values()):
        fail("must have at least one above 0")
    if abs(total - 1) > 1e-6:  # a little room for rounding, as in 0.1 + 0.2 + 0.7
        fail(f"must sum to 1, not {total}")
    self.probabilities = dict(probabilities)  # a copy, so later changes to the user's dict have no effect
    self._links_checked = False
check_links()

Check the probabilities against the links to the site's suppliers: the probabilities have been given, every link has a probability, every probability has a link, and no supplier has two links to this site (its ID would not say which link is meant). A link or ID left over is most likely a typo, so it is an error, not a probability of 0. create_sc_net calls this once every link is created, and select calls it before its first choice.

Returns:
  • –

    None

Raises:
  • ValueError –

    If any of these checks fails.

Source code in src/SupplyNetPy/Components/core.py
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
def check_links(self):
    """
    Check the probabilities against the links to the site's suppliers: the probabilities have been given,
    every link has a probability, every probability has a link, and no supplier has two links to this site
    (its ID would not say which link is meant). A link or ID left over is most likely a typo, so it is an
    error, not a probability of 0. ``create_sc_net`` calls this once every link is created, and ``select``
    calls it before its first choice.

    Returns:
        None

    Raises:
        ValueError: If any of these checks fails.
    """
    def fail(reason):
        message = f"{self.node.ID}: SelectProbabilistic {reason}."
        global_logger.error(message)
        raise ValueError(message)

    if self.probabilities is None:
        fail("has no probabilities. Give them in supplier_selection_param or with set_probabilities")
    linked = [link.source.ID for link in self.node.suppliers]
    twice = sorted({str(i) for i in linked if linked.count(i) > 1})
    if twice:
        fail(f"cannot tell apart two links from the same supplier ({', '.join(twice)})")
    missing = [str(i) for i in linked if i not in self.probabilities]
    if missing:
        fail(f"has no probability for the supplier(s) {', '.join(missing)}, linked to this site")
    unlinked = sorted(str(i) for i in self.probabilities if i not in linked)
    if unlinked:
        fail(f"has a probability for the supplier(s) {', '.join(unlinked)}, which have no link to this site")
    self._links_checked = True

select

select(order_quantity)

Chooses the supplier of one replenishment order at random.

With "rescale", the choice is made among the suppliers that can take the order (if none with a probability above 0 can, the chosen supplier is disrupted and the order is blocked). With "block", the choice is made among all suppliers with a probability above 0. A disrupted supplier returned here is blocked by InventoryNode.process_order, which counts the order as blocked.

Parameters:
  • order_quantity (float) –

    Units in the replenishment order.

Returns:
  • Link –

    The link to the chosen supplier.

Source code in src/SupplyNetPy/Components/core.py
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
def select(self, order_quantity):
    """
    Chooses the supplier of one replenishment order at random.

    With ``"rescale"``, the choice is made among the suppliers that can take the order (if none with a
    probability above 0 can, the chosen supplier is disrupted and the order is blocked). With ``"block"``,
    the choice is made among all suppliers with a probability above 0. A disrupted supplier returned here
    is blocked by ``InventoryNode.process_order``, which counts the order as ``blocked``.

    Parameters:
        order_quantity (float): Units in the replenishment order.

    Returns:
        Link: The link to the chosen supplier.
    """
    if not self._links_checked:
        self.check_links()
    u = self.rng.random()  # exactly one number for every order
    candidates = [(link, self.probabilities[link.source.ID]) for link in self.node.suppliers
                  if self.probabilities[link.source.ID] > 0]
    choices = candidates  # the suppliers this order can go to
    if self.on_disruption == "rescale":
        working = [(link, p) for link, p in candidates if link.can_take_order()]
        if working:
            choices = working
    # Scaling u by the total probability of these suppliers shares out the
    # probability of the suppliers left out, keeping their ratios.
    target = u * sum(p for _, p in choices)
    running = 0.0
    for link, p in choices:
        running += p
        if target < running:
            return link
    return choices[-1][0]  # rounding left u just past the end

Node

Node(env: Environment, ID: str, name: str, node_type: str, failure_p: float = 0.0, node_disrupt_time: Callable = None, node_recovery_time: Callable = lambda: 1, logging: bool = True, rng: Random = None, disruption_impact=None, disruption_loss_fraction=1.0, disposal_cost: float = 0, **kwargs)

Bases: NamedEntity, InfoMixin

Represents a node in the supply chain network: a raw-material supplier, a manufacturer, a warehouse, distributor or retailer, or a customer demand. This is the base class they all share. It handles disruption and recovery, and logging.

A node can be disrupted at random (each time unit, a disruption starts with probability failure_p) or at the times given by node_disrupt_time. While disrupted, its status is "inactive"; it recovers after node_recovery_time. The subclasses also keep statistics (stats): orders, shortages, costs, revenue and profit.

Sites can use the built-in replenishment policies ((s, S), (R, Q) and periodic) or a custom one made by subclassing InventoryReplenishment, and the built-in supplier-selection policies (SelectFirst, SelectAvailable, SelectCheapest, SelectFastest) or a custom one made by subclassing SupplierSelectionPolicy.

For the node types, see NodeType.

Parameters:
  • env (Environment) –

    Simulation environment.

  • ID (str) –

    Unique node ID.

  • name (str) –

    Node name.

  • node_type (str) –

    Type of the node.

  • failure_p (float, default: 0.0 ) –

    Disruption probability: the chance, each time unit, that a disruption starts.

  • node_disrupt_time (callable, default: None ) –

    Time units until the next disruption (a number, or a function returning one each time).

  • node_recovery_time (callable, default: lambda: 1 ) –

    Time units a disruption lasts (a number, or a function returning one each time).

  • logging (bool, default: True ) –

    Flag to enable/disable logging.

  • disruption_impact (str | callable | None, default: None ) –

    What a disruption does to the units at the node, at the moment it starts. None (default) leaves them untouched. Built-in names: "destroy_all" destroys all units at the node (and a manufacturer's raw materials); "destroy_fraction" destroys disruption_loss_fraction × inventory level. For any other effect (partial spoilage, contamination, capacity damage), pass your own function f(node) -> None. It runs once per disruption; for an effect that builds up during the disruption, start a SimPy process inside the function.

  • disruption_loss_fraction (float | callable, default: 1.0 ) –

    Fraction between 0 and 1, used with disruption_impact="destroy_fraction". May be a zero-arg function, so each disruption can destroy a different fraction.

  • disposal_cost (float, default: 0 ) –

    The cost of getting rid of one unit destroyed in a disruption, for example the safe disposal of medicines spoiled by a power cut. Default 0, for units that can simply be thrown away. Destroyed units are charged this cost, recorded as stats.destroyed_value and included in node_cost. Their buy price is not charged again: the node paid it when the units arrived (inventory_spend_cost). It applies to raw materials destroyed at a manufacturer too.

  • **kwargs –

    Additional arguments for the logger.

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • env (Environment) –

    simulation environment

  • ID (str) –

    ID of the node (alphanumeric)

  • name (str) –

    name of the node

  • node_type (str) –

    type of the node

  • node_failure_p (float) –

    disruption probability (the chance, each time unit, that a disruption starts)

  • node_status (str) –

    status of the node: "active", or "inactive" while disrupted

  • node_disrupt_time (callable) –

    function giving the time units until the next disruption

  • node_recovery_time (callable) –

    function giving the time units a disruption lasts

  • disruption_impact –

    The disruption_impact setting as given (a name, a function, or None).

  • _disruption_impact_fn (callable | None) –

    The function that disruption() calls when a disruption starts.

  • disposal_cost (float) –

    The cost of getting rid of one unit destroyed in a disruption.

  • logger (GlobalLogger) –

    logger object

  • suppliers (list) –

    The links to this node's suppliers (links whose sink is this node); filled in by Link.__init__ through add_supplier_link.

Methods:

Name Description
disruption

Disrupts the node and recovers it, for the whole simulation.

add_supplier_link

Registers a link from one of this node's suppliers (called by Link.init).

position

The inventory position, used by replenishment policies.

place_order

Chooses a supplier with the supplier-selection policy and places a replenishment order.

wait_for_drop

Waits until the inventory drops (use with yield from).

Initialize the node object.

Parameters:
  • env (Environment) –

    Simulation environment.

  • ID (str) –

    Unique node ID.

  • name (str) –

    Node name.

  • node_type (str) –

    Type of the node.

  • failure_p (float, default: 0.0 ) –

    Disruption probability: the chance, each time unit, that a disruption starts.

  • node_disrupt_time (callable, default: None ) –

    Time units until the next disruption (a number or a function).

  • node_recovery_time (callable, default: lambda: 1 ) –

    Time units a disruption lasts (a number or a function).

  • logging (bool, default: True ) –

    Flag to enable/disable logging.

  • **kwargs –

    Additional arguments for the logger.

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • env (Environment) –

    simulation environment

  • ID (str) –

    ID of the node (alphanumeric)

  • name (str) –

    name of the node

  • node_type (str) –

    type of the node

  • node_failure_p (float) –

    disruption probability

  • node_status (str) –

    status of the node: "active", or "inactive" while disrupted

  • node_disrupt_time (callable) –

    function giving the time units until the next disruption

  • node_recovery_time (callable) –

    function giving the time units a disruption lasts

  • logger (GlobalLogger) –

    logger object

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
def __init__(self, env: simpy.Environment,
             ID: str,
             name: str,
             node_type: str,
             failure_p:float = 0.0,
             node_disrupt_time: Callable = None,
             node_recovery_time: Callable = lambda: 1,
             logging: bool = True,
             rng: random.Random = None,
             disruption_impact=None,
             disruption_loss_fraction=1.0,
             disposal_cost: float = 0,
             **kwargs) -> None:
    """
    Initialize the node object.

    Parameters:
        env (simpy.Environment): Simulation environment.
        ID (str): Unique node ID.
        name (str): Node name.
        node_type (str): Type of the node.
        failure_p (float, optional): Disruption probability: the chance, each time unit, that a disruption starts.
        node_disrupt_time (callable, optional): Time units until the next disruption (a number or a function).
        node_recovery_time (callable, optional): Time units a disruption lasts (a number or a function).
        logging (bool, optional): Flag to enable/disable logging.
        **kwargs: Additional arguments for the logger.

    Attributes:
        _info_keys (list): List of keys to include in the info dictionary.  
        env (simpy.Environment): simulation environment
        ID (str): ID of the node (alphanumeric)
        name (str): name of the node
        node_type (str): type of the node
        node_failure_p (float): disruption probability
        node_status (str): status of the node: "active", or "inactive" while disrupted
        node_disrupt_time (callable): function giving the time units until the next disruption
        node_recovery_time (callable): function giving the time units a disruption lasts
        logger (GlobalLogger): logger object

    Returns:
        None
    """
    if not isinstance(env, simpy.Environment):
        raise ValueError("Invalid environment. Provide a valid SimPy environment.")
    # Validate node_type via the NodeType enum — single source of truth
    # shared with create_sc_net. Accepts either a string (case-insensitive)
    # or a NodeType member. Stored as the normalized lowercase string so
    # every existing equality check against a literal (``== "demand"``,
    # ``"supplier" in node.node_type``) continues to work.
    try:
        node_type = NodeType(node_type).value
    except ValueError:
        global_logger.error(f"Invalid node type. Node type: {node_type}")
        raise ValueError(f"Invalid node type: {node_type}.")
    # ``ensure_numeric_callable`` turns a plain number into a function that
    # returns it, and calls the function once to check that it returns a
    # number. A mistake (for example, passing a class such as ``int``) is
    # then reported here, instead of crashing later in the middle of a run.
    node_recovery_time = ensure_numeric_callable("node_recovery_time", node_recovery_time)
    if node_disrupt_time is not None:
        node_disrupt_time = ensure_numeric_callable("node_disrupt_time", node_disrupt_time)
    self.env = env  # simulation environment
    self.ID = ID  # ID of the node (alphanumeric)
    self.name = name  # name of the node
    self.node_type = node_type  # type of the node (see NodeType)
    self.node_failure_p = failure_p  # disruption probability
    self.node_status = "active"  # "active", or "inactive" while disrupted
    self.node_disrupt_time = node_disrupt_time  # function: time units until the next disruption
    self.node_recovery_time = node_recovery_time  # function: time units a disruption lasts
    self.rng = rng if rng is not None else _rng  # random number generator for random disruptions; the library's default if none is given
    self.suppliers = []  # links to this node's suppliers; filled in by Link.__init__ through add_supplier_link
    self.buyers = []  # links to this node's buyers; filled in by Link.__init__ through add_buyer_link
    # Work out the disruption-impact function once, here. The setting as
    # given is kept as ``disruption_impact`` (shown by get_info); the
    # function that runs when a disruption starts is ``_disruption_impact_fn``.
    self.disruption_impact = disruption_impact
    self._disruption_impact_fn = _resolve_disruption_impact(disruption_impact, disruption_loss_fraction)
    # The cost of getting rid of one destroyed unit (0 when they can simply
    # be thrown away). Only this is charged for destroyed units: their buy
    # price was already counted when they arrived.
    validate_number(name="disposal_cost", value=disposal_cost)
    validate_non_negative(name="disposal_cost", value=disposal_cost)
    self.disposal_cost = disposal_cost

    logger_name = self.ID # default logger name is the node ID
    if 'logger_name' in kwargs:
        logger_name = kwargs.pop('logger_name')
    # Per-node loggers live as children of the library root ``"sim_trace"``
    # so their records propagate up to the single set of handlers configured
    # on ``global_logger``. This avoids each Node opening its own
    # FileHandler on ``simulation_trace.log`` (which used to truncate the
    # file once per node at construction). Users who pass a custom
    # ``logger_name`` get the prefix added automatically; if they already
    # supplied the prefix we don't double it.
    if not logger_name.startswith("sim_trace."):
        logger_name = f"sim_trace.{logger_name}"
    # Only forward kwargs that GlobalLogger actually accepts; ignore the rest
    # so unrelated keys (e.g. record_inv_levels, shelf_life) don't raise TypeError.
    logger_kwargs = {k: kwargs[k] for k in kwargs if k in _LOGGER_KWARGS}
    self.logger = GlobalLogger(logger_name=logger_name, **logger_kwargs)  # create a logger
    # Do NOT call enable_logging() here — the per-node logger inherits its
    # handlers from ``global_logger`` via propagation. The historical call
    # site re-attached a FileHandler in mode='w' on every Node, which
    # truncated the trace file N times during network construction (§4.8).
    if not logging:
        self.logger.disable_logging()  # mute this node specifically

    if(self.node_failure_p>0 or self.node_disrupt_time): # start the disruption process if the node can be disrupted
        self.env.process(self.disruption()) 

disruption

disruption()

Disrupts the node (its status becomes "inactive") and recovers it after the recovery time, for the whole simulation. Disruptions start either at the times given by node_disrupt_time, or at random: each time unit, a disruption starts with probability failure_p.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
def disruption(self):
    """
    Disrupts the node (its status becomes "inactive") and recovers it after the
    recovery time, for the whole simulation. Disruptions start either at the
    times given by ``node_disrupt_time``, or at random: each time unit, a
    disruption starts with probability ``failure_p``.

    Parameters:
        None

    Attributes:
        None

    Returns:
        None
    """
    # TODO: interrupt all ongoing processes spawned by this node on disruption, and resume them after recovery.
    while True:
        if(self.node_status=="active"):
            if(self.node_disrupt_time): # if node_disrupt_time is provided, wait for the disruption time
                disrupt_time = self.node_disrupt_time() # get the disruption time
                validate_positive(name="node_disrupt_time", value=disrupt_time) # check if disrupt_time is positive
                yield self.env.timeout(disrupt_time)
                self.node_status = "inactive" # change the node status to inactive
                self.logger.info(f"{self.env.now}:{self.ID}: Node disrupted.")
                self._record_disruption_start()
                # The disruption-impact effect runs once, at the moment the
                # node switches from active to inactive. Effects that should
                # build up *during* the disruption belong in a custom
                # function that starts its own process; checking once per
                # time unit here would wake the simulation up for nothing.
                if self._disruption_impact_fn is not None:
                    self._disruption_impact_fn(self)
            else:
                # Random disruption: check once per time unit, so
                # ``node_failure_p`` acts as the chance that a disruption
                # starts in any one time unit. The ``yield env.timeout(1)`` below MUST run
                # whether or not this check succeeds. If it were skipped on a
                # miss, the ``while True`` loop would spin without advancing
                # simulation time, repeating the check over and over until one
                # finally succeeds — which would disrupt the node almost
                # immediately at t=0 no matter how small ``failure_p`` is.
                if(self.rng.random() < self.node_failure_p):
                    self.node_status = "inactive"
                    self.logger.info(f"{self.env.now}:{self.ID}: Node disrupted.")
                    self._record_disruption_start()
                    if self._disruption_impact_fn is not None:
                        self._disruption_impact_fn(self)
                yield self.env.timeout(1)
        else:
            recovery_time = self.node_recovery_time() # get the recovery time
            validate_positive(name="node_recovery_time", value=recovery_time) # the recovery time must be positive
            yield self.env.timeout(recovery_time)
            self.node_status = "active"
            self.logger.info(f"{self.env.now}:{self.ID}: Node recovered from disruption.")
            stats = getattr(self, "stats", None)
            if stats is not None:
                stats.disruption_ended()
            # Orders from buyers were not placed while this node was
            # disrupted. Wake each buyer's replenishment policy so that it
            # checks its inventory position again now. Without this, a buyer
            # with no units left would never order again: nothing would
            # leave its inventory, so nothing else would wake its policy.
            for link in self.buyers:
                link.sink.wake_replenishment()
            # Wake this node's own replenishment policy too, so that it
            # checks its inventory position now that the node is working
            # again. A disruption that destroyed units did not wake it (see
            # ``Inventory.destroy``). Without this, a node with no units
            # left, no orders from buyers and no waiting customers would
            # never order again, for the same reason. It is woken after
            # its buyers, so that a run gives the same results as models
            # that woke the node with their own impact function before
            # the library did.
            self.wake_replenishment()
add_supplier_link(link) -> None

Register a link from one of this node's suppliers (a link whose sink is this node).

This is the one supported way to attach a Link to a Node. Supplier-selection policies look through node.suppliers to choose which supplier receives a replenishment order, so a Link must be registered here for the node to send orders over it.

Link.__init__ calls this automatically for its sink — users of the direct-instantiation API do not need to call it themselves. It is also safe to call explicitly if a Link is constructed separately from its sink and then attached later.

Parameters:
  • link (Link) –

    The Link object whose sink is this node.

Raises:
  • TypeError –

    If link is not a Link instance.

  • ValueError –

    If link.sink is not this node.

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
def add_supplier_link(self, link) -> None:
    """
    Register a link from one of this node's suppliers (a link whose ``sink``
    is this node).

    This is the one supported way to attach a Link to a Node.
    Supplier-selection policies look through ``node.suppliers`` to choose
    which supplier receives a replenishment order, so a Link must be
    registered here for the node to send orders over it.

    ``Link.__init__`` calls this automatically for its ``sink`` — users of
    the direct-instantiation API do not need to call it themselves.
    It is also safe to call explicitly if a Link is constructed separately
    from its sink and then attached later.

    Parameters:
        link (Link): The Link object whose ``sink`` is this node.

    Raises:
        TypeError: If ``link`` is not a Link instance.
        ValueError: If ``link.sink`` is not this node.

    Returns:
        None
    """
    if not isinstance(link, Link):
        global_logger.error("add_supplier_link expects a Link instance.")
        raise TypeError("add_supplier_link expects a Link instance.")
    if link.sink is not self:
        global_logger.error(
            f"{self.ID}: add_supplier_link called with a link whose sink is "
            f"{link.sink.ID}, not {self.ID}."
        )
        raise ValueError("link.sink must match the node add_supplier_link is called on.")
    self.suppliers.append(link)
add_buyer_link(link) -> None

Register a link to one of this node's buyers (a link whose source is this node).

The node uses this list when it recovers from a disruption, to wake the replenishment policy of each buyer (see wake_replenishment). Link.__init__ calls this automatically for its source.

Parameters:
  • link (Link) –

    The Link object whose source is this node.

Raises:
  • TypeError –

    If link is not a Link instance.

  • ValueError –

    If link.source is not this node.

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
def add_buyer_link(self, link) -> None:
    """
    Register a link to one of this node's buyers (a link whose ``source``
    is this node).

    The node uses this list when it recovers from a disruption, to wake
    the replenishment policy of each buyer (see ``wake_replenishment``).
    ``Link.__init__`` calls this automatically for its ``source``.

    Parameters:
        link (Link): The Link object whose ``source`` is this node.

    Raises:
        TypeError: If ``link`` is not a Link instance.
        ValueError: If ``link.source`` is not this node.

    Returns:
        None
    """
    if not isinstance(link, Link):
        global_logger.error("add_buyer_link expects a Link instance.")
        raise TypeError("add_buyer_link expects a Link instance.")
    if link.source is not self:
        global_logger.error(
            f"{self.ID}: add_buyer_link called with a link whose source is "
            f"{link.source.ID}, not {self.ID}."
        )
        raise ValueError("link.source must match the node add_buyer_link is called on.")
    self.buyers.append(link)

wake_replenishment

wake_replenishment() -> None

Make this node's replenishment policy check its inventory position now.

A policy without a review period checks only when it is woken, normally when the node's inventory drops. A supplier or a link that recovers from a disruption calls this on its buyer, because an order the policy tried to place during the disruption was not placed. A node that recovers also calls it on itself, because units destroyed by the disruption did not wake its policy. The policy then checks again and orders if its inventory position is too low. An extra wake-up does no harm: if the position is fine, the policy just waits again. A policy with a review period checks on its own timer and is not affected.

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
def wake_replenishment(self) -> None:
    """
    Make this node's replenishment policy check its inventory position now.

    A policy without a review period checks only when it is woken, normally
    when the node's inventory drops. A supplier or a link that recovers from
    a disruption calls this on its buyer, because an order the policy tried
    to place during the disruption was not placed. A node that recovers
    also calls it on itself, because units destroyed by the disruption did
    not wake its policy. The policy then checks again and orders if its
    inventory position is too low. An extra
    wake-up does no harm: if the position is fine, the policy just waits
    again. A policy with a review period checks on its own timer and is not
    affected.

    Returns:
        None
    """
    inventory_drop = getattr(self, "inventory_drop", None)
    if inventory_drop is not None and not inventory_drop.triggered:
        inventory_drop.succeed()

position

position() -> float

The inventory position, used by replenishment policies.

The inventory position is what the node can count on: its inventory level plus the units in transit to it, minus the units it still owes (to other sites and to waiting customers). Replenishment policies use this one value instead of working it out themselves, so the arithmetic stays in one place.

inventory.on_hand already holds most of it. The node's backorders hold two kinds of waiting orders, and only one of them is subtracted here:

  • Replenishment orders from other sites. They were taken off on_hand when they were placed (those still waiting are in the inventory's line, see Inventory.waiting_site_orders), so subtracting them again would count them twice and make the node order too much.
  • Customers waiting for units. They were not taken off on_hand, so they are subtracted here.
Returns:
  • float( float ) –

    The inventory position.

Source code in src/SupplyNetPy/Components/core.py
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
def position(self) -> float:
    """
    The inventory position, used by replenishment policies.

    The inventory position is what the node can count on: its inventory
    level plus the units in transit to it, minus the units it still owes
    (to other sites and to waiting customers). Replenishment policies use
    this one value instead of working it out themselves, so the arithmetic
    stays in one place.

    ``inventory.on_hand`` already holds most of it. The node's backorders
    hold two kinds of waiting orders, and only one of them is subtracted
    here:

    - Replenishment orders from other sites. They were taken off
      ``on_hand`` when they were placed (those still waiting are in the
      inventory's line, see ``Inventory.waiting_site_orders``), so
      subtracting them again would count them twice and make the node order
      too much.
    - Customers waiting for units. They were not taken off ``on_hand``, so
      they are subtracted here.

    Returns:
        float: The inventory position.
    """
    customer_backorders = self.stats.backorder[1] - self.inventory.waiting_site_orders()
    return self.inventory.on_hand - customer_backorders

place_order

place_order(quantity) -> None

Choose a supplier with this node's supplier-selection policy and place a replenishment order with it.

First it checks that some supplier can take the order (see can_place_order). If none can, no order is placed. The replenishment policy then goes back to waiting, and it is woken when a supplier or a link recovers (see wake_replenishment), or when units leave the inventory.

Otherwise it does in one call what a replenishment policy would otherwise do in two steps: selection_policy.select(...), which chooses among the suppliers that can take the order, and env.process(process_order(...)). The order itself is handled by process_order, which runs as its own SimPy process: it reduces the order to fit the node's capacity and records the order at the supplier. This method returns at once.

Parameters:
  • quantity (float) –

    Units to order. process_order may still reduce this to fit the capacity.

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
def place_order(self, quantity) -> None:
    """
    Choose a supplier with this node's supplier-selection policy and place a
    replenishment order with it.

    First it checks that some supplier can take the order (see
    ``can_place_order``). If none can, no order is placed. The replenishment
    policy then goes back to waiting, and it is woken when a supplier or a
    link recovers (see ``wake_replenishment``), or when units leave the
    inventory.

    Otherwise it does in one call what a replenishment policy would
    otherwise do in two steps: ``selection_policy.select(...)``, which
    chooses among the suppliers that can take the order, and
    ``env.process(process_order(...))``. The order itself is handled by
    ``process_order``, which runs as its own SimPy process: it reduces the
    order to fit the node's capacity and records the order at the
    supplier. This method returns at once.

    Parameters:
        quantity (float): Units to order. ``process_order`` may still reduce
            this to fit the capacity.

    Returns:
        None
    """
    if not self.can_place_order():
        if self.has_suppliers():
            self.logger.info(f"{self.env.now:.4f}:{self.ID}: No supplier can take an order (every supplier or the link to it is disrupted). Order not placed.")
            # A blocked order: not lost, because the policy orders again
            # when a supplier or a link recovers.
            self.stats.update_stats(blocked=[1, quantity])
        else:
            # Not a disruption: the site has no supplier to order from at
            # all, a mistake in how the network is built. No recovery will
            # ever let the order through, so it is not counted as blocked.
            # The user is warned once for each site (the warning text
            # leaves out the site, so Python shows it once in all).
            self.logger.info(f"{self.env.now:.4f}:{self.ID}: The site has no supplier to order from. Order not placed.")
            if not getattr(self, "_warned_no_supplier", False):
                self._warned_no_supplier = True
                message = ("A site has no supplier to place replenishment orders with (a manufacturer needs a "
                           "raw-material supplier for every raw material), so its orders are not placed. "
                           "Connect a supplier to it with a link.")
                self.logger.warning(f"{self.ID}: {message}")
                warnings.warn(message, UserWarning, stacklevel=2)
        return
    # The order is really placed when process_order runs, a moment later
    # at this same time. It checks the chosen supplier again then.
    supplier = self.selection_policy.select(quantity)
    self.env.process(self.process_order(supplier, quantity))

can_place_order

can_place_order() -> bool

Whether this node can place a replenishment order right now: at least one supplier can take it, because neither the supplier's site nor the link to it is disrupted. place_order checks this first. Manufacturer replaces it with its own rule.

Returns:
  • bool( bool ) –

    True if at least one supplier can take an order.

Source code in src/SupplyNetPy/Components/core.py
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
def can_place_order(self) -> bool:
    """
    Whether this node can place a replenishment order right now: at least
    one supplier can take it, because neither the supplier's site nor the
    link to it is disrupted. ``place_order`` checks this first.
    ``Manufacturer`` replaces it with its own rule.

    Returns:
        bool: True if at least one supplier can take an order.
    """
    return any(link.can_take_order() for link in self.suppliers)

has_suppliers

has_suppliers() -> bool

Whether this site has a supplier to order from at all, disrupted or not. When can_place_order is false, this tells the two cases apart: every supplier is disrupted (the order is blocked, and the site orders again after a recovery), or the site has no supplier (a mistake in how the network is built). Manufacturer replaces it with its own rule.

Returns:
  • bool( bool ) –

    True if at least one link from a supplier leads to this site.

Source code in src/SupplyNetPy/Components/core.py
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
def has_suppliers(self) -> bool:
    """
    Whether this site has a supplier to order from at all, disrupted or
    not. When ``can_place_order`` is false, this tells the two cases apart:
    every supplier is disrupted (the order is blocked, and the site orders
    again after a recovery), or the site has no supplier (a mistake in how
    the network is built). ``Manufacturer`` replaces it with its own rule.

    Returns:
        bool: True if at least one link from a supplier leads to this site.
    """
    return len(self.suppliers) > 0

wait_for_drop

wait_for_drop()

Wait until the node's inventory drops, then get ready for the next drop.

Replenishment policies use it as yield from node.wait_for_drop(). It waits on the node's inventory_drop event, which fires when the inventory drops, and also when the node itself, one of its suppliers or the link to it recovers from a disruption (see wake_replenishment). A SimPy event can fire only once, so after it fires it is replaced with a fresh one, ready for the next drop. Doing this here keeps that replacing step in one place.

Yields:
  • –

    simpy.Event: The current inventory_drop event.

Source code in src/SupplyNetPy/Components/core.py
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
def wait_for_drop(self):
    """
    Wait until the node's inventory drops, then get ready for the next drop.

    Replenishment policies use it as ``yield from node.wait_for_drop()``.
    It waits on the node's ``inventory_drop`` event, which fires when the
    inventory drops, and also when the node itself, one of its suppliers or
    the link to it recovers from a disruption (see ``wake_replenishment``). A SimPy event can fire only once, so after it fires it
    is replaced with a fresh one, ready for the next drop. Doing this here
    keeps that replacing step in one place.

    Yields:
        simpy.Event: The current ``inventory_drop`` event.
    """
    yield self.inventory_drop
    self.inventory_drop = self.env.event()
Link(env: Environment, ID: str, source: Node, sink: Node, cost: float, lead_time: Callable, link_failure_p: float = 0.0, link_disrupt_time: Callable = None, link_recovery_time: Callable = lambda: 1, rng: Random = None)

Bases: NamedEntity, InfoMixin

Represents a link: the transport connection that carries goods from one site, its source (the supplier), to another, its sink (the buyer).

Each link has a transportation cost (per order) and a lead time. A link can be disrupted at random (each time unit, a disruption starts with probability link_failure_p) or at the times given by link_disrupt_time; it recovers after link_recovery_time. While a link is disrupted, no new orders are sent over it; orders already in transit still arrive.

Parameters:
  • env (Environment) –

    Simulation environment.

  • ID (str) –

    ID of the link.

  • source (Node) –

    The supplier: the site the goods come from.

  • sink (Node) –

    The buyer: the site the goods go to.

  • cost (float) –

    Transportation cost of each order sent over the link.

  • lead_time (callable) –

    Time units an order takes to arrive (a number, or a function returning one each time).

  • link_failure_p (float, default: 0.0 ) –

    Disruption probability: the chance, each time unit, that a disruption starts.

  • link_disrupt_time (callable, default: None ) –

    Time units until the next disruption (a number or a function).

  • link_recovery_time (callable, default: lambda: 1 ) –

    Time units a disruption lasts (a number or a function).

Attributes:
  • env (Environment) –

    Simulation environment.

  • ID (str) –

    ID of the link.

  • source (Node) –

    The supplier.

  • sink (Node) –

    The buyer.

  • cost (float) –

    Transportation cost of each order.

  • lead_time (callable) –

    Function giving the lead time of each order.

  • link_failure_p (float) –

    Disruption probability.

  • status (str) –

    Status of the link: "active", or "inactive" while disrupted.

  • link_disrupt_time (callable) –

    Function giving the time units until the next disruption.

  • link_recovery_time (callable) –

    Function giving the time units a disruption lasts.

  • stats (LinkStatistics) –

    What flows over the link (see LinkStatistics).

Methods:

Name Description
disruption

Disrupts the link and recovers it, for the whole simulation.

available_quantity

The supplier's inventory level.

Initialize the link.

Parameters:
  • env (Environment) –

    The simulation environment.

  • ID (str) –

    Unique identifier for the link.

  • source (Node) –

    The supplier (the site the goods come from). Cannot be a customer demand.

  • sink (Node) –

    The buyer (the site the goods go to). Cannot be a raw-material supplier.

  • cost (float) –

    Transportation cost of each order sent over the link. Must not be negative.

  • lead_time (callable) –

    Lead time of each order: a number, or a function returning one (it can be random). Cannot be None.

  • link_failure_p (float, default: 0.0 ) –

    Disruption probability: the chance, each time unit, that a disruption starts. Default is 0.0.

  • link_disrupt_time (callable, default: None ) –

    Time units until the next disruption (a number or a function). If given, it is used instead of link_failure_p.

  • link_recovery_time (callable, default: lambda: 1 ) –

    Time units a disruption lasts (a number or a function). Default is 1 time unit.

Attributes:
  • env (Environment) –

    The simulation environment.

  • ID (str) –

    The ID of the link.

  • source (Node) –

    The supplier.

  • sink (Node) –

    The buyer.

  • name (str) –

    Readable name of the link, made from the source and sink IDs.

  • cost (float) –

    Transportation cost of each order.

  • lead_time (callable) –

    Function giving the lead time of each order.

  • link_failure_p (float) –

    Disruption probability.

  • status (str) –

    Status of the link: "active", or "inactive" while disrupted.

  • link_recovery_time (callable) –

    Function giving the time units a disruption lasts.

  • link_disrupt_time (callable) –

    Function giving the time units until the next disruption.

  • stats (LinkStatistics) –

    What flows over the link: the orders placed over it, what arrived, waiting times, lead times, costs and disruptions. link.get_statistics() gives only the status.

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
def __init__(self, env: simpy.Environment,
             ID: str,
             source: Node,
             sink: Node,
             cost: float, # transportation cost
             lead_time: Callable,
             link_failure_p: float = 0.0,
             link_disrupt_time: Callable = None,
             link_recovery_time: Callable = lambda: 1,
             rng: random.Random = None) -> None:
    """
    Initialize the link.

    Parameters:
        env (simpy.Environment): The simulation environment.
        ID (str): Unique identifier for the link.
        source (Node): The supplier (the site the goods come from). Cannot be a customer demand.
        sink (Node): The buyer (the site the goods go to). Cannot be a raw-material supplier.
        cost (float): Transportation cost of each order sent over the link. Must not be negative.
        lead_time (callable): Lead time of each order: a number, or a function returning one (it can be random). Cannot be None.
        link_failure_p (float, optional): Disruption probability: the chance, each time unit, that a disruption starts. Default is 0.0.
        link_disrupt_time (callable, optional): Time units until the next disruption (a number or a function). If given, it is used instead of link_failure_p.
        link_recovery_time (callable, optional): Time units a disruption lasts (a number or a function). Default is 1 time unit.

    Attributes:
        env (simpy.Environment): The simulation environment.
        ID (str): The ID of the link.
        source (Node): The supplier.
        sink (Node): The buyer.
        name (str): Readable name of the link, made from the source and sink IDs.
        cost (float): Transportation cost of each order.
        lead_time (callable): Function giving the lead time of each order.
        link_failure_p (float): Disruption probability.
        status (str): Status of the link: "active", or "inactive" while disrupted.
        link_recovery_time (callable): Function giving the time units a disruption lasts.
        link_disrupt_time (callable): Function giving the time units until the next disruption.
        stats (LinkStatistics): What flows over the link: the orders placed over it, what arrived, waiting
            times, lead times, costs and disruptions. ``link.get_statistics()`` gives only the status.

    Returns:
        None
    """
    if not isinstance(env, simpy.Environment):
        raise ValueError("Invalid environment. Provide a valid SimPy environment.")
    if not isinstance(source, Node) or not isinstance(sink, Node):
        raise ValueError("Invalid source or sink node. Provide valid Node instances.")
    if lead_time is None:
        global_logger.error("Lead time cannot be None. Provide a function to model stochastic lead time.")
        raise ValueError("Lead time cannot be None. Provide a function to model stochastic lead time.")
    lead_time = ensure_numeric_callable("lead_time", lead_time)
    if(source == sink):
        global_logger.error("Source and sink nodes cannot be the same.")
        raise ValueError("Source and sink nodes cannot be the same.")
    if(source.node_type == "demand"):
        global_logger.error("Demand node cannot be a source node.")
        raise ValueError("Demand node cannot be a source node.")
    if("supplier" in sink.node_type):
        global_logger.error("Supplier node cannot be a sink node.")
        raise ValueError("Supplier node cannot be a sink node.")
    if("supplier" in source.node_type and "supplier" in sink.node_type):
        global_logger.error("Supplier nodes cannot be connected.")
        raise ValueError("Supplier nodes cannot be connected.")
    if("supplier" in source.node_type and sink.node_type == "demand"):
        global_logger.error("Supplier node cannot be connected to a demand node.")
        raise ValueError("Supplier node cannot be connected to a demand node.")
    validate_non_negative("Cost", cost)
    if (link_disrupt_time is not None):
        link_disrupt_time = ensure_numeric_callable("link_disrupt_time", link_disrupt_time)
    if (link_recovery_time is not None):
        link_recovery_time = ensure_numeric_callable("link_recovery_time", link_recovery_time)

    self.env = env  # simulation environment
    self.ID = ID  # ID of the link (alphanumeric)
    self.source = source  # the supplier
    self.sink = sink  # the buyer
    self.name = f"{self.source.ID} to {self.sink.ID}"  # name of the link
    self.cost = cost  # transportation cost of each order
    self.lead_time = lead_time  # function giving the lead time of each order
    self.link_failure_p = link_failure_p  # disruption probability
    self.status = "active"  # "active", or "inactive" while disrupted
    self.link_recovery_time = link_recovery_time  # function: time units a disruption lasts
    self.link_disrupt_time = link_disrupt_time  # function: time units until the next disruption, if given
    self.rng = rng if rng is not None else _rng  # random number generator for random disruptions; the library's default if none is given
    self.stats = LinkStatistics(self)  # what flows over this link (see LinkStatistics)

    # Register this link with its sink (the buyer) through
    # ``add_supplier_link``, rather than changing ``sink.suppliers``
    # directly. Every Node starts with an empty ``suppliers`` list, so this
    # works for any valid sink.
    self.sink.add_supplier_link(self)
    self.source.add_buyer_link(self)
    self._warn_if_buyer_can_order_more_than_supplier_holds()
    if(self.link_failure_p>0 or self.link_disrupt_time): # start the disruption process if the link can be disrupted
        self.env.process(self.disruption())

disruption

disruption()

Disrupts the link (its status becomes "inactive") and recovers it after the recovery time, for the whole simulation. Disruptions start either at the times given by link_disrupt_time, or at random: each time unit, a disruption starts with probability link_failure_p.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
def disruption(self):
    """
    Disrupts the link (its status becomes "inactive") and recovers it after the recovery time, for the whole
    simulation. Disruptions start either at the times given by ``link_disrupt_time``, or at random: each time
    unit, a disruption starts with probability ``link_failure_p``.

    Parameters:
        None

    Attributes:
        None

    Returns:
        None
    """
    # Orders already in transit over this link are not stopped when the
    # link is disrupted; only *new* orders are not sent (see
    # Node.place_order, which checks Link.can_take_order).
    # Stopping orders in transit would need each one tracked as its own
    # process; that is left for later.
    while True:
        if(self.status=="active"):
            if(self.link_disrupt_time): # if link_disrupt_time is provided, wait for the disruption time
                disrupt_time = self.link_disrupt_time() # get the disruption time
                validate_positive(name="link_disrupt_time", value=disrupt_time) # the disruption time must be positive
                yield self.env.timeout(disrupt_time)
                self.status = "inactive" # change the link status to inactive
                global_logger.info(f"{self.env.now}:{self.ID}: Link disrupted.")
                self.stats.disruption_started()
            else:
                # Random disruption: check once per time unit. The
                # ``yield env.timeout(1)`` below must run whether or not the
                # check succeeds (same as in ``Node.disruption``). Without
                # it, a miss would spin through ``while True`` without
                # advancing simulation time, making ``link_failure_p`` turn
                # into near-certain disruption at t=0.
                if(self.rng.random() < self.link_failure_p):
                    self.status = "inactive"
                    global_logger.info(f"{self.env.now}:{self.ID}: Link disrupted.")
                    self.stats.disruption_started()
                yield self.env.timeout(1)
        else:
            recovery_time = self.link_recovery_time() # get the recovery time
            validate_positive(name="link_recovery_time", value=recovery_time) # the recovery time must be positive
            yield self.env.timeout(recovery_time)
            self.status = "active"
            global_logger.info(f"{self.env.now}:{self.ID}: Link recovered from disruption.")
            self.stats.disruption_ended()
            # Orders were not sent over this link while it was disrupted.
            # Wake the buyer's replenishment policy so it checks again now
            # (see Node.wake_replenishment).
            self.sink.wake_replenishment()

available_quantity

available_quantity() -> float

The supplier's inventory level: how many units it has right now.

Supplier-selection policies (such as SelectAvailable) use it to compare suppliers without reading link.source.inventory.level directly.

Returns:
  • float( float ) –

    The supplier's inventory level.

Source code in src/SupplyNetPy/Components/core.py
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
def available_quantity(self) -> float:
    """
    The supplier's inventory level: how many units it has right now.

    Supplier-selection policies (such as ``SelectAvailable``) use it to
    compare suppliers without reading ``link.source.inventory.level``
    directly.

    Returns:
        float: The supplier's inventory level.
    """
    return self.source.inventory.level

can_take_order

can_take_order() -> bool

Whether an order can be sent over this link right now: neither the link nor its supplier's site is disrupted.

Node.place_order and the supplier-selection policies use it to skip suppliers that cannot take an order.

Returns:
  • bool( bool ) –

    True if neither the link nor the supplier's site is disrupted.

Source code in src/SupplyNetPy/Components/core.py
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
def can_take_order(self) -> bool:
    """
    Whether an order can be sent over this link right now: neither the
    link nor its supplier's site is disrupted.

    ``Node.place_order`` and the supplier-selection policies use it to skip
    suppliers that cannot take an order.

    Returns:
        bool: True if neither the link nor the supplier's site is disrupted.
    """
    return self.status == "active" and self.source.node_status == "active"

Inventory

Inventory(env: Environment, capacity: float, initial_level: float, node: Node, replenishment_policy: InventoryReplenishment, holding_cost: float = 0.0, shelf_life: float = 0, inv_type: str = 'non-perishable', record_inv_levels=False)

Bases: NamedEntity, InfoMixin

The Inventory class holds the units at a node and its records about them. It keeps the inventory level within the capacity, keeps the inventory position (on_hand), and tells the node when its inventory drops, so that its replenishment policy can check whether to order. For perishable goods it keeps the list of batches and throws away expired units. It also works out the holding cost, and can record the inventory level once per time unit.

Parameters:
  • env (Environment) –

    Simulation environment.

  • capacity (float) –

    The largest inventory level the node can hold.

  • initial_level (float) –

    Inventory level at the start.

  • node (Node) –

    Node to which this inventory belongs.

  • replenishment_policy (InventoryReplenishment) –

    The node's replenishment policy.

  • holding_cost (float, default: 0.0 ) –

    Holding cost per unit per time unit.

  • shelf_life (float, default: 0 ) –

    Shelf life of perishable goods, in time units.

  • inv_type (str, default: 'non-perishable' ) –

    Type of the inventory, either "non-perishable" or "perishable".

  • record_inv_levels (bool, default: False ) –

    If True, record the inventory level once per time unit (in instantaneous_levels).

Attributes:
  • _info_keys (list) –

    Keys included in the information dictionary.

  • _stats_keys (list) –

    Keys included in the statistics dictionary.

  • env (Environment) –

    Simulation environment.

  • capacity (float) –

    The largest inventory level the node can hold.

  • init_level (float) –

    Inventory level at the start.

  • level (float) –

    Current inventory level.

  • on_hand (float) –

    Despite its name, not the units physically here: the inventory position before units owed to waiting customers are taken off (inventory level, plus units in transit, minus units owed to other sites). See Node.position.

  • inv_type (str) –

    Inventory type ("non-perishable" or "perishable").

  • holding_cost (float) –

    Holding cost per unit per time unit.

  • carry_cost (float) –

    Holding cost so far.

  • replenishment_policy (InventoryReplenishment) –

    The node's replenishment policy.

  • inventory (Container) –

    The SimPy container that holds the units (an _InventoryContainer).

  • last_update_t (float) –

    Time of the last holding-cost update.

  • level_time (float) –

    Running sum of inventory level × time, up to the last update.

  • zero_level_time (float) –

    Time the inventory level was 0, up to the last update.

  • shelf_life (float) –

    Shelf life of perishable goods, in time units (perishable goods only).

  • perish_queue (list) –

    The list of batches, as (manufacturing_date, quantity) tuples, kept as a heap (Python's heapq) so the oldest batch (the first to expire) is always at index 0. put adds a batch; a batch leaves the list (oldest first) at the moment its units leave the inventory, so the list always adds up to the inventory level.

  • perish_changed (Event) –

    Wake-up signal for remove_expired. put fires it when a new batch becomes the oldest one (or arrives while the list is empty), so remove_expired works out the next expiry again instead of checking every time unit. Replaced with a fresh event on each wake-up.

  • waste (float) –

    Expired units thrown away so far.

  • instantaneous_levels (list) –

    (time, inventory level) pairs, recorded once per time unit if record_inv_levels is True.

Methods:

Name Description
waiting_orders

Units asked for by orders still waiting at this inventory.

waiting_site_orders

The same, for replenishment orders from other sites only.

record_inventory_levels

Records the inventory level once per time unit.

put

Adds units (for perishable goods, as a new batch with its manufacturing date).

get

Takes units out for an order (oldest batch first, for perishable goods).

remove_expired

Throws away expired units (perishable goods only).

update_carry_cost

Adds the holding cost since the last update.

destroy

Removes units lost in a disruption.

Initialize the Inventory object.

Parameters:
  • env (Environment) –

    Simulation environment.

  • capacity (float) –

    The largest inventory level the node can hold.

  • initial_level (float) –

    Inventory level at the start.

  • node (Node) –

    Node to which this inventory belongs.

  • replenishment_policy (InventoryReplenishment) –

    The node's replenishment policy.

  • holding_cost (float, default: 0.0 ) –

    Holding cost per unit per time unit.

  • shelf_life (float, default: 0 ) –

    Shelf life of perishable goods, in time units.

  • inv_type (str, default: 'non-perishable' ) –

    Type of the inventory, either "non-perishable" or "perishable".

  • record_inv_levels (bool, default: False ) –

    If True, record the inventory level once per time unit.

Attributes:
  • _info_keys (list) –

    Keys included in the information dictionary.

  • _stats_keys (list) –

    Keys included in the statistics dictionary.

  • env (Environment) –

    Simulation environment.

  • capacity (float) –

    The largest inventory level the node can hold.

  • init_level (float) –

    Inventory level at the start.

  • level (float) –

    Current inventory level.

  • on_hand (float) –

    Despite its name, not the units physically here: the inventory position before units owed to waiting customers are taken off (inventory level, plus units in transit, minus units owed to other sites). See Node.position.

  • inv_type (str) –

    Inventory type ("non-perishable" or "perishable").

  • holding_cost (float) –

    Holding cost per unit per time unit.

  • carry_cost (float) –

    Holding cost so far.

  • replenishment_policy (InventoryReplenishment) –

    The node's replenishment policy.

  • inventory (Container) –

    The SimPy container that holds the units (an _InventoryContainer).

  • last_update_t (float) –

    Time of the last holding-cost update.

  • level_time (float) –

    Running sum of inventory level × time, up to the last update.

  • zero_level_time (float) –

    Time the inventory level was 0, up to the last update.

  • shelf_life (float) –

    Shelf life of perishable goods, in time units (perishable goods only).

  • perish_queue (list) –

    The list of batches, as (manufacturing_date, quantity) tuples, kept as a heap (Python's heapq) so the oldest batch (the first to expire) is always at index 0. put adds a batch; a batch leaves the list (oldest first) at the moment its units leave the inventory, so the list always adds up to the inventory level.

  • waste (float) –

    Expired units thrown away so far.

  • instantaneous_levels (list) –

    (time, inventory level) pairs, recorded once per time unit.

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
def __init__(self,
             env: simpy.Environment, 
             capacity: float, 
             initial_level: float, 
             node: Node,
             replenishment_policy: InventoryReplenishment,
             holding_cost: float = 0.0,
             shelf_life: float = 0,
             inv_type: str = "non-perishable",
             record_inv_levels = False) -> None:
    """
    Initialize the Inventory object.

    Parameters:
        env (simpy.Environment): Simulation environment.
        capacity (float): The largest inventory level the node can hold.
        initial_level (float): Inventory level at the start.
        node (Node): Node to which this inventory belongs.
        replenishment_policy (InventoryReplenishment): The node's replenishment policy.
        holding_cost (float): Holding cost per unit per time unit.
        shelf_life (float): Shelf life of perishable goods, in time units.
        inv_type (str): Type of the inventory, either "non-perishable" or "perishable".
        record_inv_levels (bool): If True, record the inventory level once per time unit.

    Attributes:
        _info_keys (list): Keys included in the information dictionary.
        _stats_keys (list): Keys included in the statistics dictionary.
        env (simpy.Environment): Simulation environment.
        capacity (float): The largest inventory level the node can hold.
        init_level (float): Inventory level at the start.
        level (float): Current inventory level.
        on_hand (float): Despite its name, not the units physically here: the inventory position before units owed to waiting customers are taken off (inventory level, plus units in transit, minus units owed to other sites). See ``Node.position``.
        inv_type (str): Inventory type ("non-perishable" or "perishable").
        holding_cost (float): Holding cost per unit per time unit.
        carry_cost (float): Holding cost so far.
        replenishment_policy (InventoryReplenishment): The node's replenishment policy.
        inventory (simpy.Container): The SimPy container that holds the units (an ``_InventoryContainer``).
        last_update_t (float): Time of the last holding-cost update.
        level_time (float): Running sum of inventory level × time, up to the last update.
        zero_level_time (float): Time the inventory level was 0, up to the last update.
        shelf_life (float): Shelf life of perishable goods, in time units (perishable goods only).
        perish_queue (list): The list of batches, as ``(manufacturing_date, quantity)`` tuples, kept as a heap (Python's ``heapq``) so the oldest batch (the first to expire) is always at index 0. ``put`` adds a batch; a batch leaves the list (oldest first) at the moment its units leave the inventory, so the list always adds up to the inventory level.
        waste (float): Expired units thrown away so far.
        instantaneous_levels (list): ``(time, inventory level)`` pairs, recorded once per time unit.

    Returns:
        None
    """
    if not isinstance(node, Node):
        global_logger.error("Node must be an instance of Node class.")
        raise TypeError("Node must be an instance of Node class.")
    self.node = node # node to which this inventory belongs
    if initial_level > capacity:
        self.node.logger.error("Initial level cannot be greater than capacity.")
        raise ValueError("Initial level cannot be greater than capacity.")
    if replenishment_policy is not None:
        if not issubclass(replenishment_policy.__class__, InventoryReplenishment):
            self.node.logger.error(f"{type(replenishment_policy).__name__} must inherit from InventoryReplenishment")
            raise TypeError(f"{type(replenishment_policy).__name__} must inherit from InventoryReplenishment")
    if inv_type not in ["non-perishable", "perishable"]:
        self.node.logger.error(f"Invalid inventory type. {inv_type} is not yet available.")
        raise ValueError(f"Invalid inventory type. {inv_type} is not yet available.")
    validate_positive("Capacity", capacity)
    validate_non_negative("Initial level", initial_level)
    validate_non_negative("Inventory holding cost",holding_cost)
    validate_non_negative("Shelf life", shelf_life)
    self.env = env
    self.init_level = initial_level
    self.on_hand = initial_level # the inventory position (see on_hand above); starts at the initial level
    self.inv_type = inv_type
    self.holding_cost = holding_cost
    self.carry_cost = 0 # holding cost so far
    self.level_time = 0 # running sum of inventory level × time (see Statistics.inventory_level_time)
    self.zero_level_time = 0 # time the inventory level was 0
    self.replenishment_policy = replenishment_policy
    # The SimPy container that holds the units. For perishable goods it
    # picks the batches for each order at the moment the units leave the
    # inventory (see _InventoryContainer).
    self.inventory = _InventoryContainer(env=self.env, capacity=capacity, init=self.init_level,
                            pick_batches=self._pick_oldest_batches)
    self.last_update_t = self.env.now # last time the holding cost was updated

    if self.inv_type == "perishable":
        validate_positive("Shelf life", shelf_life)
        self.shelf_life = shelf_life
        self.perish_queue = [(0, initial_level)]  # the initial units count as made at time 0
        self.waste = 0
        # Wake-up signal for the ``remove_expired`` helper. ``put`` fires
        # this event when a newly added batch becomes the oldest one (so the
        # next expiry comes sooner), and the helper makes a fresh event each
        # time it wakes — the same pattern as ``Node.wait_for_drop``.
        self.perish_changed = self.env.event()
        self.env.process(self.remove_expired())

    self.instantaneous_levels = []
    if record_inv_levels:
        self.env.process(self.record_inventory_levels())  # record the inventory level once per time unit

level property

level

Current inventory level, read from the SimPy container that holds the units.

capacity property

capacity

The largest inventory level the node can hold, read from the SimPy container.

waiting_orders

waiting_orders()

How many units are asked for by orders still waiting at this inventory.

An order the inventory cannot fill right away (for example, a replenishment order bigger than the inventory level) joins a line and is served, in turn, when enough units arrive. Orders are served strictly in the order they arrived, so an order can also wait behind an earlier one. Besides replenishment orders from other sites, the line can hold other orders made with get. All of these units have already been taken off on_hand but have not left the inventory yet. (Expired and destroyed units never wait in this line: they are removed at once.)

Returns:
  • float –

    the units asked for by all waiting orders, or 0 if none are waiting.

Source code in src/SupplyNetPy/Components/core.py
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
def waiting_orders(self):
    """
    How many units are asked for by orders still waiting at this inventory.

    An order the inventory cannot fill right away (for example, a
    replenishment order bigger than the inventory level) joins a line and
    is served, in turn, when enough units arrive. Orders are served strictly
    in the order they arrived, so an order can also wait behind an earlier
    one. Besides replenishment orders from other sites, the line can hold
    other orders made with `get`. All of these units have already been
    taken off `on_hand` but have not left the inventory yet. (Expired and
    destroyed units never wait in this line: they are removed at once.)

    Returns:
        float: the units asked for by all waiting orders, or 0 if none are waiting.
    """
    return sum(request.amount for request in self.inventory.get_queue)

waiting_site_orders

waiting_site_orders()

How many units are owed to other sites whose replenishment orders are still waiting at this inventory.

Only the orders made with get(..., site_order=True) count. These orders are also part of the node's backorders.

Returns:
  • float –

    the units asked for by the waiting replenishment orders from other sites, or 0 if none are waiting.

Source code in src/SupplyNetPy/Components/core.py
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
def waiting_site_orders(self):
    """
    How many units are owed to other sites whose replenishment orders are still waiting at this inventory.

    Only the orders made with `get(..., site_order=True)` count. These
    orders are also part of the node's backorders.

    Returns:
        float: the units asked for by the waiting replenishment orders from other sites, or 0 if none are waiting.
    """
    return sum(request.amount for request in self.inventory.get_queue
               if getattr(request, "site_order", False))

record_inventory_levels

record_inventory_levels()

Record the inventory level once per time unit, in instantaneous_levels.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
def record_inventory_levels(self):
    """
    Record the inventory level once per time unit, in ``instantaneous_levels``.

    Parameters:
        None

    Attributes: 
        None

    Returns:
        None
    """
    while True:
        self.instantaneous_levels.append((self.env.now,self.inventory.level))  # record the current inventory level
        yield self.env.timeout(1)

put

put(amount: float, manufacturing_date: float = None)

Add units to the inventory. For perishable goods, they are added as a new batch with their manufacturing date.

Parameters:
  • amount (float) –

    units to add

  • manufacturing_date (float, default: None ) –

    the units' manufacturing date (needed for perishable goods only)

Returns:
  • float –

    the units actually accepted. Less than amount when they do not all fit within the capacity, and 0 when the inventory is infinite, already full, or amount <= 0. Callers that keep on_hand must take any shortfall (requested - accepted) back off it.

Source code in src/SupplyNetPy/Components/core.py
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
def put(self, amount: float, manufacturing_date: float = None):
    """
    Add units to the inventory. For perishable goods, they are added as a new
    batch with their manufacturing date.

    Parameters:
        amount (float): units to add
        manufacturing_date (float): the units' manufacturing date (needed for perishable goods only)

    Returns:
        float: the units actually accepted. Less than ``amount`` when they do
            not all fit within the capacity, and 0 when the inventory is
            infinite, already full, or ``amount <= 0``. Callers that keep
            ``on_hand`` must take any shortfall (``requested - accepted``)
            back off it.
    """
    # Use ``>=``, not ``==``. When the inventory is filled exactly to
    # capacity, floating-point rounding can leave the level a hair ABOVE
    # capacity (e.g. 99.99000000000001 for a capacity of 99.99). A plain
    # ``== capacity`` check would miss that case, so ``>=`` is used to
    # correctly spot an inventory that is already full.
    if self.inventory.level == float('inf') or amount <= 0 or self.inventory.level >= self.capacity:
        return 0

    if amount + self.inventory.level > self.capacity: # adjust amount if it exceeds capacity
        old_amount = amount
        amount = self.capacity - self.inventory.level
        # Safety net: if the rounding above left the level just over
        # capacity, this leftover space comes out as zero or negative.
        # SimPy's Container.put rejects a non-positive amount with an error,
        # so we treat the inventory as already full and simply accept nothing.
        if amount <= 0:
            return 0
        self.node.logger.warning(f"Inventory capacity exceeded. Only {amount} of {old_amount} units added to inventory.")

    if self.inv_type == "perishable":
        if manufacturing_date is None:
            self.node.logger.error("Manufacturing date must be provided for perishable inventory.")
            raise ValueError("Manufacturing date must be provided for perishable inventory.")
        # ``perish_queue`` is a heap ordered by manufacturing date, so the
        # oldest batch (the first to expire) is always at position 0. Adding
        # a batch with ``heapq.heappush`` is fast.
        new_batch = (manufacturing_date, amount)
        heapq.heappush(self.perish_queue, new_batch)
        # Only wake ``remove_expired`` when this new batch becomes the oldest
        # one (position 0) — either because the list was empty, or because
        # this batch is older than every batch already there. For ordinary
        # deliveries (newer dates than what is there) the oldest batch does
        # not change, so ``remove_expired`` is left alone.
        if self.perish_queue[0] is new_batch and not self.perish_changed.triggered:
            self.perish_changed.succeed()
    self.update_carry_cost()  # add the holding cost up to now, before the level changes
    self.inventory.put(amount)
    if(not self.node.inventory_raised.triggered):
        self.node.inventory_raised.succeed()  # signal that inventory has been raised
    return amount

get

get(amount: float, site_order: bool = False)

Take units out of the inventory for an order. For perishable goods, the units come from the oldest batch first.

If the inventory level is too low, the order waits until enough units arrive (see _InventoryContainer). Either way, the units are taken off the inventory position (on_hand) straight away.

Parameters:
  • amount (float) –

    units to take.

  • site_order (bool, default: False ) –

    True for a replenishment order from another site, False for a customer order (see waiting_site_orders). Defaults to False.

Returns:
  • tuple –

    (SimPy event, list of (manufacturing_date, quantity)). The event fires when the order is served. The list holds the batches the units came from (perishable goods only; empty otherwise). It is filled in when the units really leave the inventory: at once if the inventory level is high enough, or later, when an order that had to wait is served. So read it after yielding the event.

Source code in src/SupplyNetPy/Components/core.py
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
def get(self, amount: float, site_order: bool = False):
    """
    Take units out of the inventory for an order. For perishable goods, the
    units come from the oldest batch first.

    If the inventory level is too low, the order waits until enough units
    arrive (see `_InventoryContainer`). Either way, the units are taken off
    the inventory position (``on_hand``) straight away.

    Parameters:
        amount (float): units to take.
        site_order (bool): True for a replenishment order from another site,
            False for a customer order (see `waiting_site_orders`).
            Defaults to False.

    Returns:
        tuple: (SimPy event, list of (manufacturing_date, quantity)). The
            event fires when the order is served. The list holds the batches
            the units came from (perishable goods only; empty otherwise). It
            is filled in when the units really leave the inventory: at once
            if the inventory level is high enough, or later, when an order
            that had to wait is served. So read it after yielding the event.
    """
    # The order is labelled, so that among the orders still waiting the
    # node can tell replenishment orders from other sites apart from
    # customer orders (see `waiting_site_orders`).
    if self.inventory.level == float('inf'):
        get_event = self.inventory.get(amount, site_order=site_order)
        return get_event, get_event.batches

    self.update_carry_cost()
    get_event = self.inventory.get(amount, site_order=site_order)
    self.on_hand -= amount  # the inventory position drops at once
    self._signal_inventory_drop()
    return get_event, get_event.batches

remove_expired

remove_expired()

Throw away expired units (perishable goods only).

This runs as a SimPy process for the whole simulation. Instead of checking every time unit, it sleeps exactly until the oldest batch is due to expire. It wakes up early through the perish_changed event when put adds a batch that becomes the new oldest one (or arrives while the list is empty), so a batch that expires sooner is handled without waiting for the old timer. After each wake-up it creates a fresh perish_changed event so an already-used one cannot fire again by mistake — the same pattern as Node.wait_for_drop.

Source code in src/SupplyNetPy/Components/core.py
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
def remove_expired(self):
    """
    Throw away expired units (perishable goods only).

    This runs as a SimPy process for the whole simulation. Instead of
    checking every time unit, it sleeps exactly until the oldest batch is
    due to expire. It wakes up early through the ``perish_changed`` event
    when ``put`` adds a batch that becomes the new oldest one (or arrives
    while the list is empty), so a batch that expires sooner is handled
    without waiting for the old timer. After each wake-up it creates a fresh ``perish_changed`` event so
    an already-used one cannot fire again by mistake — the same pattern as
    ``Node.wait_for_drop``.
    """
    while True:
        if not self.perish_queue:
            # Nothing to expire: wait until ``put`` adds a batch.
            yield self.perish_changed
            self.perish_changed = self.env.event()
            continue
        # The oldest batch sits at position 0 of the heap, so one look is
        # enough to find when the next expiry is due. ``max(0, ...)`` keeps
        # the sleep time from going negative for a batch whose expiry time
        # has already passed (e.g. one given an old manufacturing date).
        next_expiry = self.perish_queue[0][0] + self.shelf_life
        sleep_dt = max(0, next_expiry - self.env.now)
        timer = self.env.timeout(sleep_dt)
        result = yield timer | self.perish_changed
        if self.perish_changed in result:
            # A new oldest batch arrived (or the list was empty and now has
            # a batch). Make a fresh event and start over from the new
            # oldest batch — even if the timer also fired, the next loop will
            # see a sleep time of 0 and remove the expired units right away.
            self.perish_changed = self.env.event()
            continue
        # The timer fired — remove every batch that has now expired. The
        # test is written as ``env.now >= mfg_date + shelf_life`` to match
        # how the expiry time was worked out above. The other form,
        # ``env.now - mfg_date >= shelf_life``, is unreliable with
        # fractional shelf lives because of floating-point rounding (e.g.
        # ``9.2 - 8`` becomes ``1.1999999999999993`` and fails a ``>= 1.2``
        # check), which could make this helper skip the removal and loop
        # forever at the same time.
        #
        # Expired units are thrown away at once, even when an order is
        # waiting. Every batch in the list is still physically in the
        # inventory (a batch leaves the list only when its units leave the
        # inventory), so the units are there to remove. Queueing the
        # removal behind a waiting order would leave the expired units in
        # the inventory, where that order could take them once it is served.
        while self.perish_queue and self.env.now >= self.perish_queue[0][0] + self.shelf_life:
            mfg_date, qty = heapq.heappop(self.perish_queue)  # take the oldest batch off the list
            if qty > 0:
                self.update_carry_cost()
                removed = self.inventory.remove_now(qty)  # and the same units out of the inventory
                self.on_hand -= removed
                self.waste += removed  # expired units thrown away so far
                self.node.logger.info(f"{self.env.now:.4f}: {removed} units expired (Mgf date:{mfg_date}).")
                self._signal_inventory_drop()

update_carry_cost

update_carry_cost()

Add the holding cost since the last update: inventory level × time units since then × holding cost per unit per time unit. It is called before every change of the inventory level, so each stretch of time is charged at the level the inventory really had. The same stretch is also added, without the holding cost, to level_time (inventory level × time) and, if the level was 0, to zero_level_time.

Source code in src/SupplyNetPy/Components/core.py
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
def update_carry_cost(self):
    """
    Add the holding cost since the last update: inventory level × time units
    since then × holding cost per unit per time unit. It is called before
    every change of the inventory level, so each stretch of time is charged
    at the level the inventory really had. The same stretch is also added,
    without the holding cost, to ``level_time`` (inventory level × time) and,
    if the level was 0, to ``zero_level_time``.
    """
    carry_period = self.env.now - self.last_update_t
    self.carry_cost += self.inventory.level * (carry_period) * self.holding_cost  # holding cost for the time since the last update
    if self.inventory.level != float('inf'):
        # The same stretch of time, kept without the holding cost: the
        # inventory level × time (for the average inventory level), and the
        # time spent at level 0. The tolerance ignores rounding dust.
        self.level_time += self.inventory.level * carry_period
        if self.inventory.level <= 1e-9:
            self.zero_level_time += carry_period
    self.last_update_t = self.env.now  # update the last update time

destroy

destroy(amount: float = None, reason: str = 'disruption') -> float

Destroy units now, for a physical loss in a disruption (natural disaster, contamination, theft, etc.).

Removes the units right away, even when orders are waiting, so the caller (usually a Node disruption handler) sees the new level on the very next line. The method does not fire inventory_drop for nodes with a replenishment policy. That event wakes the policy, and a disrupted node can still place replenishment orders, so the node would order while it is disrupted. Instead the node wakes its own policy when it recovers (see Node.disruption), and the policy then orders if its inventory position is too low. It does wake a raw-material supplier (which then mines to refill its inventory, as it does whenever its inventory drops) and a manufacturer (which can make more now that there is free capacity).

For perishable goods the oldest batches are destroyed first, the same as for orders: destroying part of the oldest batch lowers its quantity but keeps its manufacturing date. waste (which counts only expired units) is left unchanged — destroyed units are counted separately, in Statistics.destroyed_qty / destroyed_value, which the caller fills in.

For an infinite supplier (level == float('inf')) this does nothing: it stands for an unlimited supply of raw material, not a real pile that can be destroyed.

Parameters:
  • amount (float, default: None ) –

    Units to destroy. None (default) destroys all units (the whole inventory level). Values larger than the inventory level are reduced to it. Zero or negative values do nothing.

  • reason (str, default: 'disruption' ) –

    Free-form label written to the per-node logger so the trace makes the distinction between expiry, consumption, and destruction visible. Default "disruption".

Returns:
  • float( float ) –

    The units actually destroyed (0 in the cases above where

  • float –

    nothing happens). Callers usually multiply this by the node's

  • float –

    disposal_cost to record destroyed_value on Statistics.

Source code in src/SupplyNetPy/Components/core.py
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
def destroy(self, amount: float = None, reason: str = "disruption") -> float:
    """
    Destroy units now, for a physical loss in a disruption (natural
    disaster, contamination, theft, etc.).

    Removes the units right away, even when orders are waiting,
    so the caller (usually a ``Node`` disruption handler) sees the new
    ``level`` on the very next line. The method does **not** fire
    ``inventory_drop`` for nodes with a replenishment policy. That event
    wakes the policy, and a disrupted node can still place replenishment
    orders, so the node would order while it is disrupted. Instead the node
    wakes its own policy when it recovers (see ``Node.disruption``), and the
    policy then orders if its inventory position is too low. It does
    wake a raw-material supplier (which then mines to refill its inventory,
    as it does whenever its inventory drops) and a manufacturer (which can
    make more now that there is free capacity).

    For perishable goods the oldest batches are destroyed first, the same as
    for orders: destroying part of the oldest batch lowers its quantity but
    keeps its manufacturing date. ``waste`` (which counts only expired
    units) is left unchanged — destroyed units are counted separately, in
    ``Statistics.destroyed_qty`` / ``destroyed_value``, which the caller
    fills in.

    For an infinite supplier (``level == float('inf')``) this does nothing:
    it stands for an unlimited supply of raw material, not a real pile that
    can be destroyed.

    Parameters:
        amount (float, optional): Units to destroy. ``None`` (default)
            destroys all units (the whole inventory level). Values larger
            than the inventory level are reduced to it. Zero or negative
            values do nothing.
        reason (str, optional): Free-form label written to the per-node
            logger so the trace makes the distinction between expiry,
            consumption, and destruction visible. Default ``"disruption"``.

    Returns:
        float: The units actually destroyed (``0`` in the cases above where
        nothing happens). Callers usually multiply this by the node's
        ``disposal_cost`` to record ``destroyed_value`` on ``Statistics``.
    """
    if self.inventory.level == float('inf'):
        return 0  # infinite_supplier — nothing physical to destroy
    available = self.inventory.level
    if amount is None:
        amount = available
    amount = max(0.0, min(amount, available))
    if amount <= 0:
        return 0

    # Add the holding cost up to now, so the destroyed units are not
    # charged holding cost afterwards.
    self.update_carry_cost()

    # Destroy the oldest batches first (perishable goods only), and take the
    # same units out of the inventory at once — even when an order is
    # waiting. An ordinary request would wait in line behind that order,
    # so the units would stay in the inventory and would instead be
    # taken from the next delivery.
    self._pick_oldest_batches(amount)
    amount = self.inventory.remove_now(amount)
    self.on_hand -= amount
    self.node.logger.info(
        f"{self.env.now:.4f}:{self.node.ID}: {amount} units destroyed ({reason}). "
        f"Level={self.inventory.level}, on_hand={self.on_hand}."
    )
    # There is free capacity again. A raw-material supplier whose inventory
    # was full starts mining again, and a manufacturer that stopped because
    # its inventory was full carries on. Other nodes are not told, so their
    # replenishment policies do not order while the node is disrupted; the
    # node wakes its policy when it recovers (see above).
    # Raw-material suppliers have no replenishment policy, so for them the
    # "inventory dropped" signal only restarts mining.
    if isinstance(self.node, Supplier):
        inventory_drop = getattr(self.node, "inventory_drop", None)
        if inventory_drop is not None and not inventory_drop.triggered:
            inventory_drop.succeed()
    wake_production = getattr(self.node, "wake_production", None)
    if wake_production is not None:
        wake_production()
    return amount

Supplier

Supplier(env: Environment, ID: str, name: str, node_type: str = 'supplier', capacity: float = 0.0, initial_level: float = 0.0, inventory_holding_cost: float = 0.0, raw_material: RawMaterial = None, **kwargs)

Bases: Node

The Supplier class represents a raw-material supplier: a site at the start of the network that mines one raw material and sells it to manufacturers. It has no supplier of its own. SupplyNetPy does not model perishable raw materials yet, so its inventory is always non-perishable.

A raw-material supplier with finite inventory (node_type="supplier") mines RawMaterial.extraction_quantity units every RawMaterial.extraction_time time units, as long as its inventory is not full. When its inventory is full it stops, and starts mining again as soon as its inventory drops. An infinite supplier (node_type="infinite_supplier") never runs out, so it does not mine at all.

Parameters:
  • env (Environment) –

    simulation environment

  • ID (str) –

    unique identifier for the raw-material supplier

  • name (str) –

    name of the raw-material supplier

  • node_type (str, default: 'supplier' ) –

    "supplier" (finite inventory, mines raw material) or "infinite_supplier" (never runs out)

  • capacity (float, default: 0.0 ) –

    the largest inventory level it can hold (ignored for an infinite supplier)

  • initial_level (float, default: 0.0 ) –

    inventory level at the start (ignored for an infinite supplier)

  • inventory_holding_cost (float, default: 0.0 ) –

    holding cost per unit per time unit

  • raw_material (RawMaterial, default: None ) –

    the raw material it mines and sells

  • **kwargs –

    any additional keyword arguments for the Node class and logger. Also accepts periodic_stats (bool, default False: if True, its statistics are updated every stats_period time units) and stats_period (float, default 1).

Attributes:
  • _info_keys (list) –

    list of keys to include in the info dictionary.

  • raw_material (RawMaterial) –

    the raw material it mines and sells

  • sell_price (float) –

    sell price per unit (the raw material's cost)

  • inventory (Inventory) –

    its inventory (infinite for an infinite supplier)

  • inventory_drop (Event) –

    fires when its inventory drops (finite inventory only)

  • inventory_raised (Event) –

    fires when units are added to its inventory (finite inventory only)

  • stats (Statistics) –

    its statistics. Besides the usual ones, total_raw_materials_mined (units mined so far) and total_material_cost (mining cost so far, part of node_cost).

Methods:

Name Description
behavior

Mines raw material whenever the inventory is not full (finite inventory only).

Initialize the supplier object.

Parameters:
  • env (Environment) –

    simulation environment

  • ID (str) –

    unique identifier for the raw-material supplier

  • name (str) –

    name of the raw-material supplier

  • node_type (str, default: 'supplier' ) –

    "supplier" (finite inventory, mines raw material) or "infinite_supplier" (never runs out)

  • capacity (float, default: 0.0 ) –

    the largest inventory level it can hold (ignored for an infinite supplier)

  • initial_level (float, default: 0.0 ) –

    inventory level at the start (ignored for an infinite supplier)

  • inventory_holding_cost (float, default: 0.0 ) –

    holding cost per unit per time unit

  • raw_material (RawMaterial, default: None ) –

    the raw material it mines and sells

  • **kwargs –

    any additional keyword arguments for the Node class and logger. Also accepts periodic_stats (bool, default False: if True, its statistics are updated every stats_period time units) and stats_period (float, default 1).

Attributes:
  • _info_keys (list) –

    list of keys to include in the info dictionary.

  • raw_material (RawMaterial) –

    the raw material it mines and sells

  • sell_price (float) –

    sell price per unit (the raw material's cost)

  • inventory (Inventory) –

    its inventory (infinite for an infinite supplier)

  • inventory_drop (Event) –

    fires when its inventory drops (finite inventory only)

  • inventory_raised (Event) –

    fires when units are added to its inventory (finite inventory only)

  • stats (Statistics) –

    its statistics. Besides the usual ones, total_raw_materials_mined (units mined so far) and total_material_cost (mining cost so far, part of node_cost).

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
4015
4016
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
def __init__(self,
             env: simpy.Environment,
             ID: str,
             name: str,
             node_type: str = "supplier",
             capacity: float = 0.0,
             initial_level: float = 0.0,
             inventory_holding_cost:float = 0.0,
             raw_material: RawMaterial = None,
             **kwargs) -> None:
    """
    Initialize the supplier object.

    Parameters:
        env (simpy.Environment): simulation environment
        ID (str): unique identifier for the raw-material supplier
        name (str): name of the raw-material supplier
        node_type (str): "supplier" (finite inventory, mines raw material) or "infinite_supplier" (never runs out)
        capacity (float): the largest inventory level it can hold (ignored for an infinite supplier)
        initial_level (float): inventory level at the start (ignored for an infinite supplier)
        inventory_holding_cost (float): holding cost per unit per time unit
        raw_material (RawMaterial): the raw material it mines and sells
        **kwargs: any additional keyword arguments for the Node class and logger. Also accepts
            ``periodic_stats`` (bool, default False: if True, its statistics are updated every
            ``stats_period`` time units) and ``stats_period`` (float, default 1).

    Attributes:
        _info_keys (list): list of keys to include in the info dictionary.
        raw_material (RawMaterial): the raw material it mines and sells
        sell_price (float): sell price per unit (the raw material's ``cost``)
        inventory (Inventory): its inventory (infinite for an infinite supplier)
        inventory_drop (simpy.Event): fires when its inventory drops (finite inventory only)
        inventory_raised (simpy.Event): fires when units are added to its inventory (finite inventory only)
        stats (Statistics): its statistics. Besides the usual ones, ``total_raw_materials_mined`` (units
            mined so far) and ``total_material_cost`` (mining cost so far, part of ``node_cost``).

    Returns:
        None
    """
    # Take the statistics options out of kwargs before passing the rest on
    # to Node. They belong to Statistics, but are accepted here so users can
    # ask for regular statistics updates without subclassing. By default a
    # raw-material supplier's statistics are not updated regularly.
    periodic_stats = kwargs.pop('periodic_stats', False)
    stats_period = kwargs.pop('stats_period', 1)
    if periodic_stats:
        validate_positive(name="stats_period", value=stats_period)
    super().__init__(env=env,ID=ID,name=name,node_type=node_type,**kwargs)
    # SupplyNetPy does not model perishable raw materials yet (they would
    # need a manufacturing date for every unit mined, and a shelf life), so
    # the inventory of a raw-material supplier is always non-perishable.
    # Mining adds units without a manufacturing date, so a perishable
    # inventory would fail at the first mining, long after the mistake was
    # made. Catch it here instead, under both names: ``inventory_type`` (the
    # name used by the other sites) and ``inv_type`` (the name Inventory
    # itself takes).
    for key in ("inventory_type", "inv_type"):
        inventory_type = kwargs.pop(key, "non-perishable")
        if inventory_type != "non-perishable":
            message = (f"{self.ID}: {key}={inventory_type!r} is not possible for a raw-material supplier. "
                       f"SupplyNetPy does not model perishable raw materials yet, so its inventory is "
                       f"always non-perishable.")
            self.logger.error(message)
            raise ValueError(message)
    self.raw_material = raw_material # the raw material it mines and sells
    self.sell_price = 0
    if(self.raw_material):
        self.sell_price = self.raw_material.cost # sell price per unit
    if(self.node_type!="infinite_supplier"):
        inventory_kwargs = {k: v for k, v in kwargs.items() if k not in _LOGGER_KWARGS and k not in _NODE_KWARGS}
        self.inventory = Inventory(env=self.env, capacity=capacity, initial_level=initial_level, node=self, holding_cost=inventory_holding_cost, replenishment_policy=None, **inventory_kwargs)
        self.inventory_drop = self.env.event()  # fires when the inventory drops
        self.inventory_raised = self.env.event() # fires when units are added
        if(self.raw_material):
            self.env.process(self.behavior()) # start mining
        else:
            self.logger.error(f"{self.ID}:Raw material not provided for this supplier. Recreate it with a raw material.")
            raise ValueError("Raw material not provided.")
    else:
        inventory_kwargs = {k: v for k, v in kwargs.items() if k not in _LOGGER_KWARGS and k not in _NODE_KWARGS}
        self.inventory = Inventory(env=self.env, capacity=float('inf'), initial_level=float('inf'), node=self, holding_cost=inventory_holding_cost, replenishment_policy=None, **inventory_kwargs)

    self.stats = Statistics(self, periodic_update=periodic_stats, period=stats_period)
    setattr(self.stats,"total_raw_materials_mined",0)
    setattr(self.stats,"total_material_cost",0)
    self.stats._stats_keys.extend(["total_raw_materials_mined", "total_material_cost"])
    self.stats._cost_components.append("total_material_cost")

behavior

behavior()

Mine raw material for the whole simulation: extraction_quantity units every extraction_time time units, as long as the inventory is not full. When it is full, wait until the inventory drops, then mine again. A raw-material supplier mines a single raw material.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
def behavior(self):
    """
    Mine raw material for the whole simulation: `extraction_quantity` units every `extraction_time` time
    units, as long as the inventory is not full. When it is full, wait until the inventory drops, then mine
    again. A raw-material supplier mines a single raw material.

    Parameters:
        None

    Attributes:
        None

    Returns:
        None
    """
    while True:
        if(self.inventory.level < self.inventory.capacity): # check if the inventory is not full
            mined_quantity = self.raw_material.extraction_quantity
            if((self.inventory.level+self.raw_material.extraction_quantity)>self.inventory.capacity): # mine only what fits within the capacity
                mined_quantity = self.inventory.capacity - self.inventory.level
            self.inventory.put(mined_quantity)
            self.stats.update_stats(total_raw_materials_mined=mined_quantity, total_material_cost=mined_quantity*self.raw_material.mining_cost)
            self.logger.info(f"{self.env.now:.4f}:{self.ID}:Raw material mined/extracted. Inventory level:{self.inventory.level}")
            yield self.env.timeout(self.raw_material.extraction_time)
        else:
            # The inventory is full. Wait until it drops (a buyer takes
            # units, and Inventory.get signals inventory_drop), then mine
            # again, instead of checking the inventory every time unit.
            yield from self.wait_for_drop()
        self.logger.info(f"{self.env.now:.4f}:{self.ID}: Inventory level:{self.inventory.level}") # log on each event-driven wake-up

InventoryNode

InventoryNode(env: Environment, ID: str, name: str, node_type: str, capacity: float, initial_level: float, inventory_holding_cost: float, replenishment_policy: InventoryReplenishment, policy_param: dict, product_sell_price: float, product_buy_price: float, inventory_type: str = 'non-perishable', shelf_life: float = 0.0, manufacture_date: Callable = None, product: Product = None, supplier_selection_policy: SupplierSelectionPolicy = SelectFirst, supplier_selection_mode: str = None, supplier_selection_param: dict = None, **kwargs)

Bases: Node

The InventoryNode class represents a site that buys a product from its suppliers, holds it, and sells it on: a distributor, warehouse or retailer. It sells to buyers (other sites) and to customers.

It places replenishment orders according to its replenishment policy, and when it has more than one supplier, it chooses one with its supplier-selection policy. Its inventory can be perishable or non-perishable.

Parameters:
  • env (Environment) –

    Simulation environment.

  • ID (str) –

    Unique identifier for the node.

  • name (str) –

    Name of the node.

  • node_type (str) –

    "distributor", "warehouse", "retailer", "store" or "shop" (they behave the same).

  • capacity (float) –

    The largest inventory level the node can hold.

  • initial_level (float) –

    Inventory level at the start.

  • inventory_holding_cost (float) –

    Holding cost per unit per time unit.

  • replenishment_policy (InventoryReplenishment) –

    The replenishment policy class (for example scm.SSReplenishment), not an object: the node creates the policy itself. None for no replenishment policy.

  • policy_param (dict) –

    Parameters for the replenishment policy (for example {'s': 20, 'S': 60}).

  • product_sell_price (float) –

    Sell price per unit.

  • product_buy_price (float) –

    Buy price per unit.

  • inventory_type (str, default: 'non-perishable' ) –

    Type of inventory ("non-perishable" or "perishable").

  • shelf_life (float, default: 0.0 ) –

    Shelf life of the product, in time units (perishable goods only).

  • manufacture_date (callable, default: None ) –

    Function giving the manufacturing date of units that arrive without one (perishable goods only). It is called with the arrival time; if it is not given, the arrival time is used.

  • product (Product, default: None ) –

    The product this node sells (optional). The node keeps its own copy, with its own prices.

  • supplier_selection_policy (SupplierSelectionPolicy, default: SelectFirst ) –

    The supplier-selection policy class (default SelectFirst).

  • supplier_selection_mode (str, default: None ) –

    "fixed" or "dynamic"; see SupplierSelectionPolicy. If it is not given, the policy's own default is used ("fixed" for the built in policies except SelectProbabilistic).

  • supplier_selection_param (dict, default: None ) –

    Settings for the supplier-selection policy (optional), for example the probabilities of SelectProbabilistic.

  • **kwargs –

    Additional keyword arguments for the Node class and logger. Also accepts periodic_stats (bool, default False: if True, its statistics are updated every stats_period time units) and stats_period (float, default 1).

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • replenishment_policy (InventoryReplenishment) –

    The replenishment policy object the node created (or None).

  • inventory (Inventory) –

    The node's inventory.

  • inventory_drop (Event) –

    Fires when the inventory drops.

  • inventory_raised (Event) –

    Fires when units are added to the inventory.

  • manufacture_date (callable) –

    Function giving the manufacturing date of units that arrive without one.

  • sell_price (float) –

    Sell price per unit.

  • buy_price (float) –

    Buy price per unit.

  • product (Product) –

    The node's own copy of the product (if one was given).

  • suppliers (list) –

    The links to this node's suppliers.

  • pending_orders (int) –

    Number of this node's replenishment orders placed but not yet arrived (waiting at a supplier or in transit).

  • selection_policy (SupplierSelectionPolicy) –

    The supplier-selection policy object.

  • stats (Statistics) –

    The node's statistics.

Methods:

Name Description
process_order

Places a replenishment order with one supplier and adds the units when they arrive.

Initialize the inventory node object.

Parameters:
  • env (Environment) –

    Simulation environment.

  • ID (str) –

    Unique identifier for the node.

  • name (str) –

    Name of the node.

  • node_type (str) –

    "distributor", "warehouse", "retailer", "store" or "shop" (they behave the same).

  • capacity (float) –

    The largest inventory level the node can hold.

  • initial_level (float) –

    Inventory level at the start.

  • inventory_holding_cost (float) –

    Holding cost per unit per time unit.

  • replenishment_policy (InventoryReplenishment) –

    The replenishment policy class (for example scm.SSReplenishment), not an object: the node creates the policy itself. None for no replenishment policy.

  • policy_param (dict) –

    Parameters for the replenishment policy (for example {'s': 20, 'S': 60}).

  • product_sell_price (float) –

    Sell price per unit.

  • product_buy_price (float) –

    Buy price per unit.

  • inventory_type (str, default: 'non-perishable' ) –

    Type of inventory ("non-perishable" or "perishable").

  • shelf_life (float, default: 0.0 ) –

    Shelf life of the product, in time units (perishable goods only).

  • manufacture_date (callable, default: None ) –

    Function giving the manufacturing date of units that arrive without one (perishable goods only). It is called with the arrival time; if it is not given, the arrival time is used.

  • product (Product, default: None ) –

    The product this node sells (optional). The node keeps its own copy, with its own prices.

  • supplier_selection_policy (SupplierSelectionPolicy, default: SelectFirst ) –

    The supplier-selection policy class (default SelectFirst).

  • supplier_selection_mode (str, default: None ) –

    "fixed" or "dynamic"; see SupplierSelectionPolicy. If it is not given, the policy's own default is used ("fixed" for the built in policies except SelectProbabilistic).

  • supplier_selection_param (dict, default: None ) –

    Settings for the supplier-selection policy (optional), for example the probabilities of SelectProbabilistic.

  • **kwargs –

    Additional keyword arguments for the Node class and logger. Also accepts periodic_stats (bool, default False: if True, its statistics are updated every stats_period time units) and stats_period (float, default 1).

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • replenishment_policy (InventoryReplenishment) –

    The replenishment policy object the node created (or None).

  • inventory (Inventory) –

    The node's inventory.

  • inventory_drop (Event) –

    Fires when the inventory drops.

  • inventory_raised (Event) –

    Fires when units are added to the inventory.

  • manufacture_date (callable) –

    Function giving the manufacturing date of units that arrive without one.

  • sell_price (float) –

    Sell price per unit.

  • buy_price (float) –

    Buy price per unit.

  • product (Product) –

    The node's own copy of the product (if one was given).

  • suppliers (list) –

    The links to this node's suppliers.

  • pending_orders (int) –

    Number of this node's replenishment orders placed but not yet arrived (waiting at a supplier or in transit).

  • selection_policy (SupplierSelectionPolicy) –

    The supplier-selection policy object.

  • stats (Statistics) –

    The node's statistics.

Returns:
  • None –

    None

Behavior

The node holds units of the product so that buyers (other sites) and customers can order them. It places replenishment orders with its suppliers according to its replenishment policy, and chooses among its suppliers with its supplier-selection policy. The buy and sell prices are set here; the node is expected to sell at a higher price than it buys, but that is up to the user.

Source code in src/SupplyNetPy/Components/core.py
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
def __init__(self,
             env: simpy.Environment,
             ID: str,
             name: str,
             node_type: str,
             capacity: float,
             initial_level: float,
             inventory_holding_cost:float,
             replenishment_policy:InventoryReplenishment,
             policy_param: dict,
             product_sell_price: float,
             product_buy_price: float,
             inventory_type:str = "non-perishable", 
             shelf_life:float = 0.0,
             manufacture_date: Callable = None,
             product:Product = None,
             supplier_selection_policy: SupplierSelectionPolicy = SelectFirst,
             supplier_selection_mode: str = None,
             supplier_selection_param: dict = None,
             **kwargs) -> None:
    """
    Initialize the inventory node object.

    Parameters:
        env (simpy.Environment): Simulation environment.
        ID (str): Unique identifier for the node.
        name (str): Name of the node.
        node_type (str): "distributor", "warehouse", "retailer", "store" or "shop" (they behave the same).
        capacity (float): The largest inventory level the node can hold.
        initial_level (float): Inventory level at the start.
        inventory_holding_cost (float): Holding cost per unit per time unit.
        replenishment_policy (InventoryReplenishment): The replenishment policy **class** (for example
            ``scm.SSReplenishment``), not an object: the node creates the policy itself. None for no
            replenishment policy.
        policy_param (dict): Parameters for the replenishment policy (for example ``{'s': 20, 'S': 60}``).
        product_sell_price (float): Sell price per unit.
        product_buy_price (float): Buy price per unit.
        inventory_type (str): Type of inventory ("non-perishable" or "perishable").
        shelf_life (float): Shelf life of the product, in time units (perishable goods only).
        manufacture_date (callable): Function giving the manufacturing date of units that arrive without one
            (perishable goods only). It is called with the arrival time; if it is not given, the arrival time
            is used.
        product (Product): The product this node sells (optional). The node keeps its own copy, with its own
            prices.
        supplier_selection_policy (SupplierSelectionPolicy): The supplier-selection policy class (default
            ``SelectFirst``).
        supplier_selection_mode (str): "fixed" or "dynamic"; see `SupplierSelectionPolicy`. If it is not given,
            the policy's own default is used ("fixed" for the built in policies except ``SelectProbabilistic``).
        supplier_selection_param (dict): Settings for the supplier-selection policy (optional), for example
            the probabilities of ``SelectProbabilistic``.
        **kwargs: Additional keyword arguments for the Node class and logger. Also accepts
            ``periodic_stats`` (bool, default False: if True, its statistics are updated every
            ``stats_period`` time units) and ``stats_period`` (float, default 1).

    Attributes:
        _info_keys (list): List of keys to include in the info dictionary.
        replenishment_policy (InventoryReplenishment): The replenishment policy object the node created (or None).
        inventory (Inventory): The node's inventory.
        inventory_drop (simpy.Event): Fires when the inventory drops.
        inventory_raised (simpy.Event): Fires when units are added to the inventory.
        manufacture_date (callable): Function giving the manufacturing date of units that arrive without one.
        sell_price (float): Sell price per unit.
        buy_price (float): Buy price per unit.
        product (Product): The node's own copy of the product (if one was given).
        suppliers (list): The links to this node's suppliers.
        pending_orders (int): Number of this node's replenishment orders placed but not yet arrived (waiting at a
            supplier or in transit).
        selection_policy (SupplierSelectionPolicy): The supplier-selection policy object.
        stats (Statistics): The node's statistics.

    Returns:
        None

    Behavior:
        The node holds units of the product so that buyers (other sites) and customers can order them. It
        places replenishment orders with its suppliers according to its replenishment policy, and chooses
        among its suppliers with its supplier-selection policy. The buy and sell prices are set here; the
        node is expected to sell at a higher price than it buys, but that is up to the user.
    """
    # Take the statistics options out of kwargs before passing the rest on
    # to Node. They belong to Statistics, but are accepted here so users can
    # ask for regular statistics updates.
    periodic_stats = kwargs.pop('periodic_stats', False)
    stats_period = kwargs.pop('stats_period', 1)
    if periodic_stats:
        validate_positive(name="stats_period", value=stats_period)
    super().__init__(env=env,ID=ID,name=name,node_type=node_type,**kwargs)
    validate_non_negative("Product Sell Price", product_sell_price)
    validate_non_negative("Product Buy Price", product_buy_price)
    self.replenishment_policy = None
    if(replenishment_policy):
        self.replenishment_policy = replenishment_policy(env = self.env, node = self, params = policy_param)
        self.env.process(self.replenishment_policy.run())

    inventory_kwargs = {k: v for k, v in kwargs.items() if k not in _LOGGER_KWARGS and k not in _NODE_KWARGS}
    self.inventory = Inventory(env=self.env, capacity=capacity, initial_level=initial_level, node=self,
                               inv_type=inventory_type, holding_cost=inventory_holding_cost,
                               replenishment_policy=self.replenishment_policy, shelf_life=shelf_life, **inventory_kwargs)
    self.inventory_drop = self.env.event()  # fires when the inventory drops
    self.inventory_raised = self.env.event() # fires when units are added
    self.manufacture_date = manufacture_date
    self.sell_price = product_sell_price # sell price per unit
    self.buy_price = product_buy_price # buy price per unit
    if product is not None:
        # Make a full copy so each InventoryNode that "sells" the same
        # Product can set its own sell_price / buy_price without changing the
        # original (or any other node's copy). Without the copy, two nodes
        # sharing one Product would overwrite each other's prices in the
        # assignments below.
        self.product = copy.deepcopy(product)
        self.product.sell_price = product_sell_price
        self.product.buy_price = product_buy_price # the node's own buy price
    # self.suppliers is already set to [] in Node.__init__; the links to
    # this node's suppliers register themselves via Node.add_supplier_link.
    self.pending_orders = 0 # replenishment orders placed but not yet arrived
    self.selection_policy = self._make_selection_policy(supplier_selection_policy, supplier_selection_mode,
                                                        supplier_selection_param)
    self.stats = Statistics(self, periodic_update=periodic_stats, period=stats_period) # create a statistics object for the inventory node

process_order

process_order(supplier, reorder_quantity)

Place a replenishment order with one supplier, and add the units to the inventory when they arrive. This runs as a SimPy process (place_order starts it).

place_order has already checked that some supplier can take the order. This method checks the chosen supplier again when it starts, because that is when the order is really placed. If the supplier's site or the link to it is disrupted by then, the order is not placed.

Parameters:
  • supplier (Link) –

    The link to the supplier, not the supplier itself (the supplier is supplier.source).

  • reorder_quantity (float) –

    Units to order. Reduced if they would not fit within the capacity.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
4316
4317
4318
4319
4320
4321
4322
4323
4324
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
4343
4344
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
4356
4357
4358
4359
4360
4361
4362
4363
4364
4365
4366
4367
4368
4369
4370
4371
4372
4373
4374
4375
4376
4377
4378
4379
4380
4381
4382
4383
4384
4385
4386
4387
4388
4389
4390
4391
4392
4393
4394
4395
4396
4397
4398
4399
4400
4401
4402
4403
4404
4405
4406
4407
4408
4409
4410
4411
4412
4413
4414
4415
4416
4417
4418
4419
4420
4421
4422
4423
4424
4425
4426
4427
4428
4429
4430
4431
4432
4433
4434
4435
4436
4437
4438
4439
4440
4441
4442
4443
4444
4445
4446
4447
4448
4449
4450
4451
4452
4453
4454
4455
4456
4457
4458
4459
4460
4461
4462
4463
4464
4465
4466
4467
4468
4469
4470
4471
4472
4473
4474
4475
4476
4477
4478
4479
4480
4481
4482
4483
4484
4485
4486
4487
4488
4489
4490
4491
4492
4493
4494
4495
4496
4497
4498
4499
def process_order(self, supplier, reorder_quantity):
    """
    Place a replenishment order with one supplier, and add the units to the
    inventory when they arrive. This runs as a SimPy process (``place_order``
    starts it).

    ``place_order`` has already checked that some supplier can take the
    order. This method checks the chosen supplier again when it starts,
    because that is when the order is really placed. If the supplier's
    site or the link to it is disrupted by then, the order is not placed.

    Parameters:
        supplier (Link): The **link** to the supplier, not the supplier
            itself (the supplier is ``supplier.source``).
        reorder_quantity (float): Units to order. Reduced if they would not
            fit within the capacity.

    Attributes:
        None

    Returns:
        None
    """
    # Check the order against the capacity using the inventory position
    # (inventory level plus units in transit, minus units owed to other
    # sites and to waiting customers). Units owed to customers leave the
    # inventory as soon as the next delivery arrives, so they do not take
    # up space and must not count against the capacity.
    gross = self.position()
    if(gross + reorder_quantity > self.inventory.capacity): # check if the inventory can accommodate the reordered quantity
            reorder_quantity = self.inventory.capacity - gross # if not, adjust reorder quantity to order only what can fit

    if reorder_quantity <= 0:
        return  # no need to place an order if reorder quantity is zero

    # The order is really placed here, not in place_order. place_order only
    # starts this process, and SimPy runs it a moment later at the same
    # time. Processes queued before it run first, and at time 0 these
    # include the start of every disruption process, whose first random
    # draw can disrupt the chosen supplier or link. So check again now. A
    # blocked order records nothing (no shortage, backorder or pending
    # order). The replenishment policy is woken when the supplier or the
    # link recovers (see Node.wake_replenishment).
    if not supplier.can_take_order():
        self.logger.info(f"{self.env.now:.4f}:{self.ID}:Supplier:{supplier.source.name} or link:{supplier.ID} is disrupted. Order not placed.")
        self.stats.update_stats(blocked=[1, reorder_quantity])
        supplier.stats.update_stats(blocked=[1, reorder_quantity])
        return

    self.pending_orders += 1  # count this order as placed; taken off again when it arrives

    # The supplier is short if its inventory level cannot fill the whole
    # order right now.
    if supplier.source.inventory.level < reorder_quantity:
        shortage = reorder_quantity - supplier.source.inventory.level
        supplier.source.stats.update_stats(shortage=[1,shortage])
        supplier.stats.update_stats(shortage=[1,shortage])

    # Each of these is recorded at the buyer, at the supplier, and on the
    # link between them (see LinkStatistics).
    self.stats.update_stats(demand_placed=[1,reorder_quantity],transportation_cost=supplier.cost)
    supplier.source.stats.update_stats(demand_received=[1,reorder_quantity])
    supplier.stats.update_stats(demand_placed=[1,reorder_quantity],transportation_cost=supplier.cost)
    supplier.stats.record_order(reorder_quantity)

    self.logger.info(f"{self.env.now:.4f}:{self.ID}:Replenishing inventory from supplier:{supplier.source.name}, order placed for {reorder_quantity} units.")
    self._warn_if_supplier_cannot_hold(supplier, reorder_quantity)
    event, man_date_ls = supplier.source.inventory.get(reorder_quantity, site_order=True)
    self.inventory.on_hand += reorder_quantity

    # The supplier's backorders show what it owes *right now*: an order
    # is counted while it waits for units and removed once it is served.
    # The inventory hands out an order on the spot if it can, so an
    # order that has not been served at this point is really waiting —
    # either the inventory level is too low, or an earlier order is
    # ahead of it in line. (The whole order counts, because the
    # inventory never sends part of an order: it waits until it can
    # send all of it.)
    order_waits = not event.triggered
    ordered_at = self.env.now
    if order_waits:
        supplier.source.stats.update_stats(backorder=[1,reorder_quantity], waited=[1,reorder_quantity])
        supplier.stats.update_stats(backorder=[1,reorder_quantity], waited=[1,reorder_quantity])
    yield event
    waited_for = self.env.now - ordered_at  # 0 if the order was served at once
    if order_waits:
        # Served at last: the supplier no longer owes this order, and it
        # adds how long the order waited.
        supplier.source.stats.update_stats(backorder=[-1,-reorder_quantity], waiting_time=waited_for)
        supplier.stats.update_stats(backorder=[-1,-reorder_quantity])

    self.logger.info(f"{self.env.now:.4f}:{self.ID}:shipment in transit from supplier:{supplier.source.name}.") # log the shipment
    lead_time = supplier.lead_time() # draw the lead time from the link
    validate_non_negative(name="lead_time", value=lead_time) # check if lead_time is non-negative
    yield self.env.timeout(lead_time) # lead time for the order

    # Add the delivered units to the inventory, one part that fits at a time.
    #
    # Why loop instead of a single put()? A replenishment order can be
    # bigger than the free capacity. This is normal, not a mistake: when
    # the node owes units to waiting customers (backorders), the (s, S)
    # policy orders enough to BOTH refill the inventory AND cover those
    # backorders. That combined amount can be more than the inventory
    # can hold at once, because the backordered units are meant to pass
    # straight through to customers, not to stay in the inventory. A
    # single put() would simply drop whatever did not fit — and the node
    # would still have paid for those dropped units.
    #
    # So we add what fits right now, then pause for one instant of
    # simulation time. `env.timeout(0)` does not advance the clock; it just
    # lets other events scheduled for this same instant run. During that
    # pause, the units we just added are handed to the waiting customers,
    # which frees capacity again. We then add the next part, and repeat
    # until the whole order has been added or handed out.
    #
    # Safety stop: if a pause frees no capacity at all, the inventory is
    # genuinely full and the leftover cannot be stored. We stop there (so
    # the loop can never run forever inside a single instant) and drop the
    # leftover. `on_hand` (the inventory position, apart from units owed
    # to waiting customers) was increased by the full order amount when
    # the order was placed, so we subtract the undeliverable leftover
    # back out to keep it correct.
    if man_date_ls:                                  # the supplier sent batches with their manufacturing dates (perishable goods)
        batches = [(ele[0], ele[1]) for ele in man_date_ls]
    elif self.inventory.inv_type == "perishable":    # perishable, but we set the manufacturing date ourselves
        mfg = self.manufacture_date(self.env.now) if self.manufacture_date else self.env.now
        batches = [(mfg, reorder_quantity)]
    else:                                            # ordinary (non-perishable) goods
        batches = [(None, reorder_quantity)]

    accepted = 0  # how many units actually made it into the inventory
    for mfg_date, qty in batches:
        # `remaining > 1e-9` rather than `> 0`: a tiny tolerance so
        # floating-point rounding dust never triggers one extra loop.
        remaining = qty
        while remaining > 1e-9:
            # put() returns how many units it actually accepted — it
            # never goes over the capacity, so this can be less than asked.
            if mfg_date is None:
                put_now = self.inventory.put(remaining)
            else:
                put_now = self.inventory.put(remaining, mfg_date)
            accepted += put_now
            remaining -= put_now
            if remaining <= 1e-9:
                break  # the whole part is in the inventory; done
            # The inventory is full. Pause one instant so the units we just
            # added can be handed to waiting customers (freeing capacity), then retry.
            prev_level = self.inventory.level
            yield self.env.timeout(0)
            if self.inventory.level >= prev_level:
                # The pause freed nothing, so nothing more can be stored.
                # Drop the leftover and correct the inventory position.
                # Warn the user: the node paid for these units but has
                # nowhere to put them and no waiting customer to hand
                # them to. This usually means the node's storage
                # capacity is too small for the order sizes flowing
                # through it.
                self.logger.warning(
                    f"{self.env.now:.4f}:{self.ID}:Dropped {remaining} units from the "
                    f"delivered shipment — the shelf is full and no waiting customer "
                    f"could take them. The node's storage capacity "
                    f"({self.inventory.capacity}) may be too small for this order size."
                )
                self.stats.update_stats(dropped_qty=remaining)
                self.inventory.on_hand -= remaining
                remaining = 0
                break

    self.logger.info(f"{self.env.now:.4f}:{self.ID}:Inventory replenished. reorder_quantity={reorder_quantity}, accepted={accepted}, Inventory levels:{self.inventory.level}")

    # Record what actually arrived (`accepted`), not what was ordered.
    # In the normal case these are equal. They differ only when part of an
    # order could not be stored (the "safety stop" above), and in that case
    # the node should be billed for, and credited with, only the units it
    # truly received.
    self.stats.update_stats(fulfillment_received=[1,accepted],inventory_spend_cost=accepted*self.buy_price)
    supplier.source.stats.update_stats(demand_fulfilled=[1,accepted])
    # The link adds the order's waiting time and lead time now, on arrival,
    # with fulfillment_received, so that their averages divide by a count
    # recorded at the same moment (this matters after a reset).
    supplier.stats.update_stats(fulfillment_received=[1,accepted], inventory_spend_cost=accepted*self.buy_price,
                                waiting_time=waited_for, lead_time=lead_time)
    self.pending_orders -= 1

Manufacturer

Manufacturer(env: Environment, ID: str, name: str, capacity: float, initial_level: float, inventory_holding_cost: float, product_sell_price: float, replenishment_policy: InventoryReplenishment, policy_param: dict, product: Product = None, inventory_type: str = 'non-perishable', shelf_life: float = 0.0, supplier_selection_policy: SupplierSelectionPolicy = SelectFirst, supplier_selection_mode: str = None, supplier_selection_param: dict = None, **kwargs)

Bases: Node

The Manufacturer class represents a manufacturer: a site that turns raw materials into a product and sells the product to buyers (other sites) and customers. It keeps its raw materials apart from its inventory of the product. Its replenishment policy watches the product's inventory position and decides when to plan production; the manufacturer then orders the raw materials needed from its raw-material suppliers, one for each raw material.

Parameters:
  • env (Environment) –

    Simulation environment.

  • ID (str) –

    Unique identifier for the manufacturer.

  • name (str) –

    Name of the manufacturer.

  • capacity (float) –

    The largest inventory level of the product it can hold.

  • initial_level (float) –

    Inventory level of the product at the start.

  • inventory_holding_cost (float) –

    Holding cost per unit of the product per time unit.

  • product_sell_price (float) –

    Sell price per unit of the product.

  • replenishment_policy (InventoryReplenishment) –

    The replenishment policy class (for example scm.SSReplenishment), not an object: the manufacturer creates the policy itself. It decides when to plan production. None for no replenishment policy.

  • policy_param (dict) –

    Parameters for the replenishment policy (for example {'s': 20, 'S': 60}).

  • product (Product, default: None ) –

    The product it makes (required). If the product's buy price is not set, it is set to the cost of making one unit: the manufacturing cost plus the cost of its raw materials.

  • inventory_type (str, default: 'non-perishable' ) –

    Type of inventory ("non-perishable" or "perishable").

  • shelf_life (float, default: 0.0 ) –

    Shelf life of the product, in time units (perishable goods only).

  • supplier_selection_policy (SupplierSelectionPolicy, default: SelectFirst ) –

    The supplier-selection policy class (default SelectFirst).

  • supplier_selection_mode (str, default: None ) –

    "fixed" or "dynamic"; see SupplierSelectionPolicy. If it is not given, the policy's own default is used ("fixed" for the built in policies except SelectProbabilistic).

  • supplier_selection_param (dict, default: None ) –

    Settings for the supplier-selection policy (optional), for example the probabilities of SelectProbabilistic.

  • **kwargs –

    Additional keyword arguments for the Node class and logger. Also accepts periodic_stats (bool, default False: if True, its statistics are updated every stats_period time units) and stats_period (float, default 1).

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • replenishment_policy (InventoryReplenishment) –

    The replenishment policy object the manufacturer created (or None).

  • inventory (Inventory) –

    Its inventory of the product.

  • inventory_drop (Event) –

    Fires when its inventory drops.

  • inventory_raised (Event) –

    Fires when units are added to its inventory.

  • product (Product) –

    The product it makes.

  • suppliers (list) –

    The links to its raw-material suppliers.

  • sell_price (float) –

    Sell price per unit of the product.

  • production_cycle (bool) –

    True while a production batch is being made.

  • raw_inventory_counts (dict) –

    Units of each raw material it holds, by raw material ID. (Raw materials are counted here, not in inventory.)

  • pending_orders (int) –

    Number of its raw-material replenishment orders placed but not yet arrived.

  • selection_policy (SupplierSelectionPolicy) –

    The supplier-selection policy object.

  • stats (Statistics) –

    Its statistics. Besides the usual ones, total_products_manufactured (units made so far) and total_manufacturing_cost (manufacturing cost so far, part of node_cost).

  • production_wake (Event) –

    Event that wakes the production loop when it is waiting (see wake_production).

Methods:

Name Description
manufacture_product

Makes one production batch, using up raw materials and adding the units made to the inventory.

behavior

Production loop that keeps making production batches while there are planned units left to make.

wake_production

Wakes the production loop if it is waiting.

process_order

Plans production and orders the raw materials needed for it.

process_order_raw

Places one replenishment order for one raw material with one raw-material supplier.

Behavior

When the replenishment policy asks for more units, the manufacturer plans that many units and orders the raw materials needed to make them. It then makes the planned units in production batches: each makes at most product.batch_size units and takes product.manufacturing_time. Production batches follow one another until every planned unit is made. If one cannot start (no raw material yet, or no free capacity), the manufacturer waits until raw material arrives, a new plan comes in, or units leave its inventory, and then carries on.

Assumptions

The manufacturer makes a single product. Raw materials are kept apart from the inventory of the product. Only the product's inventory position is watched by the replenishment policy. Raw materials are ordered when production is planned, for exactly the planned units. The manufacturer starts with no raw materials.

Initialize the manufacturer object.

Parameters:
  • env (Environment) –

    Simulation environment.

  • ID (str) –

    Unique identifier for the manufacturer.

  • name (str) –

    Name of the manufacturer.

  • capacity (float) –

    The largest inventory level of the product it can hold.

  • initial_level (float) –

    Inventory level of the product at the start.

  • inventory_holding_cost (float) –

    Holding cost per unit of the product per time unit.

  • product_sell_price (float) –

    Sell price per unit of the product.

  • replenishment_policy (InventoryReplenishment) –

    The replenishment policy class (for example scm.SSReplenishment), not an object: the manufacturer creates the policy itself. It decides when to plan production. None for no replenishment policy.

  • policy_param (dict) –

    Parameters for the replenishment policy (for example {'s': 20, 'S': 60}).

  • product (Product, default: None ) –

    The product it makes (required). If the product's buy price is not set, it is set to the cost of making one unit: the manufacturing cost plus the cost of its raw materials.

  • inventory_type (str, default: 'non-perishable' ) –

    Type of inventory ("non-perishable" or "perishable").

  • shelf_life (float, default: 0.0 ) –

    Shelf life of the product, in time units (perishable goods only).

  • supplier_selection_policy (SupplierSelectionPolicy, default: SelectFirst ) –

    The supplier-selection policy class (default SelectFirst).

  • supplier_selection_mode (str, default: None ) –

    "fixed" or "dynamic"; see SupplierSelectionPolicy. If it is not given, the policy's own default is used ("fixed" for the built in policies except SelectProbabilistic).

  • supplier_selection_param (dict, default: None ) –

    Settings for the supplier-selection policy (optional), for example the probabilities of SelectProbabilistic.

  • **kwargs –

    Additional keyword arguments for the Node class and logger. Also accepts periodic_stats (bool, default False: if True, its statistics are updated every stats_period time units) and stats_period (float, default 1).

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • replenishment_policy (InventoryReplenishment) –

    The replenishment policy object the manufacturer created (or None).

  • inventory (Inventory) –

    Its inventory of the product.

  • inventory_drop (Event) –

    Fires when its inventory drops.

  • inventory_raised (Event) –

    Fires when units are added to its inventory.

  • product (Product) –

    The product it makes.

  • suppliers (list) –

    The links to its raw-material suppliers.

  • sell_price (float) –

    Sell price per unit of the product.

  • production_cycle (bool) –

    True while a production batch is being made.

  • raw_inventory_counts (dict) –

    Units of each raw material it holds, by raw material ID. (Raw materials are counted here, not in inventory.)

  • pending_orders (int) –

    Number of its raw-material replenishment orders placed but not yet arrived.

  • selection_policy (SupplierSelectionPolicy) –

    The supplier-selection policy object.

  • stats (Statistics) –

    Its statistics. Besides the usual ones, total_products_manufactured (units made so far) and total_manufacturing_cost (manufacturing cost so far, part of node_cost).

  • production_wake (Event) –

    Event that wakes the production loop when it is waiting (see wake_production).

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
4578
4579
4580
4581
4582
4583
4584
4585
4586
4587
4588
4589
4590
4591
4592
4593
4594
4595
4596
4597
4598
4599
4600
4601
4602
4603
4604
4605
4606
4607
4608
4609
4610
4611
4612
4613
4614
4615
4616
4617
4618
4619
4620
4621
4622
4623
4624
4625
4626
4627
4628
4629
4630
4631
4632
4633
4634
4635
4636
4637
4638
4639
4640
4641
4642
4643
4644
4645
4646
4647
4648
4649
4650
4651
4652
4653
4654
4655
4656
4657
4658
4659
4660
4661
4662
4663
4664
4665
4666
4667
4668
4669
4670
4671
4672
4673
4674
4675
4676
4677
4678
4679
4680
4681
4682
4683
4684
4685
4686
4687
4688
4689
4690
4691
4692
4693
4694
4695
4696
4697
4698
4699
4700
4701
4702
4703
4704
4705
4706
4707
4708
4709
4710
4711
4712
4713
4714
4715
4716
4717
4718
4719
4720
4721
4722
4723
4724
def __init__(self,
             env: simpy.Environment,
             ID: str,
             name: str,
             capacity: float,
             initial_level: float, 
             inventory_holding_cost: float, 
             product_sell_price: float, 
             replenishment_policy: InventoryReplenishment, 
             policy_param: dict, 
             product: Product = None, 
             inventory_type: str = "non-perishable",
             shelf_life: float = 0.0,
             supplier_selection_policy: SupplierSelectionPolicy = SelectFirst,
             supplier_selection_mode: str = None,
             supplier_selection_param: dict = None,
             **kwargs) -> None:
    """
    Initialize the manufacturer object.

    Parameters:
        env (simpy.Environment): Simulation environment.
        ID (str): Unique identifier for the manufacturer.
        name (str): Name of the manufacturer.
        capacity (float): The largest inventory level of the product it can hold.
        initial_level (float): Inventory level of the product at the start.
        inventory_holding_cost (float): Holding cost per unit of the product per time unit.
        product_sell_price (float): Sell price per unit of the product.
        replenishment_policy (InventoryReplenishment): The replenishment policy **class** (for example
            ``scm.SSReplenishment``), not an object: the manufacturer creates the policy itself. It decides when
            to plan production. None for no replenishment policy.
        policy_param (dict): Parameters for the replenishment policy (for example ``{'s': 20, 'S': 60}``).
        product (Product): The product it makes (required). If the product's buy price is not set, it is set to
            the cost of making one unit: the manufacturing cost plus the cost of its raw materials.
        inventory_type (str): Type of inventory ("non-perishable" or "perishable").
        shelf_life (float): Shelf life of the product, in time units (perishable goods only).
        supplier_selection_policy (SupplierSelectionPolicy): The supplier-selection policy class (default
            ``SelectFirst``).
        supplier_selection_mode (str): "fixed" or "dynamic"; see `SupplierSelectionPolicy`. If it is not given,
            the policy's own default is used ("fixed" for the built in policies except ``SelectProbabilistic``).
        supplier_selection_param (dict): Settings for the supplier-selection policy (optional), for example
            the probabilities of ``SelectProbabilistic``.
        **kwargs: Additional keyword arguments for the Node class and logger. Also accepts
            ``periodic_stats`` (bool, default False: if True, its statistics are updated every
            ``stats_period`` time units) and ``stats_period`` (float, default 1).

    Attributes:
        _info_keys (list): List of keys to include in the info dictionary.
        replenishment_policy (InventoryReplenishment): The replenishment policy object the manufacturer created (or None).
        inventory (Inventory): Its inventory of the product.
        inventory_drop (simpy.Event): Fires when its inventory drops.
        inventory_raised (simpy.Event): Fires when units are added to its inventory.
        product (Product): The product it makes.
        suppliers (list): The links to its raw-material suppliers.
        sell_price (float): Sell price per unit of the product.
        production_cycle (bool): True while a production batch is being made.
        raw_inventory_counts (dict): Units of each raw material it holds, by raw material ID. (Raw materials are
            counted here, not in ``inventory``.)
        pending_orders (int): Number of its raw-material replenishment orders placed but not yet arrived.
        selection_policy (SupplierSelectionPolicy): The supplier-selection policy object.
        stats (Statistics): Its statistics. Besides the usual ones, ``total_products_manufactured`` (units made so
            far) and ``total_manufacturing_cost`` (manufacturing cost so far, part of ``node_cost``).
        production_wake (simpy.Event): Event that wakes the production loop when it is waiting (see `wake_production`).

    Returns:
        None
    """
    # Take the statistics options out of kwargs before passing the rest on
    # to Node. They belong to Statistics, but are accepted here so users can
    # ask for regular statistics updates.
    periodic_stats = kwargs.pop('periodic_stats', False)
    stats_period = kwargs.pop('stats_period', 1)
    if periodic_stats:
        validate_positive(name="stats_period", value=stats_period)
    super().__init__(env=env,ID=ID,name=name,node_type="manufacturer",**kwargs)
    if product == None:
        global_logger.error("Product not provided for the manufacturer.")
        raise ValueError("Product not provided for the manufacturer.")
    elif not isinstance(product, Product):
        raise ValueError("Invalid product type. Expected a Product instance.")
    validate_positive("Product Sell Price", product_sell_price)
    self.replenishment_policy = None
    if(replenishment_policy):
        self.replenishment_policy = replenishment_policy(env = self.env, node = self, params = policy_param)
        self.env.process(self.replenishment_policy.run())

    inventory_kwargs = {k: v for k, v in kwargs.items() if k not in _LOGGER_KWARGS and k not in _NODE_KWARGS}
    self.inventory = Inventory(env=self.env, capacity=capacity, initial_level=initial_level, node=self, inv_type=inventory_type, holding_cost=inventory_holding_cost, replenishment_policy=self.replenishment_policy, shelf_life=shelf_life, **inventory_kwargs)
    self.inventory_drop = self.env.event()  # fires when the inventory drops
    self.inventory_raised = self.env.event() # fires when units are added
    # Wakes the production loop when it is waiting because it could not
    # make anything. It is set off by anything that may let production go
    # on: raw material arriving, a new production plan, or units leaving
    # the inventory (which frees capacity). See `wake_production`.
    self.production_wake = self.env.event()
    self.product = product # the product it makes
    # self.suppliers is already set to [] in Node.__init__.
    self.product.sell_price = product_sell_price
    self.sell_price = product_sell_price # sell price per unit

    self.production_cycle = False # True while a production batch is being made
    self.raw_inventory_counts = {} # units of each raw material held, by raw material ID
    self.pending_orders = 0 # raw-material replenishment orders placed but not yet arrived

    # If no buy price is given, use the cost of making one unit: the
    # manufacturing cost plus the cost of its raw materials.
    if(self.product.buy_price <= 0):
        self.product.buy_price = self.product.manufacturing_cost 
        for raw_material in self.product.raw_materials:
            self.product.buy_price += raw_material[0].cost * raw_material[1] # raw-material cost of one unit

    self.env.process(self.behavior()) # start the production loop
    # A manufacturer does not use its supplier-selection policy yet: for
    # each raw material it orders from the first raw-material supplier of
    # it that can take the order (see _raw_material_links). Warn when the
    # user asked for anything else, so the setting is not ignored unseen.
    # The text leaves out the site, so Python shows it once in all. The
    # warning comes before the policy is created, so it is shown even when
    # the policy rejects its settings.
    if (supplier_selection_policy is not SelectFirst or supplier_selection_mode is not None
            or supplier_selection_param is not None):
        message = ("A manufacturer does not use its supplier-selection policy yet. For each raw material it "
                   "orders from the first raw-material supplier that can take the order, so the policy given "
                   "is ignored.")
        self.logger.warning(f"{self.ID}: {message}")
        warnings.warn(message, UserWarning, stacklevel=2)
    # A policy with its own random numbers (SelectProbabilistic) given no
    # seed or rng would draw its seed from this site's random number
    # generator, which is usually the library's. That one draw would move
    # every later number of it, so the disruptions of the network would
    # change although the policy is ignored. Since the policy never
    # chooses, give it a generator of its own and draw nothing. Remove
    # this when manufacturers use their policy.
    if (isinstance(supplier_selection_param, (dict, type(None)))
            and "rng" in (getattr(supplier_selection_policy, "_param_keys", None) or ())):
        settings = dict(supplier_selection_param or {})  # a copy, so the user's dict is not changed
        if settings.get("seed") is None and settings.get("rng") is None:
            settings["rng"] = random.Random(0)
            supplier_selection_param = settings
    self.selection_policy = self._make_selection_policy(supplier_selection_policy, supplier_selection_mode,
                                                        supplier_selection_param)

    self.stats = Statistics(self, periodic_update=periodic_stats, period=stats_period) # create a statistics object for the manufacturer
    setattr(self.stats,"total_products_manufactured",0) # adding specific statistics for the manufacturer
    setattr(self.stats,"total_manufacturing_cost",0) # adding specific statistics for the manufacturer
    self.stats._stats_keys.extend(["total_products_manufactured", "total_manufacturing_cost"])
    self.stats._cost_components.append("total_manufacturing_cost")

manufacture_product

manufacture_product()

Make one production batch. A production batch makes at most product.batch_size units and takes product.manufacturing_time. It can be smaller when there is only raw material, free capacity, or planned units left for fewer units. The raw materials are used up when the production batch starts, and the units made are added to the inventory when it ends.

Returns:
  • int –

    Number of units made in this production batch (0 if nothing could be made).

Source code in src/SupplyNetPy/Components/core.py
4726
4727
4728
4729
4730
4731
4732
4733
4734
4735
4736
4737
4738
4739
4740
4741
4742
4743
4744
4745
4746
4747
4748
4749
4750
4751
4752
4753
4754
4755
4756
4757
4758
4759
4760
4761
4762
4763
4764
4765
4766
4767
4768
4769
4770
4771
4772
4773
4774
4775
4776
4777
4778
4779
4780
4781
4782
4783
4784
4785
def manufacture_product(self):
    """
    Make one production batch.
    A production batch makes at most `product.batch_size` units and takes `product.manufacturing_time`. It
    can be smaller when there is only raw material, free capacity, or planned units left for fewer units. The
    raw materials are used up when the production batch starts, and the units made are added to the
    inventory when it ends.

    Parameters:
        None

    Attributes:
        None

    Returns:
        int: Number of units made in this production batch (0 if nothing could be made).
    """
    max_producible_units = self.product.batch_size 
    for raw_material in self.product.raw_materials:
        raw_mat_id = raw_material[0].ID
        required_amount = raw_material[1]
        current_raw_material_level = self.raw_inventory_counts[raw_mat_id]
        max_producible_units = min(max_producible_units,int(current_raw_material_level/required_amount))
    if((self.inventory.level + max_producible_units)>self.inventory.capacity): # make only what fits within the capacity
        max_producible_units = self.inventory.capacity - self.inventory.level
    # Don't make more than the manufacturer has planned to make. When it
    # plans production, it adds those units to on_hand straight away
    # (§3.11), so on_hand = inventory level + units still to be made -
    # replenishment orders from other sites that are waiting at its
    # inventory (those were taken off on_hand when they were placed).
    # Units still to be made is therefore on_hand - inventory level +
    # waiting orders.
    #
    # Leaving out the waiting orders makes this number too small, by
    # exactly the amount the manufacturer owes. Picture a manufacturer with
    # an inventory level of 10 that receives an order for 50. on_hand drops
    # from 10 to -40, and it plans production of 50 (to fill the order and
    # refill its inventory), which brings on_hand back to 10. Then on_hand -
    # inventory level = 10 - 10 = 0, so it would make nothing and the order
    # would wait forever. Adding the 50 waiting units gives the correct 50.
    pending = self.inventory.on_hand - self.inventory.level + self.inventory.waiting_orders()
    if pending < max_producible_units:
        max_producible_units = max(pending, 0)
    if(max_producible_units>0):
        self.production_cycle = True # a production batch starts
        for raw_material in self.product.raw_materials: # use up the raw materials
            raw_mat_id = raw_material[0].ID
            required_amount = raw_material[1]
            self.raw_inventory_counts[raw_mat_id] -= raw_material[1]*max_producible_units
        yield self.env.timeout(self.product.manufacturing_time) # the production batch takes manufacturing_time
        accepted = self.inventory.put(max_producible_units, manufacturing_date=self.env.now)
        shortfall = max_producible_units - accepted
        if shortfall > 0:
            self.inventory.on_hand -= shortfall
        self.logger.info(f"{self.env.now:.4f}:{self.ID}: {max_producible_units} units manufactured.")
        self.logger.info(f"{self.env.now:.4f}:{self.ID}: Product inventory levels:{self.inventory.level}")
        self.stats.update_stats(total_products_manufactured=max_producible_units, total_manufacturing_cost=max_producible_units*self.product.manufacturing_cost) # update statistics
        self.production_cycle = False
        return max_producible_units
    return 0

wake_production

wake_production()

Wake the production loop if it is waiting because it could not make anything. Called when raw material arrives, when a new production plan comes in, and when units leave the inventory (which frees capacity). Calling it while the loop is busy does no harm: the loop just checks once more when it next stops.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
4787
4788
4789
4790
4791
4792
4793
4794
4795
4796
4797
4798
def wake_production(self):
    """
    Wake the production loop if it is waiting because it could not make anything.
    Called when raw material arrives, when a new production plan comes in, and when units leave the
    inventory (which frees capacity). Calling it while the loop is busy does no harm: the loop just checks
    once more when it next stops.

    Returns:
        None
    """
    if not self.production_wake.triggered:
        self.production_wake.succeed()

behavior

behavior()

The production loop, for the whole simulation: make one production batch after another while there are planned units left to make; when nothing can be made, wait until something may change that (see wake_production). It first checks the manufacturer's suppliers: each must be a raw-material supplier with a raw material, and there should be at least one for each raw material of the product.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
4800
4801
4802
4803
4804
4805
4806
4807
4808
4809
4810
4811
4812
4813
4814
4815
4816
4817
4818
4819
4820
4821
4822
4823
4824
4825
4826
4827
4828
4829
4830
4831
4832
4833
4834
4835
4836
4837
4838
4839
4840
4841
4842
4843
4844
4845
4846
4847
4848
4849
4850
4851
4852
4853
def behavior(self):
    """
    The production loop, for the whole simulation: make one production batch after another while there are
    planned units left to make; when nothing can be made, wait until something may change that (see
    `wake_production`). It first checks the manufacturer's suppliers: each must be a raw-material supplier
    with a raw material, and there should be at least one for each raw material of the product.

    Parameters:
        None

    Attributes: 
        None

    Returns:
        None
    """
    if(len(self.suppliers)==0):
        self.logger.error("No suppliers connected to the manufacturer.")
        raise ValueError("No suppliers connected to the manufacturer.")

    if(len(self.suppliers)>0): # start a count of 0 units for each raw material (key: raw material ID)
        for supplier in self.suppliers: # iterate over supplier links
            if(supplier.source.raw_material is None): # check if the supplier has a raw material
                self.logger.error(f"{self.ID}:Supplier {supplier.source.ID} does not have a raw material. Please provide a raw material for the supplier.")
                raise ValueError(f"Supplier {supplier.source.ID} does not have a raw material.")
            self.raw_inventory_counts[supplier.source.raw_material.ID] = 0 # store initial levels

    if(len(self.suppliers)<len(self.product.raw_materials)):
        message = (f"Manufacturer {self.ID} ({self.name}) has fewer suppliers ({len(self.suppliers)}) than the raw "
                   f"materials its product needs ({len(self.product.raw_materials)}), so it cannot make any units. "
                   f"Connect a raw-material supplier for each raw material.")
        self.logger.warning(message)
        warnings.warn(message, UserWarning)

    # Production loop: make one production batch after another for as long
    # as there are planned units left to make. The manufacturer makes only
    # one production batch at a time (we wait for each to finish before
    # starting the next).
    #
    # When nothing can be made (nothing planned, no raw material yet, or no
    # free capacity), wait for something that may change that: raw
    # material arriving, a new production plan, or units leaving the
    # inventory. Waking only on raw-material deliveries is not enough.
    # Picture a manufacturer that plans 25 units with a batch size of 5:
    # one delivery of raw material covers all 25, but would start only the
    # first production batch, leaving the manufacturer idle with raw
    # material for 20 units.
    while len(self.suppliers) >= len(self.product.raw_materials): # check if required number of suppliers are connected
        made = yield self.env.process(self.manufacture_product())
        if made > 0:
            continue # go straight on to the next batch
        yield self.production_wake
        # Replace the used event with a new one for the next wait.
        self.production_wake = self.env.event()

process_order_raw

process_order_raw(raw_mat_id, supplier, reorder_quantity)

Place a replenishment order for one raw material with one raw-material supplier, and add the units to the manufacturer's raw materials when they arrive. This runs as a SimPy process (process_order starts it).

Parameters:
  • raw_mat_id (str) –

    ID of the raw material to order.

  • supplier (Link) –

    The link to the raw-material supplier, not the supplier itself (the supplier is supplier.source).

  • reorder_quantity (float) –

    Units of the raw material to order.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
4855
4856
4857
4858
4859
4860
4861
4862
4863
4864
4865
4866
4867
4868
4869
4870
4871
4872
4873
4874
4875
4876
4877
4878
4879
4880
4881
4882
4883
4884
4885
4886
4887
4888
4889
4890
4891
4892
4893
4894
4895
4896
4897
4898
4899
4900
4901
4902
4903
4904
4905
4906
4907
4908
4909
4910
4911
4912
4913
4914
4915
4916
4917
4918
4919
4920
4921
4922
4923
4924
4925
4926
4927
4928
def process_order_raw(self, raw_mat_id, supplier, reorder_quantity):
    """
    Place a replenishment order for one raw material with one raw-material supplier, and add the units to
    the manufacturer's raw materials when they arrive. This runs as a SimPy process (``process_order``
    starts it).

    Parameters:
        raw_mat_id (str): ID of the raw material to order.
        supplier (Link): The **link** to the raw-material supplier, not the supplier itself (the supplier
            is ``supplier.source``).
        reorder_quantity (float): Units of the raw material to order.

    Attributes:
        None

    Returns:
        None
    """
    # This order is always placed. `process_order` has already chosen a
    # supplier whose site and link are not disrupted, and has already
    # counted the units this raw material is for as planned production. This process starts at the same moment, before the clock
    # moves on, so neither can have become disrupted in between.
    #
    # Raw material the manufacturer already holds is not a reason to skip
    # the order: it is set aside for units planned earlier. Using it for
    # these new units would leave some planned units with no raw material
    # to make them from, and the manufacturer would wait for them forever.
    if supplier.source.inventory.level < reorder_quantity:  # the supplier cannot fill this order right away: record the shortage
        shortage = reorder_quantity - supplier.source.inventory.level
        supplier.source.stats.update_stats(shortage=[1,shortage])
        supplier.stats.update_stats(shortage=[1,shortage])

    self.pending_orders += 1  # count this raw-material order as placed
    self.logger.info(f"{self.env.now:.4f}:{self.ID}:Replenishing raw material:{supplier.source.raw_material.name} from supplier:{supplier.source.ID}, order placed for {reorder_quantity} units. Current inventory level: {self.raw_inventory_counts}.")
    # The order counts as placed now, when it is placed, as in
    # InventoryNode.process_order, even if the supplier makes it wait.
    # It is recorded at the manufacturer and on the link (see LinkStatistics).
    self.stats.update_stats(demand_placed=[1,reorder_quantity],transportation_cost=supplier.cost)
    supplier.stats.update_stats(demand_placed=[1,reorder_quantity],transportation_cost=supplier.cost)
    supplier.stats.record_order(reorder_quantity)
    self._warn_if_supplier_cannot_hold(supplier, reorder_quantity)
    event, man_date_ls = supplier.source.inventory.get(reorder_quantity, site_order=True)
    supplier.source.stats.update_stats(demand_received=[1,reorder_quantity]) # update the supplier statistics for demand received
    # The supplier owes this order only while it waits for units: count
    # it as a backorder now and remove it once it is served (the same as
    # for other replenishment orders, see InventoryNode.process_order).
    order_waits = not event.triggered
    ordered_at = self.env.now
    if order_waits:
        supplier.source.stats.update_stats(backorder=[1,reorder_quantity], waited=[1,reorder_quantity])
        supplier.stats.update_stats(backorder=[1,reorder_quantity], waited=[1,reorder_quantity])
    yield event
    waited_for = self.env.now - ordered_at  # 0 if the order was served at once
    if order_waits:
        # Served at last: the supplier no longer owes this order, and it
        # adds how long the order waited.
        supplier.source.stats.update_stats(backorder=[-1,-reorder_quantity], waiting_time=waited_for)
        supplier.stats.update_stats(backorder=[-1,-reorder_quantity])

    self.logger.info(f"{self.env.now:.4f}:{self.ID}:shipment in transit from supplier:{supplier.source.name}.")
    lead_time = supplier.lead_time() # draw the lead time from the link
    validate_non_negative(name="lead_time", value=lead_time) # check if lead_time is non-negative
    yield self.env.timeout(lead_time) # lead time for the order

    self.stats.update_stats(fulfillment_received=[1,reorder_quantity],inventory_spend_cost=reorder_quantity*supplier.source.sell_price)
    supplier.source.stats.update_stats(demand_fulfilled=[1,reorder_quantity]) # update the supplier statistics for demand fulfilled
    # On arrival, as in InventoryNode.process_order.
    supplier.stats.update_stats(fulfillment_received=[1,reorder_quantity],
                                inventory_spend_cost=reorder_quantity*supplier.source.sell_price,
                                waiting_time=waited_for, lead_time=lead_time)
    self.raw_inventory_counts[raw_mat_id] += reorder_quantity
    self.logger.info(f"{self.env.now:.4f}:{self.ID}:Order received from supplier:{supplier.source.name}, inventory levels: {self.raw_inventory_counts}")
    self.pending_orders -= 1
    self.wake_production() # raw material just arrived

can_place_order

can_place_order() -> bool

Whether this manufacturer can plan production right now: every raw material of the product has a raw-material supplier that can take an order. If even one raw material cannot be ordered, the units could never be made, so nothing is planned (see process_order).

Returns:
  • bool( bool ) –

    True if every raw material can be ordered.

Source code in src/SupplyNetPy/Components/core.py
4951
4952
4953
4954
4955
4956
4957
4958
4959
4960
4961
def can_place_order(self) -> bool:
    """
    Whether this manufacturer can plan production right now: every raw
    material of the product has a raw-material supplier that can take an
    order. If even one raw material cannot be ordered, the units could
    never be made, so nothing is planned (see ``process_order``).

    Returns:
        bool: True if every raw material can be ordered.
    """
    return all(link is not None for _, _, link in self._raw_material_links())

has_suppliers

has_suppliers() -> bool

Whether every raw material of the product has at least one raw-material supplier connected to this manufacturer, disrupted or not (see Node.has_suppliers).

Returns:
  • bool( bool ) –

    True if every raw material has a raw-material supplier.

Source code in src/SupplyNetPy/Components/core.py
4963
4964
4965
4966
4967
4968
4969
4970
4971
4972
4973
4974
def has_suppliers(self) -> bool:
    """
    Whether every raw material of the product has at least one
    raw-material supplier connected to this manufacturer, disrupted or not
    (see ``Node.has_suppliers``).

    Returns:
        bool: True if every raw material has a raw-material supplier.
    """
    supplied = {link.source.raw_material.ID for link in self.suppliers
                if getattr(link.source, "raw_material", None) is not None}
    return all(raw_mat.ID in supplied for raw_mat, _ in self.product.raw_materials)

process_order

process_order(supplier, reorder_quantity)

Plan the production of more product units and order the raw materials needed to make them. Each raw material is ordered from the first raw-material supplier of that raw material whose link and site are both not disrupted. place_order starts this as a SimPy process only when every raw material has such a supplier (see can_place_order).

Parameters:
  • supplier (Link) –

    The link chosen by the supplier-selection policy. Not used: each raw material is ordered from its own raw-material supplier, as described above.

  • reorder_quantity (float) –

    Number of product units to plan.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
4976
4977
4978
4979
4980
4981
4982
4983
4984
4985
4986
4987
4988
4989
4990
4991
4992
4993
4994
4995
4996
4997
4998
4999
5000
5001
5002
5003
5004
5005
5006
5007
5008
5009
5010
5011
5012
5013
5014
5015
5016
5017
5018
5019
5020
5021
5022
5023
5024
5025
5026
5027
5028
5029
5030
5031
5032
5033
5034
5035
5036
5037
5038
5039
5040
5041
5042
5043
5044
5045
5046
def process_order(self, supplier, reorder_quantity):
    """
    Plan the production of more product units and order the raw materials needed to make them.
    Each raw material is ordered from the first raw-material supplier of that raw material whose link and
    site are both not disrupted. ``place_order`` starts this as a SimPy process only when every raw material
    has such a supplier (see ``can_place_order``).

    Parameters:
        supplier (Link): The link chosen by the supplier-selection policy. Not used: each raw material is
            ordered from its own raw-material supplier, as described above.
        reorder_quantity (float): Number of product units to plan.

    Attributes:
        None

    Returns:
        None
    """
    # Check the order against the capacity using the inventory position,
    # as InventoryNode.process_order does. Units owed to waiting customers
    # leave the inventory as soon as they are made, so they do not take up
    # space. See §3.10 for details.
    gross = self.position()
    if(gross + reorder_quantity > self.inventory.capacity): # check if the inventory can accommodate the reordered quantity
            reorder_quantity = self.inventory.capacity - gross # if not, adjust reorder quantity to order only what can fit
    if reorder_quantity <= 0:
        return # no need to place an order if reorder quantity is zero

    # Units count as planned only if all their raw materials really get
    # ordered. Otherwise the manufacturer would count on units it can
    # never make: its inventory position would include them, so the policy
    # would not order again, and orders waiting for those units would wait
    # forever. place_order has already checked that every raw material can
    # be ordered (can_place_order); here we choose the supplier for each.
    # The check below matters only if a supplier was disrupted after
    # place_order checked (possible at time 0, see
    # InventoryNode.process_order), or if process_order is called directly.
    raw_orders = [] # (raw material ID, supplier link, quantity to order)
    for raw_mat, qty_per_unit, supplier_link in self._raw_material_links():
        if supplier_link is None:
            self.logger.info(f"{self.env.now:.4f}:{self.ID}:No supplier can take an order for raw material {raw_mat.name}. No production planned.")
            if self.has_suppliers():
                # Every supplier of this raw material is disrupted: a
                # blocked order. With no supplier at all, it is not one.
                self.stats.update_stats(blocked=[1, reorder_quantity])
            return
        raw_orders.append((raw_mat.ID, supplier_link, qty_per_unit*reorder_quantity))

    # Count the planned production in on_hand right away (the same thing
    # InventoryNode.process_order does). Without this, the replenishment
    # policy could check again before the next production batch, see an
    # out-of-date on_hand, and order raw materials a second time for units
    # that are already planned.
    self.inventory.on_hand += reorder_quantity
    # New units are planned, so let the production loop know. Raw material
    # already on hand may be enough to start on them.
    self.wake_production()

    # Order the raw materials for these units. This happens even if an
    # earlier raw-material order is still on its way: that order is for
    # units planned earlier, so these new units need their own.
    for raw_mat_id, supplier_link, quantity in raw_orders:
        self.env.process(self.process_order_raw(raw_mat_id, supplier_link, quantity))
    # This method runs via ``env.process``, so it must be a generator and
    # therefore must yield at least once — but it has nothing to wait for.
    # Each raw-material order is started as its own separate process and is
    # not awaited here (the production loop is woken when the raw material
    # arrives, see `wake_production`). So we yield a zero-length timeout, which
    # satisfies that requirement without pausing for any real time. (An
    # earlier version waited one time unit here for no real reason.)
    yield self.env.timeout(0)

Demand

Demand(env: Environment, ID: str, name: str, order_arrival_model: Callable, order_quantity_model: Callable, demand_node: Node, tolerance: float = 0.0, order_min_split_ratio: float = 1.0, delivery_cost: Callable = lambda: 0, lead_time: Callable = lambda: 0, consume_available: bool = False, **kwargs)

Bases: Node

The Demand class represents a customer demand: a stream of customer orders arriving at one site (the demand_node). The time between orders and the units in each order are given by functions, so they can be random. When a site cannot fill an order at once, the customer either leaves without the units or, if a tolerance is set, waits for them.

It supports:

  • a lead time and a delivery cost for each customer order,

  • partial deliveries for customers who wait (order_min_split_ratio),

  • backorders at the site for customers who wait.

Parameters:
  • env (Environment) –

    Simulation environment.

  • ID (str) –

    Unique identifier for the customer demand.

  • name (str) –

    Name of the customer demand.

  • order_arrival_model (callable) –

    Time units between one customer order and the next (a number, or a function returning one each time).

  • order_quantity_model (callable) –

    Units in each customer order (a number, or a function returning one each time).

  • demand_node (Node) –

    The site the customers order from. (Despite its name, this is not the customer demand itself.) It cannot be a raw-material supplier.

  • tolerance (float, default: 0.0 ) –

    How long, in time units, a customer is willing to wait for units that are not there. 0 (the default) means the customer leaves at once.

  • order_min_split_ratio (float, default: 1.0 ) –

    For a customer who waits: the smallest partial delivery they accept, as a fraction of the order (more than 0, at most 1). 1 (the default) means no partial deliveries.

  • delivery_cost (callable, default: lambda: 0 ) –

    Cost of delivering each customer order (a number or a function); recorded as this customer demand's transportation cost.

  • lead_time (callable, default: lambda: 0 ) –

    Lead time of each customer order: time units from the site to the customer (a number or a function).

  • consume_available (bool, default: False ) –

    If True, a customer whose order cannot be filled in full takes the units that are there and leaves; the rest is recorded as a shortage.

  • **kwargs –

    Additional keyword arguments for Node and GlobalLogger. Also accepts periodic_stats (bool, default False: if True, its statistics are updated every stats_period time units) and stats_period (float, default 1).

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • order_arrival_model (callable) –

    Function giving the time units between customer orders.

  • order_quantity_model (callable) –

    Function giving the units in each customer order.

  • demand_node (Node) –

    The site the customers order from.

  • customer_tolerance (float) –

    How long a customer is willing to wait (the tolerance parameter).

  • delivery_cost (callable) –

    Function giving the cost of delivering each customer order.

  • lead_time (callable) –

    Function giving the lead time of each customer order.

  • min_split (float) –

    The smallest partial delivery a waiting customer accepts, as a fraction of the order.

  • consume_available (bool) –

    If True, a customer takes the units that are there when the order cannot be filled in full.

  • stats (Statistics) –

    Its statistics: the customer orders placed (demand_placed) and received (fulfillment_received), and the delivery costs (transportation_cost). Shortages and backorders are recorded at the site (demand_node).

Methods:

Name Description
_process_delivery

Delivers one customer order: records the delivery cost and waits for the lead time.

wait_for_order

Lets a customer wait for units, up to their tolerance, when the order cannot be filled at once.

customer

Handles one customer order.

behavior

Creates customer orders for the whole simulation.

Behavior

Customer orders arrive at the site with the times and sizes given by order_arrival_model and order_quantity_model. If the site's inventory can fill an order, it is delivered at once. If not, a shortage is recorded at the site, and

  • with no tolerance (the default), the customer leaves without the units;

  • with consume_available, the customer takes the units that are there and leaves;

  • with a tolerance, the customer waits for the units, up to the tolerance, and the order counts as a backorder at the site while they wait. They may accept partial deliveries, if order_min_split_ratio is below 1. If the tolerance runs out, they leave without the rest.

Assumptions
  • The time between customer orders follows order_arrival_model; the first order arrives at time 0.
  • The units in each order follow order_quantity_model.
  • The delivery cost and lead time are drawn afresh for each customer order.
  • The site (demand_node) cannot be a raw-material supplier; it is usually a retailer or a distributor.

Initialize the customer demand object.

Parameters:
  • env (Environment) –

    Simulation environment.

  • ID (str) –

    Unique identifier for the customer demand.

  • name (str) –

    Name of the customer demand.

  • order_arrival_model (callable) –

    Time units between one customer order and the next (a number, or a function returning one each time).

  • order_quantity_model (callable) –

    Units in each customer order (a number, or a function returning one each time).

  • demand_node (Node) –

    The site the customers order from. (Despite its name, this is not the customer demand itself.) It cannot be a raw-material supplier.

  • tolerance (float, default: 0.0 ) –

    How long, in time units, a customer is willing to wait for units that are not there. 0 (the default) means the customer leaves at once.

  • order_min_split_ratio (float, default: 1.0 ) –

    For a customer who waits: the smallest partial delivery they accept, as a fraction of the order (more than 0, at most 1). 1 (the default) means no partial deliveries.

  • delivery_cost (callable, default: lambda: 0 ) –

    Cost of delivering each customer order (a number or a function); recorded as this customer demand's transportation cost.

  • lead_time (callable, default: lambda: 0 ) –

    Lead time of each customer order: time units from the site to the customer (a number or a function).

  • consume_available (bool, default: False ) –

    If True, a customer whose order cannot be filled in full takes the units that are there and leaves; the rest is recorded as a shortage.

  • **kwargs –

    Additional keyword arguments for Node and GlobalLogger. Also accepts periodic_stats (bool, default False: if True, its statistics are updated every stats_period time units) and stats_period (float, default 1).

Attributes:
  • _info_keys (list) –

    List of keys to include in the info dictionary.

  • order_arrival_model (callable) –

    Function giving the time units between customer orders.

  • order_quantity_model (callable) –

    Function giving the units in each customer order.

  • demand_node (Node) –

    The site the customers order from.

  • customer_tolerance (float) –

    How long a customer is willing to wait (the tolerance parameter).

  • delivery_cost (callable) –

    Function giving the cost of delivering each customer order.

  • lead_time (callable) –

    Function giving the lead time of each customer order.

  • min_split (float) –

    The smallest partial delivery a waiting customer accepts, as a fraction of the order.

  • consume_available (bool) –

    If True, a customer takes the units that are there when the order cannot be filled in full.

  • stats (Statistics) –

    Its statistics: the customer orders placed (demand_placed) and received (fulfillment_received), and the delivery costs (transportation_cost). Shortages and backorders are recorded at the site (demand_node).

Returns:
  • None –

    None

Source code in src/SupplyNetPy/Components/core.py
5133
5134
5135
5136
5137
5138
5139
5140
5141
5142
5143
5144
5145
5146
5147
5148
5149
5150
5151
5152
5153
5154
5155
5156
5157
5158
5159
5160
5161
5162
5163
5164
5165
5166
5167
5168
5169
5170
5171
5172
5173
5174
5175
5176
5177
5178
5179
5180
5181
5182
5183
5184
5185
5186
5187
5188
5189
5190
5191
5192
5193
5194
5195
5196
5197
5198
5199
5200
5201
5202
5203
5204
5205
5206
5207
5208
5209
5210
5211
5212
5213
5214
5215
5216
5217
5218
5219
5220
5221
5222
5223
5224
5225
5226
5227
5228
def __init__(self,
             env: simpy.Environment,
             ID: str,
             name: str,
             order_arrival_model: Callable,
             order_quantity_model: Callable,
             demand_node: Node,
             tolerance: float = 0.0,
             order_min_split_ratio: float = 1.0,
             delivery_cost: Callable = lambda: 0,
             lead_time: Callable = lambda: 0,
             consume_available: bool = False,
             **kwargs) -> None:
    """
    Initialize the customer demand object.

    Parameters:
        env (simpy.Environment): Simulation environment.
        ID (str): Unique identifier for the customer demand.
        name (str): Name of the customer demand.
        order_arrival_model (callable): Time units between one customer order and the next (a number, or a
            function returning one each time).
        order_quantity_model (callable): Units in each customer order (a number, or a function returning one
            each time).
        demand_node (Node): The site the customers order from. (Despite its name, this is not the customer
            demand itself.) It cannot be a raw-material supplier.
        tolerance (float): How long, in time units, a customer is willing to wait for units that are not there.
            0 (the default) means the customer leaves at once.
        order_min_split_ratio (float): For a customer who waits: the smallest partial delivery they accept, as a
            fraction of the order (more than 0, at most 1). 1 (the default) means no partial deliveries.
        delivery_cost (callable): Cost of delivering each customer order (a number or a function); recorded as
            this customer demand's transportation cost.
        lead_time (callable): Lead time of each customer order: time units from the site to the customer (a
            number or a function).
        consume_available (bool): If True, a customer whose order cannot be filled in full takes the units that
            are there and leaves; the rest is recorded as a shortage.
        **kwargs: Additional keyword arguments for Node and GlobalLogger. Also accepts ``periodic_stats``
            (bool, default False: if True, its statistics are updated every ``stats_period`` time units) and
            ``stats_period`` (float, default 1).

    Attributes:
        _info_keys (list): List of keys to include in the info dictionary.
        order_arrival_model (callable): Function giving the time units between customer orders.
        order_quantity_model (callable): Function giving the units in each customer order.
        demand_node (Node): The site the customers order from.
        customer_tolerance (float): How long a customer is willing to wait (the ``tolerance`` parameter).
        delivery_cost (callable): Function giving the cost of delivering each customer order.
        lead_time (callable): Function giving the lead time of each customer order.
        min_split (float): The smallest partial delivery a waiting customer accepts, as a fraction of the order.
        consume_available (bool): If True, a customer takes the units that are there when the order cannot be
            filled in full.
        stats (Statistics): Its statistics: the customer orders placed (``demand_placed``) and received
            (``fulfillment_received``), and the delivery costs (``transportation_cost``). Shortages and
            backorders are recorded at the site (``demand_node``).

    Returns:
        None
    """
    # Take the statistics options out of kwargs before passing the rest on
    # to Node. They belong to Statistics, but are accepted here so users can
    # ask for regular statistics updates.
    periodic_stats = kwargs.pop('periodic_stats', False)
    stats_period = kwargs.pop('stats_period', 1)
    if periodic_stats:
        validate_positive(name="stats_period", value=stats_period)
    super().__init__(env=env,ID=ID,name=name,node_type="demand",**kwargs)
    if order_arrival_model is None or order_quantity_model is None:
        raise ValueError("Order arrival and quantity models cannot be None.")
    # Turn each of these settings into a function and check that it returns
    # a number (see ``ensure_numeric_callable``).
    order_arrival_model = ensure_numeric_callable("order_arrival_model", order_arrival_model)
    order_quantity_model = ensure_numeric_callable("order_quantity_model", order_quantity_model)
    delivery_cost = ensure_numeric_callable("delivery_cost", delivery_cost)
    lead_time = ensure_numeric_callable("lead_time", lead_time)
    if demand_node is None or "supplier" in demand_node.node_type:
        raise ValueError("Demand node must be a valid non-supplier node.")
    validate_non_negative("Customer tolerance", tolerance)
    validate_positive("Order Min Split Ratio", order_min_split_ratio)
    if order_min_split_ratio > 1:
        self.logger.error("Order Min Split Ratio must be in the range (0, 1].")
        raise ValueError("Order Min Split Ratio must be in the range (0, 1].")
    validate_number(name="order_time", value=order_arrival_model())
    validate_number(name="order_quantity", value=order_quantity_model())
    validate_number(name="delivery_cost", value=delivery_cost()) # check if delivery_cost is a number
    validate_number(name="lead_time", value=lead_time()) # check if lead_time is a number

    self.order_arrival_model = order_arrival_model
    self.order_quantity_model = order_quantity_model
    self.demand_node = demand_node
    self.customer_tolerance = tolerance
    self.delivery_cost = delivery_cost
    self.lead_time = lead_time
    self.min_split = order_min_split_ratio
    self.consume_available = consume_available # if True, a customer takes the units that are there and leaves
    self.env.process(self.behavior())
    self.stats = Statistics(self, periodic_update=periodic_stats, period=stats_period) # statistics of this customer demand

wait_for_order

wait_for_order(customer_id, order_quantity)

Let a customer wait for the units of their order, up to their tolerance. While waiting, the customer's order is counted as a backorder at the site. The customer checks again each time units are added to the site's inventory. If the tolerance is infinite, the customer waits until the order is filled. Otherwise, the customer leaves as soon as their tolerance runs out, even if no new units have arrived, and whatever they were still owed is taken off the site's backorders.

Parameters:
  • order_quantity (float) –

    Units ordered.

  • customer_id (int) –

    Customer ID for logging purposes.

Attributes:
  • customer_id (int) –

    Customer ID for logging purposes.

  • order_quantity (float) –

    Units ordered.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
5268
5269
5270
5271
5272
5273
5274
5275
5276
5277
5278
5279
5280
5281
5282
5283
5284
5285
5286
5287
5288
5289
5290
5291
5292
5293
5294
5295
5296
5297
5298
5299
5300
5301
5302
5303
5304
5305
5306
5307
5308
5309
5310
5311
5312
5313
5314
5315
5316
5317
5318
5319
5320
5321
5322
5323
5324
5325
5326
5327
5328
5329
5330
5331
5332
5333
5334
5335
5336
5337
5338
5339
5340
5341
5342
5343
5344
5345
5346
5347
5348
5349
5350
5351
5352
5353
5354
5355
5356
5357
5358
5359
5360
5361
5362
5363
5364
5365
5366
5367
def wait_for_order(self,customer_id,order_quantity):
    """
    Let a customer wait for the units of their order, up to their tolerance.
    While waiting, the customer's order is counted as a backorder at the site.
    The customer checks again each time units are added to the site's inventory.
    If the tolerance is infinite, the customer waits until the order is filled.
    Otherwise, the customer leaves as soon as their tolerance runs out, even if
    no new units have arrived, and whatever they were still owed is taken off
    the site's backorders.

    Parameters:
        order_quantity (float): Units ordered.
        customer_id (int): Customer ID for logging purposes.

    Attributes:
        customer_id (int): Customer ID for logging purposes.
        order_quantity (float): Units ordered.

    Returns:
        None
    """
    self.logger.info(f"{self.env.now:.4f}:{self.ID}:Customer{customer_id}:Order quantity:{order_quantity} not available! Order will be split if split ratio is provided.")
    self.demand_node.stats.update_stats(backorder=[1,order_quantity])
    arrived = self.env.now  # when the order arrived, for the waiting time
    if(not self.demand_node.inventory_drop.triggered):
        self.demand_node.inventory_drop.succeed()  # a backorder lowers the inventory position, so let the replenishment policy check
    partial = order_quantity
    if self.min_split < 1:
        partial = int(order_quantity * self.min_split)

    # The customer will wait until this moment and no longer.
    deadline = self.env.now + self.customer_tolerance
    while True:
        available = self._available()
        # When there is enough, the units are taken out of the inventory
        # right here, in the same step as the check, and not later inside
        # ``_process_delivery``. That process only starts once this step is
        # over. If one delivery wakes several waiting customers at the same
        # moment, each of them would otherwise check the inventory before
        # any of them had taken anything, all would see the same units, and
        # all would think those units were theirs.
        if order_quantity <= available: # check if remaining order quantity is available
            get_event, _ = self.demand_node.inventory.get(order_quantity)
            self.env.process(self._process_delivery(order_quantity, customer_id, get_event))
            self.demand_node.stats.update_stats(backorder=[-1,-order_quantity], waiting_time=self.env.now - arrived)
            order_quantity = 0
            break
        # ``partial`` can round down to 0 for a small order, so also check
        # that there is at least one unit to send. Asking the inventory for
        # 0 units is an error in SimPy.
        elif available > 0 and available >= partial: # or else at least the smallest partial delivery is there
            get_event, _ = self.demand_node.inventory.get(available)
            self.env.process(self._process_delivery(available, customer_id, get_event))
            # Partial delivery: the backorder still counts as 1 order (one customer
            # still waiting), and only its units go down. Each delivery adds 1 to the
            # order counts of fulfillment_received and demand_fulfilled (inside
            # _process_delivery), so 1 is taken off them here: the customer's order is
            # counted once, when it is finally filled.
            self.demand_node.stats.update_stats(backorder=[0,-available], demand_fulfilled=[-1,0])
            self.stats.update_stats(fulfillment_received=[-1,0])
            order_quantity -= available # update order quantity
        # Otherwise there is not enough yet, and the customer keeps waiting.
        # No shortage is recorded here: it was recorded once, when the order
        # arrived (in _enqueue_for_tolerance). Recording it again on every
        # check would count the same missing units many times.

        if self.env.now >= deadline:
            break  # the customer's tolerance has run out

        # Wait for whichever comes first: new units arriving at the site, or
        # the end of the customer's tolerance. Without the second part, a
        # customer would only notice that their tolerance had run out when
        # units next arrived — and if none ever arrived, they would wait
        # (and stay counted as a backorder) forever.
        raised_event = self.demand_node.inventory_raised  # the node's current "inventory raised" signal
        if deadline == float('inf'):
            yield raised_event  # infinite tolerance: only new units can end the wait
        else:
            yield raised_event | self.env.timeout(deadline - self.env.now)

        # The "units arrived" signal can fire only once, so after it fires it
        # is swapped for a fresh one. Several customers may be waiting on the
        # same signal; only the first one to wake should swap it. If a later
        # customer swapped it again, the customers who had already moved on
        # to the first fresh signal would be left waiting on one that never
        # fires. Hence the check that the node still holds the signal we
        # waited on. If we woke because our tolerance ran out instead, the
        # signal has not fired and must be left alone for the others.
        if raised_event.triggered and self.demand_node.inventory_raised is raised_event:
            self.demand_node.inventory_raised = self.env.event()

    if order_quantity > 0: # the customer gives up and leaves without the rest of the order
        # They are no longer waiting, so take what they were still owed off
        # the site's current backorders. (The count drops by 1 even after
        # partial deliveries: one customer is one backorder until they are
        # fully served or leave.) Without this, the site would keep placing
        # replenishment orders for a customer who has already gone.
        # The units they were still owed are never delivered: a lost sale.
        self.demand_node.stats.update_stats(backorder=[-1,-order_quantity], lost_sales=[1, order_quantity])
        self.logger.info(f"{self.env.now:.4f}:{self.ID}:Customer{customer_id}: remaining order quantity:{order_quantity} not available! Customer leaves.")

customer

customer(customer_id, order_quantity)

Handle one customer order, by passing it to the helper for its case:

  • the site is disrupted: the order is not placed. A disrupted site takes no new orders, so this is a sale it loses because of the disruption, recorded in its disruption_lost_sales (not as demand received, shortage or backorder). Customers who were already waiting when the disruption started keep waiting, and deliveries already on their way still arrive;
  • the site can fill the whole order: _serve_in_full;
  • otherwise, with consume_available and some units there: _serve_partial_consume;
  • otherwise, for a customer with a tolerance: _enqueue_for_tolerance (or _discard_unservable if the order is too big for the site ever to hold);
  • otherwise: _serve_no_tolerance.
Parameters:
  • customer_id (int) –

    Customer ID for logging purposes.

  • order_quantity (float) –

    Units ordered.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
5465
5466
5467
5468
5469
5470
5471
5472
5473
5474
5475
5476
5477
5478
5479
5480
5481
5482
5483
5484
5485
5486
5487
5488
5489
5490
5491
5492
5493
5494
5495
5496
5497
5498
5499
5500
5501
5502
5503
5504
5505
5506
5507
5508
5509
5510
5511
5512
5513
5514
5515
5516
def customer(self, customer_id, order_quantity):
    """
    Handle one customer order, by passing it to the helper for its case:

    - the site is disrupted: the order is not placed. A disrupted site takes no new orders, so this is a
      sale it loses because of the disruption, recorded in its ``disruption_lost_sales`` (not as demand
      received, shortage or backorder). Customers who were already waiting when the disruption started
      keep waiting, and deliveries already on their way still arrive;
    - the site can fill the whole order: ``_serve_in_full``;
    - otherwise, with ``consume_available`` and some units there: ``_serve_partial_consume``;
    - otherwise, for a customer with a tolerance: ``_enqueue_for_tolerance`` (or ``_discard_unservable``
      if the order is too big for the site ever to hold);
    - otherwise: ``_serve_no_tolerance``.

    Parameters:
        customer_id (int): Customer ID for logging purposes.
        order_quantity (float): Units ordered.

    Returns:
        None
    """
    if self.demand_node.node_status != "active":
        # A disrupted site takes no new orders, from other sites or from
        # customers (the same rule as Node.place_order). The customer goes
        # away, and the site loses the sale. This is not a shortage: the site
        # may have the units, it just cannot take the order. Nothing else is
        # recorded, and the customer's own demand_placed does not count it,
        # as for a replenishment order that is not placed.
        self.logger.info(f"{self.env.now:.4f}:{self.ID}:Customer{customer_id}: {self.demand_node.ID} is disrupted, order of {order_quantity} units not placed (lost sale).")
        self.demand_node.stats.update_stats(disruption_lost_sales=[1, order_quantity])
        return
    available = self._available()
    self.stats.update_stats(demand_placed=[1, order_quantity])
    # The smallest part this customer will take: the whole order when no
    # partial delivery is allowed (min_split >= 1), otherwise the smallest
    # partial delivery. This matches how ``wait_for_order`` computes ``partial``.
    min_chunk = order_quantity if self.min_split >= 1 else int(order_quantity * self.min_split)
    if order_quantity <= available:
        yield from self._serve_in_full(order_quantity, customer_id)
    elif self.consume_available and available > 0:
        yield from self._serve_partial_consume(order_quantity, available, customer_id)
    elif self.customer_tolerance > 0:
        if min_chunk > self.demand_node.inventory.capacity:
            # The order can never be served — even a full inventory cannot
            # cover the smallest part the customer accepts. Discard it
            # rather than create a backorder that would lead to a
            # replenishment order the inventory can't hold.
            yield from self._discard_unservable(order_quantity, customer_id)
        else:
            yield from self._enqueue_for_tolerance(order_quantity, available, customer_id)
    else:
        yield from self._serve_no_tolerance(order_quantity, available, customer_id)

behavior

behavior()

Create customer orders for the whole simulation: start a customer with an order of order_quantity_model() units, wait order_arrival_model() time units, and repeat. So the first customer order arrives at time 0.

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
5518
5519
5520
5521
5522
5523
5524
5525
5526
5527
5528
5529
5530
5531
5532
5533
5534
5535
5536
5537
5538
5539
5540
5541
def behavior(self):
    """
    Create customer orders for the whole simulation: start a customer with an order of
    ``order_quantity_model()`` units, wait ``order_arrival_model()`` time units, and repeat. So the first
    customer order arrives at time 0.

    Parameters:
        None

    Attributes:
        None

    Returns:
        None
    """
    customer_id = 1 # customer ID
    while True:
        order_time = self.order_arrival_model()
        order_quantity = self.order_quantity_model() 
        validate_non_negative(name=f"{self.ID}:order_arrival_model()", value=order_time)
        validate_positive(name=f"{self.ID}:order_quantity_model()", value=order_quantity)
        self.env.process(self.customer(f"{customer_id}", order_quantity)) # create a customer
        customer_id += 1 # increment customer ID
        yield self.env.timeout(order_time) # wait for the next order arrival

set_seed

set_seed(seed)

Seed the library-wide default RNG used for probabilistic node/link disruption.

Components built without an explicit rng argument draw from this RNG, so seeding it once before constructing the network is enough to make a run reproducible. Components that were passed an explicit rng are unaffected.

Parameters:
  • seed –

    any value accepted by random.Random.seed (typically int).

Returns:
  • –

    None

Source code in src/SupplyNetPy/Components/core.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def set_seed(seed):
    """
    Seed the library-wide default RNG used for probabilistic node/link disruption.

    Components built without an explicit ``rng`` argument draw from this RNG, so
    seeding it once before constructing the network is enough to make a run
    reproducible. Components that were passed an explicit ``rng`` are unaffected.

    Parameters:
        seed: any value accepted by ``random.Random.seed`` (typically ``int``).

    Returns:
        None
    """
    _rng.seed(seed)

validate_positive

validate_positive(name: str, value)

Check if the value is positive and raise ValueError if not.

Parameters:
  • name (str) –

    name of the variable

  • value –

    value to check

Raises:
  • ValueError –

    if value is not positive

Source code in src/SupplyNetPy/Components/core.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def validate_positive(name: str, value):
    """
    Check if the value is positive and raise ValueError if not.

    Parameters:
        name (str): name of the variable
        value: value to check   

    Raises:
        ValueError: if value is not positive
    """
    if value <= 0:
        global_logger.error(f"{name} must be positive.")
        raise ValueError(f"{name} must be positive.")

validate_non_negative

validate_non_negative(name: str, value)

Check if the value is non-negative and raise ValueError if not.

Parameters:
  • name (str) –

    name of the variable

  • value –

    value to check

Raises:
  • ValueError –

    if value is negative

Source code in src/SupplyNetPy/Components/core.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
def validate_non_negative(name: str, value):
    """
    Check if the value is non-negative and raise ValueError if not.

    Parameters:
        name (str): name of the variable
        value: value to check

    Raises:
        ValueError: if value is negative
    """
    if value < 0:
        global_logger.error(f"{name} cannot be negative.")
        raise ValueError(f"{name} cannot be negative.")

validate_number

validate_number(name: str, value) -> None

Check if the value is a number and raise ValueError if not.

Parameters:
  • name (str) –

    name of the variable

  • value –

    value to check

Raises:
  • ValueError –

    if value is not a number

Source code in src/SupplyNetPy/Components/core.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def validate_number(name: str, value) -> None:
    """
    Check if the value is a number and raise ValueError if not.

    Parameters:
        name (str): name of the variable
        value: value to check

    Raises:
        ValueError: if value is not a number
    """
    if not isinstance(value, numbers.Number):
        global_logger.error(f"function {name}() must return a number (an int or a float).")
        raise ValueError(f"function {name}() must be a number (an int or a float).")

ensure_numeric_callable

ensure_numeric_callable(name: str, value)

Turn a value that is "a number, or a zero-arg function returning a number" into a zero-arg function.

Many constructors accept either a plain number or a zero-arg function (lead times, arrival intervals, recovery times). The earlier version simply did

if not callable(value):
    value = lambda val=value: val

which accepted any function at all — even a class like int or a generator function — so the later value() call could fail deep inside a running simulation, far from where the mistake was made. This helper avoids that by calling the function once when it is set up and checking the result is a real number, so a bad value is reported right away, at construction, with a clear message naming the parameter at fault.

Parameters:
  • name (str) –

    Parameter name used in error messages.

  • value –

    A number or a zero-arg callable returning a number.

Returns:
  • callable –

    A zero-arg callable. If value was a scalar it is wrapped; if it was already callable the original is returned unchanged.

Raises:
  • TypeError –

    If value is callable but invoking it raises (the original exception is chained).

  • ValueError –

    If the (wrapped) callable returns a non-numeric value.

Note

The validation invocation samples the callable once at construction time. For stateful generators or iterators (rather than pure functions) this advances state before the simulation starts; if that matters, wrap the iterator in a closure that materialises the first sample lazily, or pass a numeric scalar instead.

Source code in src/SupplyNetPy/Components/core.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def ensure_numeric_callable(name: str, value):
    """
    Turn a value that is "a number, or a zero-arg function returning a number"
    into a zero-arg function.

    Many constructors accept either a plain number or a zero-arg function (lead
    times, arrival intervals, recovery times). The earlier version simply did

    ```python
    if not callable(value):
        value = lambda val=value: val
    ```

    which accepted any function at all — even a class like ``int`` or a
    generator function — so the later ``value()`` call could fail deep inside a
    running simulation, far from where the mistake was made. This helper avoids
    that by calling the function once when it is set up and checking the result
    is a real number, so a bad value is reported right away, at construction,
    with a clear message naming the parameter at fault.

    Parameters:
        name (str): Parameter name used in error messages.
        value: A number or a zero-arg callable returning a number.

    Returns:
        callable: A zero-arg callable. If ``value`` was a scalar it is wrapped;
            if it was already callable the original is returned unchanged.

    Raises:
        TypeError: If ``value`` is callable but invoking it raises (the
            original exception is chained).
        ValueError: If the (wrapped) callable returns a non-numeric value.

    Note:
        The validation invocation samples the callable once at construction
        time. For stateful generators or iterators (rather than pure
        functions) this advances state before the simulation starts; if
        that matters, wrap the iterator in a closure that materialises the
        first sample lazily, or pass a numeric scalar instead.
    """
    if not callable(value):
        scalar = value
        value = lambda val=scalar: val
    try:
        sample = value()
    except Exception as e:
        global_logger.error(f"{name}: callable raised {type(e).__name__} on validation call: {e}")
        raise TypeError(
            f"{name} must be a number or a zero-arg callable returning a number; "
            f"calling it raised {type(e).__name__}: {e}"
        ) from e
    if not isinstance(sample, numbers.Number):
        global_logger.error(
            f"{name} must be a number or a zero-arg callable returning a number; "
            f"got {type(sample).__name__} ({sample!r})."
        )
        raise ValueError(
            f"{name} must be a number or a zero-arg callable returning a number; "
            f"got {type(sample).__name__}."
        )
    return value