def update_purchase_story(the_circus): """ Adds some operations to the existing customer purchase story in order to trigger a POS restock if their stock level gets low """ purchase_story = the_circus.get_story("purchase") pos = the_circus.populations["point_of_sale"] # trigger_prop_func(level) specifies the probability of re-stocking as a # function the stock level trigger_prop_func = ops.bounded_sigmoid( # below x_min, probability is one, and decrements as x increases incrementing=False, # probability is 1 when level=2, 0 when level 10 and after x_min=2, x_max=10, # this controls the shape of the S curve in between shape=10) # Wraps the sigmoid into a dependent trigger, i.e.: # - a generator, i.e producing random values # - of booleans, hence the name "trigger" # - dependent, i.e. as a function of story_data field at execution time trigger_gen = gen.DependentTriggerGenerator( value_to_proba_mapper=trigger_prop_func, seed=next(the_circus.seeder)) # since those operations are added after the FieldLogger, the fields they # create will not be appended to the story_data purchase_story.append_operations( pos.get_relationship("items").ops.get_neighbourhood_size( from_field="POS_ID", named_as="POS_STOCK"), # generates random booleans with probability related to the stock level trigger_gen.ops.generate( observed_field="POS_STOCK", named_as="SHOULD_RESTOCK"), # trigger the restock story of the POS whose SHOULD_RESTOCK field is # now true the_circus.get_story("restock").ops.force_act_next( member_id_field="POS_ID", condition_field="SHOULD_RESTOCK") )
def test_bounded_sigmoid_should_broadcast_as_a_ufunc(): freud = operations.bounded_sigmoid(x_min=2, x_max=15, shape=5, incrementing=True) # passing a range of x should yield a range of y's for y in freud(np.linspace(-100, 2, 200)): assert y == 0 # all values after x_max should be 1 for y in freud(np.linspace(15, 100, 200)): assert y == 1 # all values in between should be in [0,1 ] for y in freud(np.linspace(0, 1, 200)): assert 0 <= y <= 1
def test_decreasing_bounded_sigmoid_must_reach_min_and_max_at_boundaries(): freud = operations.bounded_sigmoid(x_min=2, x_max=15, shape=5, incrementing=False) # all values before x_min should be 1 for x in np.linspace(-100, 2, 200): assert freud(x) == 1 # all values after x_max should be 0 for x in np.linspace(15, 100, 200): assert freud(x) == 0 # all values in between should be in [0,1 ] for x in np.linspace(0, 1, 200): assert 0 <= freud(x) <= 1
def add_bulk_restock_actions(circus, params, buyer_actor_name, seller_actor_name): buyer = circus.actors[buyer_actor_name] seller = circus.actors[seller_actor_name] pos_per_buyer = circus.actors["pos"].size / buyer.size for product, description in params["products"].items(): action_name = "{}_{}_bulk_purchase".format(buyer_actor_name, product) upper_level_restock_action_name = "{}_{}_bulk_purchase".format( seller_actor_name, product) logging.info("creating {} action".format(action_name)) # generator of item prices and type item_price_gen = random_generators.NumpyRandomGenerator( method="choice", a=description["item_prices"], seed=next(circus.seeder)) item_prices_gen = random_generators.DependentBulkGenerator( element_generator=item_price_gen) item_type_gen = random_generators.NumpyRandomGenerator( method="choice", a=circus.actors[product].ids, seed=next(circus.seeder)) item_types_gen = random_generators.DependentBulkGenerator( element_generator=item_type_gen) tx_gen = random_generators.SequencialGenerator( prefix="_".join(["TX", buyer_actor_name, product])) tx_seq_gen = random_generators.DependentBulkGenerator( element_generator=tx_gen) # trigger for another bulk purchase done by the seller if their own # stock get low seller_low_stock_bulk_purchase_trigger = random_generators.DependentTriggerGenerator( value_to_proba_mapper=operations.bounded_sigmoid( x_min=pos_per_buyer, x_max=description["max_pos_stock_triggering_pos_restock"] * pos_per_buyer, shape=description["restock_sigmoid_shape"], incrementing=False)) # bulk size distribution is a scaled version of POS bulk size distribution bulk_size_gen = scale_quantity_gen(stock_size_gen=circus.generators[ "pos_{}_bulk_size_gen".format(product)], scale_factor=pos_per_buyer) build_purchase_action = circus.create_story( name=action_name, initiating_actor=buyer, actorid_field="BUYER_ID", # no timer or activity: dealers bulk purchases are triggered externally ) build_purchase_action.set_operations( circus.clock.ops.timestamp(named_as="TIME"), buyer.get_relationship("{}__provider".format(product)) .ops.select_one(from_field="BUYER_ID", named_as="SELLER_ID"), bulk_size_gen.ops.generate(named_as="REQUESTED_BULK_SIZE"), buyer.get_relationship(product).ops .get_neighbourhood_size( from_field="BUYER_ID", named_as="OLD_BUYER_STOCK"), # TODO: the perfect case would prevent to go over max_stock at this point # selecting and removing Sims from dealers seller.get_relationship(product).ops \ .select_many( from_field="SELLER_ID", named_as="ITEM_IDS", quantity_field="REQUESTED_BULK_SIZE", # if an item is selected, it is removed from the dealer's stock pop=True, # TODO: put this back to False and log the failed purchases discard_missing=True), # and adding them to the buyer buyer.get_relationship(product).ops.add_grouped( from_field="BUYER_ID", grouped_items_field="ITEM_IDS"), # We do not track the old and new stock of the dealer since the result # is misleading: since all purchases are performed in parallel, # if a dealer is selected several times, its stock level after the # select_many() is the level _after_ all purchases are done, which is # typically not what we want to include in the log. buyer.get_relationship(product).ops \ .get_neighbourhood_size( from_field="BUYER_ID", named_as="NEW_BUYER_STOCK"), # actual number of bought items might be different due to out of stock operations.Apply(source_fields="ITEM_IDS", named_as="BULK_SIZE", f=lambda s: s.map(len), f_args="series"), # Generate some item prices. Note that the same items will have a # different price through the whole distribution chain item_prices_gen.ops.generate( named_as="ITEM_PRICES", observed_field="BULK_SIZE" ), item_types_gen.ops.generate( named_as="ITEM_TYPES", observed_field="BULK_SIZE" ), tx_seq_gen.ops.generate( named_as="TX_IDS", observed_field="BULK_SIZE" ), operations.FieldLogger(log_id="{}_stock".format(action_name), cols=["TIME", "BUYER_ID", "SELLER_ID", "OLD_BUYER_STOCK", "NEW_BUYER_STOCK", "BULK_SIZE"]), operations.FieldLogger(log_id=action_name, cols=["TIME", "BUYER_ID", "SELLER_ID"], exploded_cols=["TX_IDS", "ITEM_IDS", "ITEM_PRICES", "ITEM_TYPES"]), trigger_action_if_low_stock( circus, stock_relationship=seller.get_relationship(product), actor_id_field="SELLER_ID", restock_trigger=seller_low_stock_bulk_purchase_trigger, triggered_action_name=upper_level_restock_action_name ) )
def add_purchase_actions(circus, params): customers = circus.actors["customers"] pos = circus.actors["pos"] sites = circus.actors["sites"] for product, description in params["products"].items(): logging.info("creating customer {} purchase action".format(product)) purchase_timer_gen = DefaultDailyTimerGenerator( circus.clock, next(circus.seeder)) max_activity = purchase_timer_gen.activity( n=1, per=pd.Timedelta( days=description["customer_purchase_min_period_days"])) min_activity = purchase_timer_gen.activity( n=1, per=pd.Timedelta( days=description["customer_purchase_max_period_days"])) purchase_activity_gen = NumpyRandomGenerator( method="uniform", low=1 / max_activity, high=1 / min_activity, seed=next(circus.seeder)).map(f=lambda per: 1 / per) low_stock_bulk_purchase_trigger = DependentTriggerGenerator( value_to_proba_mapper=bounded_sigmoid( x_min=1, x_max=description["max_pos_stock_triggering_pos_restock"], shape=description["restock_sigmoid_shape"], incrementing=False)) item_price_gen = NumpyRandomGenerator(method="choice", a=description["item_prices"], seed=next(circus.seeder)) action_name = "customer_{}_purchase".format(product) purchase_action = circus.create_story( name=action_name, initiating_actor=customers, actorid_field="CUST_ID", timer_gen=purchase_timer_gen, activity_gen=purchase_activity_gen) purchase_action.set_operations( customers.ops.lookup(id_field="CUST_ID", select={"CURRENT_SITE": "SITE"}), sites.get_relationship("POS").ops.select_one( from_field="SITE", named_as="POS", weight=pos.get_attribute_values("ATTRACTIVENESS"), # TODO: this means customer in a location without POS do not buy # anything => we could add a re-try mechanism here discard_empty=True), sites.get_relationship("CELLS").ops.select_one(from_field="SITE", named_as="CELL_ID"), # injecting geo level 2 and distributor in purchase action: # this is only required for approximating targets of that # distributor sites.ops.lookup(id_field="SITE", select={ "GEO_LEVEL_2": "geo_level2_id", "{}__dist_l1".format(product): "distributor_l1" }), pos.get_relationship(product).ops.select_one( from_field="POS", named_as="INSTANCE_ID", pop=True, discard_empty=False), circus.actors[product].ops.select_one(named_as="PRODUCT_ID"), Apply(source_fields="INSTANCE_ID", named_as="FAILED_SALE_OUT_OF_STOCK", f=pd.isnull, f_args="series"), SequencialGenerator( prefix="TX_CUST_{}".format(product)).ops.generate( named_as="TX_ID"), item_price_gen.ops.generate(named_as="VALUE"), circus.clock.ops.timestamp(named_as="TIME"), FieldLogger(log_id=action_name), patterns.trigger_action_if_low_stock( circus, stock_relationship=pos.get_relationship(product), actor_id_field="POS", restock_trigger=low_stock_bulk_purchase_trigger, triggered_action_name="pos_{}_bulk_purchase".format(product)), )