Note
Go to the end to download the full example code.
Custom Roles#
This example demonstrates how to create custom roles (components) to extend BAM Engine with new agent behaviors and state. Custom roles are essential for modeling economic phenomena not covered by the built-in roles.
You’ll learn to:
Define roles using the
@roledecoratorUse type annotations for NumPy arrays
Handle optional fields with defaults
Register and retrieve custom roles
Attach custom roles using
sim.use_role()Integrate roles with simulations
What are Roles?#
In BAM Engine’s ECS (Entity-Component-System) architecture:
Agents are lightweight entities (just IDs and types)
Roles are components that hold agent state as NumPy arrays
Events are systems that operate on roles
Roles bundle related state together. For example, the built-in
Producer role holds production-related state (price, inventory, output).
import numpy as np
import bamengine as bam
from bamengine import Agent, Bool, Float, Int, get_role, ops, role
# Check built-in roles
print("Built-in roles:")
for name in ["Producer", "Worker", "Consumer", "Borrower", "Lender", "Employer"]:
try:
r = get_role(name)
print(f" {name}: {list(r.__dataclass_fields__.keys())[:4]}...")
except KeyError:
print(f" {name}: not registered")
Built-in roles:
Producer: ['name', 'production', 'production_prev', 'inventory']...
Worker: ['name', 'employer', 'employer_prev', 'wage']...
Consumer: ['name', 'income', 'savings', 'income_to_spend']...
Borrower: ['name', 'net_worth', 'total_funds', 'wage_bill']...
Lender: ['name', 'equity_base', 'credit_supply', 'interest_rate']...
Employer: ['name', 'desired_labor', 'current_labor', 'wage_offer']...
Simple Custom Role#
The @role decorator handles everything: dataclass creation,
Role inheritance, and registration.
@role
class Inventory:
"""Inventory management role for firms.
Tracks stock levels, reorder points, and delivery status.
Attach to firms to enable inventory management behavior.
"""
# Current stock level (Float for continuous quantities)
stock_level: Float
# Reorder threshold (when stock falls below this, reorder)
reorder_point: Float
# Maximum storage capacity
max_capacity: Float
print("\nInventory role created!")
print(f" Fields: {list(Inventory.__dataclass_fields__.keys())}")
# Verify it's registered
inv_role = get_role("Inventory")
print(f" Registered: {inv_role is Inventory}")
Inventory role created!
Fields: ['name', 'stock_level', 'reorder_point', 'max_capacity']
Registered: True
Role with Multiple Field Types#
Roles can use all type aliases: Float, Int, Bool, and Agent. Each serves a specific purpose.
@role
class SupplyChain:
"""Supply chain management role for firms.
Models upstream relationships and delivery logistics.
"""
# Monetary values and quantities (Float)
order_value: Float # Value of pending orders
unit_cost: Float # Cost per unit from supplier
delivery_cost: Float # Shipping cost
# Counts and durations (Int)
pending_orders: Int # Number of undelivered orders
days_to_delivery: Int # Days until next delivery
contract_length: Int # Periods remaining on supplier contract
# Status flags (Bool)
has_supplier: Bool # Whether firm has established supplier
awaiting_delivery: Bool # Expecting a delivery soon
is_preferred_customer: Bool # Gets priority from supplier
# Agent references (Agent)
supplier_id: Agent # ID of supplying firm (-1 if none)
backup_supplier_id: Agent # Secondary supplier (-1 if none)
print("\nSupplyChain role created with typed fields:")
print(" Float fields: order_value, unit_cost, delivery_cost")
print(" Int fields: pending_orders, days_to_delivery, contract_length")
print(" Bool fields: has_supplier, awaiting_delivery, is_preferred_customer")
print(" Agent fields: supplier_id, backup_supplier_id")
SupplyChain role created with typed fields:
Float fields: order_value, unit_cost, delivery_cost
Int fields: pending_orders, days_to_delivery, contract_length
Bool fields: has_supplier, awaiting_delivery, is_preferred_customer
Agent fields: supplier_id, backup_supplier_id
Optional Fields with Defaults#
Use Optional and field() for optional fields that may not
always be populated.
from dataclasses import field
@role
class QualityControl:
"""Quality control role for production firms.
Tracks defect rates and quality investment. Some fields are
optional and only populated when quality tracking is enabled.
"""
# Required fields
defect_rate: Float # Fraction of defective output (0-1)
quality_score: Float # Overall quality rating (0-100)
# Optional fields (may be None if feature not used)
inspection_results: Float | None = field(default=None)
warranty_claims: Int | None = field(default=None)
# Optional scratch buffers for computation
_temp_buffer: Float | None = field(default=None)
print("\nQualityControl role with optional fields:")
print(" Required: defect_rate, quality_score")
print(" Optional: inspection_results, warranty_claims, _temp_buffer")
QualityControl role with optional fields:
Required: defect_rate, quality_score
Optional: inspection_results, warranty_claims, _temp_buffer
Instantiating Custom Roles#
Create role instances by passing NumPy arrays for each field.
n_firms = 5
# Create Inventory role instance
inventory = Inventory(
stock_level=np.array([100.0, 50.0, 75.0, 200.0, 30.0]),
reorder_point=np.array([25.0, 20.0, 30.0, 50.0, 15.0]),
max_capacity=np.array([500.0, 300.0, 400.0, 600.0, 200.0]),
)
print("\nInventory instance for 5 firms:")
print(f" Stock levels: {inventory.stock_level}")
print(f" Reorder points: {inventory.reorder_point}")
# Create SupplyChain instance
supply = SupplyChain(
order_value=np.array([1000.0, 500.0, 750.0, 2000.0, 300.0]),
unit_cost=np.array([10.0, 15.0, 12.0, 8.0, 20.0]),
delivery_cost=np.array([50.0, 40.0, 45.0, 60.0, 35.0]),
pending_orders=np.array([2, 0, 1, 3, 0], dtype=np.int64),
days_to_delivery=np.array([3, 0, 5, 2, 0], dtype=np.int64),
contract_length=np.array([12, 6, 8, 24, 0], dtype=np.int64),
has_supplier=np.array([True, True, True, True, False]),
awaiting_delivery=np.array([True, False, True, True, False]),
is_preferred_customer=np.array([True, False, False, True, False]),
supplier_id=np.array([2, 3, 4, 1, -1], dtype=np.intp),
backup_supplier_id=np.array([3, -1, 2, 0, -1], dtype=np.intp),
)
print("\nSupplyChain instance:")
print(f" Supplier IDs: {supply.supplier_id}")
print(f" Has supplier: {supply.has_supplier}")
Inventory instance for 5 firms:
Stock levels: [100. 50. 75. 200. 30.]
Reorder points: [25. 20. 30. 50. 15.]
SupplyChain instance:
Supplier IDs: [ 2 3 4 1 -1]
Has supplier: [ True True True True False]
Modifying Role State#
Role fields are NumPy arrays that can be modified in place.
print("\nModifying inventory state:")
print(f" Before: {inventory.stock_level}")
# Simulate consumption (reduce stock)
consumption = np.array([20.0, 10.0, 15.0, 40.0, 5.0])
inventory.stock_level -= consumption
print(f" After consumption: {inventory.stock_level}")
# Check which firms need to reorder
needs_reorder = inventory.stock_level < inventory.reorder_point
print(f" Needs reorder: {needs_reorder}")
print(f" Firms needing reorder: {np.where(needs_reorder)[0]}")
Modifying inventory state:
Before: [100. 50. 75. 200. 30.]
After consumption: [ 80. 40. 60. 160. 25.]
Needs reorder: [False False False False False]
Firms needing reorder: []
Role with Methods#
Roles can include helper methods for common operations.
@role
class CreditRating:
"""Credit rating role for borrower assessment.
Tracks creditworthiness metrics and rating history.
"""
# Credit metrics
score: Float # Credit score (0-100)
default_history: Float # Historical default rate
debt_to_income: Float # Debt-to-income ratio
# Rating category (encoded as int: 0=AAA, 1=AA, 2=A, etc.)
rating_category: Int
def is_investment_grade(self) -> Bool:
"""Check if rating is investment grade (BBB or better)."""
return self.rating_category <= 3
def needs_review(self) -> Bool:
"""Check if credit needs manual review."""
return (self.score < 50) | (self.debt_to_income > 0.5)
# Create instance
credit = CreditRating(
score=np.array([85.0, 45.0, 72.0, 30.0, 90.0]),
default_history=np.array([0.0, 0.15, 0.05, 0.3, 0.0]),
debt_to_income=np.array([0.3, 0.6, 0.4, 0.8, 0.2]),
rating_category=np.array([1, 5, 3, 6, 0], dtype=np.int64),
)
print("\nCreditRating with methods:")
print(f" Scores: {credit.score}")
print(f" Investment grade: {credit.is_investment_grade()}")
print(f" Needs review: {credit.needs_review()}")
CreditRating with methods:
Scores: [85. 45. 72. 30. 90.]
Investment grade: [ True False True False True]
Needs review: [False True False True False]
Traditional Syntax (Alternative)#
The @role decorator is syntactic sugar. You can also use
explicit inheritance and dataclass decoration.
from dataclasses import dataclass
from bamengine.core import Role
# This is equivalent to using @role
@dataclass(slots=True)
class TraditionalInventory(Role):
"""Inventory role using traditional syntax."""
stock_level: Float
reorder_point: Float
max_capacity: Float
print("\nTraditional syntax role:")
print(f" Fields: {list(TraditionalInventory.__dataclass_fields__.keys())}")
print(f" Is subclass of Role: {issubclass(TraditionalInventory, Role)}")
# Both approaches produce equivalent results
# The @role decorator is recommended for cleaner code
Traditional syntax role:
Fields: ['name', 'stock_level', 'reorder_point', 'max_capacity']
Is subclass of Role: True
Attaching Roles with use_role()#
The use_role() method provides a clean way to attach custom roles
to a simulation. It automatically creates zeroed arrays of the correct size.
@role
class RnDState:
"""R&D state role for demonstrating use_role()."""
sigma: Float # R&D share of profits
rnd_intensity: Float # Expected productivity gain (mu)
productivity_increment: Float # Actual increment (z)
fragility: Float # Financial fragility W/A
# Create simulation
demo_sim = bam.Simulation.init(n_firms=50, n_households=250, seed=42)
# use_role() creates the instance with zeroed arrays automatically
rnd_state = demo_sim.use_role(RnDState)
print("\nUsing use_role() to attach custom roles:")
print(f" Created {RnDState.name} with {demo_sim.n_firms} firms")
print(" All arrays initialized to zeros:")
print(f" sigma: {rnd_state.sigma[:3]}... (shape: {rnd_state.sigma.shape})")
print(f" rnd_intensity: {rnd_state.rnd_intensity[:3]}...")
# use_role() returns the same instance if called again
rnd_state_again = demo_sim.use_role(RnDState)
print(f" Same instance on repeat call: {rnd_state is rnd_state_again}")
# The role is accessible via get_role() too
rnd_via_get = demo_sim.get_role("RnDState")
print(f" Accessible via get_role(): {rnd_via_get is rnd_state}")
Using use_role() to attach custom roles:
Created RnDState with 50 firms
All arrays initialized to zeros:
sigma: [0. 0. 0.]... (shape: (50,))
rnd_intensity: [0. 0. 0.]...
Same instance on repeat call: True
Accessible via get_role(): True
Visualizing Role Data#
Plot role state over a simulated process.
import matplotlib.pyplot as plt
# Simulate inventory dynamics over 20 periods
n_periods = 20
n_firms = 10
# Initialize using ops.full for consistency
stock = ops.full(n_firms, 100.0)
reorder_point = ops.full(n_firms, 30.0)
stock_history = [stock.copy()]
# Simulate consumption and reorder
rng = bam.make_rng(42)
for _ in range(n_periods):
# Random consumption
consumption = ops.uniform(rng, 5, 20, n_firms)
stock = ops.maximum(ops.subtract(stock, consumption), 0)
# Reorder if below threshold
needs_reorder = stock < reorder_point
stock = ops.where(needs_reorder, ops.add(stock, 80), stock)
stock_history.append(stock.copy())
stock_history = np.array(stock_history)
# Plot
fig, ax = plt.subplots(figsize=(12, 6))
# Plot individual firms (faded)
for i in range(n_firms):
ax.plot(stock_history[:, i], alpha=0.3, linewidth=1)
# Plot mean
ax.plot(stock_history.mean(axis=1), "k-", linewidth=2, label="Mean Stock")
# Reorder point line
ax.axhline(y=30, color="red", linestyle="--", label="Reorder Point")
ax.set_xlabel("Period")
ax.set_ylabel("Stock Level")
ax.set_title("Inventory Dynamics with Reorder Policy")
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print(f"\nSimulated {n_firms} firms over {n_periods} periods")
print(f"Final mean stock: {stock_history[-1].mean():.1f}")

