CiwPython / Ciw

Ciw is a simulation library for open queueing networks.
http://ciw.readthedocs.io
MIT License
148 stars 42 forks source link

[Feature Request] Time/State-Dependent Baulking #234

Closed galenseilis closed 6 months ago

galenseilis commented 9 months ago

I noticed that arrival_node.ArrivalNode.decide_baulk is responsible for making a decision about whether to baulk an instance of Individual.

Here is the implementation:

    def decide_baulk(self, next_node, next_individual):
        """
        Either makes an individual baulk, or sends the individual
        to the next node.
        """
        if next_node.baulking_functions[self.next_class] is None:
            self.send_individual(next_node, next_individual)
        else:
            rnd_num = random()
            if rnd_num < next_node.baulking_functions[self.next_class](next_node.number_of_individuals):
                self.record_baulk(next_node, next_individual)
                self.simulation.nodes[-1].accept(next_individual, completed=False)
            else:
                self.send_individual(next_node, next_individual)

Baulking is conventionally thought of as customers deciding not to join the queue if it is too long, which is may be why it was implemented this way. I first learn from the docs that the baulking function just takes the length of the queue and returns a probability of baulking.

Here is the example from the documentation:

def probability_of_baulking(n):
    if n < 3:
        return 0.0
    if n < 7:
        return 0.5
    return 1.0

That function actually uses n. We could of course make baulking functions that just ignore n. Here is a frivolous example that we would not be interested in practice. In this case there is a probability of 1 that the individual will baulk if the int of the unix time is prime, otherwise there is a 0.5 probability of baulking.

import math
import time

def is_prime(n):
    if n % 2 == 0 and n > 2: 
        return False
    return all(n % i for i in range(3, int(math.sqrt(n)) + 1, 2))

def probability_of_baulking(n):
    int_unix_time = int(time.time())

    if is_prime(int_unix_time):
        return 1.0

    return 0.5

But what is of practical interest to me is baulking that depends on time, or the individual's properties, or other properties of next_node, or even some entirely non-local properties. And I don't want to patch this unless I have to.

How feasible is replacing

next_node.baulking_functions[self.next_class](next_node.number_of_individuals)

with

next_node.baulking_functions[self.next_class](next_node, next_individual)?

Having next_node available is a handy default behaviour and otherwise anything about time or state can be accessed via next_individual.simulation. It would allow for a form of "generalized baulking" where the reason for not joining the queue could be for reasons other than the length of the waitlist.

I realize there pretty much the same thing could be achieved via state-dependent routing.

geraintpalmer commented 9 months ago

Hi, I love this idea. In fact, the baulking function could just take in the simulation object itself, and then have access to any property of the system it needs, including the time?

e.g.

def probability_of_baulking(Q, node_id):
    if len(Q.nodes[node_id].all_customers) < 3:
        return 0.0
    if len(Q.nodes[node_id].all_customers) < 7:
        return 0.5
    return 1.0

What do you think?

galenseilis commented 9 months ago

Hi, I love this idea. In fact, the baulking function could just take in the simulation object itself, and then have access to any property of the system it needs, including the time?

e.g.

def probability_of_baulking(Q, node_id):
    if len(Q.nodes[node_id].all_customers) < 3:
        return 0.0
    if len(Q.nodes[node_id].all_customers) < 7:
        return 0.5
    return 1.0

What do you think?

Thank you for considering my ideas! 😊

I can see how passing the simulation to probability_of_baulking naturally gives access to the top-level. From there the time and state can be accessed. I'm wondering, how would probability_of_baulking know which individual is being considered for baulking. That would be important for individual-dependent baulking behaviour. Would there be a natural way to access that information if the function signature was probability_of_baulking(Q, node_id)?

Here is my thinking for the function signature I suggested. Using the probability_of_baulking(next_node, next_individual) signature would communicate the pair of node and individual in consideration. In my opinion this is a natural default. However, if the time was needed, one could always use next_individual.simulation.current_time since every individual has a reference of simulation as an instance attribute. Similarly, next_individual.simulation.nodes should give access to arbitrary state information.

I suppose another option would be something like probability_of_baulking(Q, node_id, ind_id), but that means performing a search for the instance of the next node if we wanted it. It doesn't seem like a good option.

I know, I am slightly modifying the term type signature to a related note I am terming "function signature". I really am just talking about what parameters the function has.

Further discussion on this is welcome! I am still unfamiliar with most of the mechanisms in Ciw, so I may be missing something.

geraintpalmer commented 6 months ago

Implemented here: 9d00262de505e163405f135bd630f26278fee959