Custom Events#
Events are the behavioral rules of the simulation, the “systems” in BAM Engine’s ECS architecture. Each event reads role data, performs calculations, and writes results back. Custom events let you add new economic behaviors or modify existing ones.
Quick Example#
from bamengine import event, ops, Simulation
@event(after="firms_collect_revenue")
class FirmsPayBonus:
"""Pay a 5% bonus to firms with positive net profit."""
def execute(self, sim: Simulation) -> None:
bor = sim.get_role("Borrower")
bonus = ops.where(bor.net_profit > 0, ops.multiply(bor.net_profit, 0.05), 0)
ops.assign(bor.total_funds, ops.add(bor.total_funds, bonus))
The @event Decorator#
The @event decorator registers an event class and optionally specifies where
it should be inserted in the pipeline.
Standalone event (no automatic placement):
@event
class MyEvent:
def execute(self, sim): ...
Hook after an existing event:
@event(after="firms_pay_dividends")
class AfterDividends:
def execute(self, sim): ...
Hook before an existing event:
@event(before="firms_run_production")
class BeforeProduction:
def execute(self, sim): ...
Replace an existing event entirely:
@event(replace="consumers_calc_propensity")
class MyPropensityRule:
def execute(self, sim): ...
Custom name (defaults to class name converted to snake_case):
@event(name="custom_tax_event", after="firms_validate_debt_commitments")
class FirmsTaxProfits:
def execute(self, sim): ...
The execute() Method#
Every event must implement an execute method:
def execute(self, sim: Simulation) -> None: ...
The sim parameter provides access to all simulation state. The method should
not return a value; all effects happen through mutations via ops.assign()
or relationship methods.
Accessing Simulation State#
Inside execute(), the sim object exposes:
Roles (agent data arrays):
prod = sim.get_role("Producer") # or sim.prod
wrk = sim.get_role("Worker") # or sim.wrk
emp = sim.get_role("Employer") # or sim.emp
bor = sim.get_role("Borrower") # or sim.bor
con = sim.get_role("Consumer") # or sim.con
sh = sim.get_role("Shareholder") # or sim.sh
lend = sim.get_role("Lender") # or sim.lend
Economy (aggregate state):
sim.ec.avg_mkt_price # Current average market price
sim.ec.min_wage # Current minimum wage
sim.ec.collapsed # Whether the economy has collapsed
Configuration (model parameters):
sim.theta # Contract length
sim.delta # Dividend payout ratio
sim.h_rho # Production shock width
sim.n_firms # Number of firms
RNG (random number generator):
shock = sim.rng.uniform(0, 0.1, size=sim.n_firms)
# or: shock = ops.uniform(sim.rng, 0, 0.1, size=sim.n_firms)
Relationships (loan data):
loans = sim.get_relationship("LoanBook") # or sim.lb
Extension parameters (custom parameters passed at init):
sigma_min = sim.sigma_min # Accesses extra_params["sigma_min"]
Pipeline Integration#
Hook activation is explicit. Declaring @event(after="...") stores the
hook as class metadata but does not modify the pipeline. You must call
use_events() to apply hooks:
import bamengine as bam
sim = bam.Simulation.init(seed=42)
sim.use_events(FirmsPayBonus) # NOW the hook is applied
# Multiple events at once
sim.use_events(EventA, EventB, EventC)
Warning
Forgetting sim.use_events() is the most common mistake when working with
custom events. The event will be registered but never executed.
Manual pipeline methods (for events without hooks):
sim.pipeline.insert_after("target_event", "my_event")
sim.pipeline.insert_before("target_event", "my_event")
sim.pipeline.remove("event_to_remove")
sim.pipeline.replace("old_event", "new_event")
Worked Example: Inventory Carrying Cost#
This complete example adds an inventory carrying cost to firms, a charge for holding unsold goods. It demonstrates the full workflow: define a role, define an event, hook it into the pipeline, and run.
import bamengine as bam
from bamengine import event, ops, role
from bamengine.typing import Float
# 1. Define a role to track carrying costs
@role
class InventoryCost:
carrying_cost: Float
# 2. Define an event that computes and deducts carrying costs
@event(after="firms_run_production")
class FirmsPayCarryingCost:
"""Charge firms for holding unsold inventory."""
def execute(self, sim):
prod = sim.prod
bor = sim.bor
ic = sim.get_role("InventoryCost")
# 2% of inventory value per period
cost = ops.multiply(prod.inventory, ops.multiply(prod.price, 0.02))
ops.assign(ic.carrying_cost, cost)
# Deduct from available funds
ops.assign(bor.total_funds, ops.subtract(bor.total_funds, cost))
# 3. Set up and run
sim = bam.Simulation.init(seed=42)
sim.use_role(InventoryCost)
sim.use_events(FirmsPayCarryingCost)
results = sim.run(
n_periods=100,
collect={"InventoryCost": True, "Economy": True, "aggregate": "mean"},
)
Built-in Events#
BAM Engine includes 37 built-in events organized in 8 phases. Below is a summary; see The BAM Model for the economic logic of each phase.
Phase 1: Planning
|
Set production targets from demand/inventory signals |
|
Calculate cost-covering price floor (planning phase) |
|
Adjust price based on inventory and market position |
|
Calculate workforce needed for production target |
|
Post vacancies to fill labor gap |
|
Fire workers when desired labor < current labor |
Phase 2: Labor Market
|
Compute economy-wide inflation rate |
|
Revise minimum wage for inflation |
|
Firms set wage offers with random markup |
|
Workers select firms to apply to |
|
Batch matching with conflict resolution (x max_M) |
|
Calculate total wage obligations |
Phase 3: Credit Market
|
Banks set lending capacity from equity |
|
Banks set interest rates with cost shock |
|
Firms calculate borrowing needs |
|
Calculate leverage ratio for credit evaluation |
|
Firms rank banks by interest rate |
|
Batch credit matching by fragility order (x max_H) |
|
Fire workers if credit insufficient |
Phase 4: Production
|
Deduct wage bill from firm funds |
|
Workers receive wages as income |
|
Produce goods: output = productivity x labor |
|
Update economy-wide average price |
|
Decrement contract duration, handle expiration |
Phase 5: Goods Market
|
Calculate consumption propensity from savings ratio |
|
Allocate spending budget |
|
Select firms to visit (loyalty + random) |
|
Batch-sequential shopping (handles all Z visits) |
|
Save unspent budget |
Phase 6: Revenue
|
Collect sales revenue, calculate gross profit |
|
Repay loans, calculate net profit |
|
Distribute dividends from positive profits |
Phase 7: Bankruptcy
|
Add retained profits to net worth |
|
Detect and remove insolvent firms |
|
Detect and remove insolvent banks |
Phase 8: Entry
|
Create new firms to replace bankrupt ones |
|
Create new banks to replace bankrupt ones |
Tips#
Always use ``ops.assign()`` for role mutations; direct assignment (
role.field = value) silently fails to update the shared arrayAlways use ``sim.rng`` for randomness; never
numpy.randomdirectlyEvents are stateless: Don’t store state on
self. If you need state that persists across periods, use a role field.Hook activation is explicit: Call
sim.use_events(MyEvent)afterSimulation.init(); hooks declared in@event(after=...)are just metadata until activated
See also
Operations Module for the ops module reference
Pipeline Customization for pipeline customization details
Custom Roles for defining new agent state
Model Extensions for complete extension examples