Simulated 10 firms over 20 periods
Final mean stock: 75.8
Custom Role for R&D Extension#
A realistic example: R&D and innovation tracking for endogenous growth.
@role
class ResearchDevelopment:
"""R&D and innovation role for endogenous growth modeling.
Extends the BAM model to include innovation dynamics, productivity
improvements, and technology spillovers between firms.
"""
# R&D spending and effort
rd_budget: Float # R&D budget this period
rd_intensity: Float # R&D as fraction of revenue
rd_workers: Int # Number of R&D employees
# Innovation outcomes
innovation_success: Bool # Whether innovation occurred this period
patent_count: Int # Cumulative patents held
tech_level: Float # Current technology level (affects productivity)
# Knowledge and spillovers
absorptive_capacity: Float # Ability to learn from others (0-1)
knowledge_stock: Float # Accumulated R&D knowledge
# Productivity link
productivity_boost: Float # Multiplicative boost from innovation
# Instantiate for 4 firms
rd = ResearchDevelopment(
rd_budget=np.array([100.0, 50.0, 200.0, 75.0]),
rd_intensity=np.array([0.05, 0.02, 0.08, 0.03]),
rd_workers=np.array([5, 2, 10, 3], dtype=np.int64),
innovation_success=np.array([False, False, True, False]),
patent_count=np.array([3, 1, 8, 2], dtype=np.int64),
tech_level=np.array([1.0, 0.9, 1.3, 0.95]),
absorptive_capacity=np.array([0.7, 0.4, 0.9, 0.5]),
knowledge_stock=np.array([50.0, 20.0, 100.0, 30.0]),
productivity_boost=np.array([1.0, 1.0, 1.15, 1.0]),
)
print("\nR&D Role for endogenous growth:")
print(f" R&D intensity: {rd.rd_intensity}")
print(f" Tech levels: {rd.tech_level}")
print(f" Innovation success: {rd.innovation_success}")
print(f" Firm with highest tech: Firm {np.argmax(rd.tech_level)}")
R&D Role for endogenous growth:
R&D intensity: [0.05 0.02 0.08 0.03]
Tech levels: [1. 0.9 1.3 0.95]
Innovation success: [False False True False]
Firm with highest tech: Firm 2
Key Takeaways#
Use
@roledecorator for clean, automatic role definitionChoose appropriate types: Float, Int, Bool, Agent
Use Optional and field() for optional/default values
Roles can include helper methods
Roles are registered automatically and retrievable via
get_role()Use
sim.use_role(RoleClass)to attach custom roles with auto-zeroed arraysTraditional syntax (explicit @dataclass + Role inheritance) also works
Total running time of the script: (0 minutes 0.192 seconds)