Operations Module#
The ops module provides NumPy-like operations with safe defaults
for writing custom events. It handles common pitfalls (division by zero, in-place
mutation semantics) and provides a consistent API that works on agent arrays
without requiring direct NumPy imports.
Quick Example#
from bamengine import ops
# Safe division (handles zeros)
unit_cost = ops.divide(wages, productivity)
# Conditional assignment
new_prices = ops.where(demand > supply, price * 1.1, price * 0.9)
# In-place assignment
ops.assign(prod.price, new_prices)
Why Use ops?#
The ops module exists for several reasons:
Safe division:
ops.divide(a, b)returns 0 whenbis zero, avoidingZeroDivisionErrorandRuntimeWarningfrom NumPyIn-place semantics:
ops.assign(target, value)ensures role arrays are updated in place, which is required for changes to be visible across eventsConsistent API: All operations work uniformly on scalars and arrays
Self-documenting: Using
ops.dividesignals intent more clearly than rawa / bin economic model code
The ops functions are thin wrappers around NumPy; there is no performance
overhead compared to using NumPy directly.
Quick Reference#
Category |
Functions |
Notes |
|---|---|---|
Arithmetic |
|
|
Assignment |
|
Required for in-place role updates |
Comparisons |
|
Return boolean arrays |
Logical |
|
Combine boolean masks |
Conditional |
|
Vectorized if-then-else |
Element-wise |
|
Per-element bounds |
Mathematical |
|
Natural logarithm and exponential |
Aggregation |
|
Reduce across agents |
Array Creation |
|
Create agent-sized arrays |
Utilities |
|
Sorting and set operations |
Random |
|
Requires an RNG argument |
Core Operations#
Arithmetic#
Standard element-wise arithmetic on agent arrays:
from bamengine import ops
revenue = ops.multiply(price, quantity_sold)
gross_profit = ops.subtract(revenue, wage_bill)
markup = ops.divide(price, unit_cost) # safe: returns 0 when unit_cost is 0
All arithmetic functions accept an optional out= parameter for in-place
computation:
# Write result directly into an existing array
ops.multiply(price, 1.1, out=price)
Safe division. ops.divide(a, b) adds a tiny epsilon (1e-10) to the
denominator to prevent division by zero. This is essential in economic models
where denominators like production or net worth can legitimately be zero:
# Without ops: RuntimeWarning when productivity is 0
unit_cost = wage_bill / productivity
# With ops: safely returns 0 for zero-productivity firms
unit_cost = ops.divide(wage_bill, productivity)
Assignment#
ops.assign copies values into an existing array in place. This is the
correct way to update role fields:
# Correct: updates the array that prod.price points to
ops.assign(prod.price, new_prices)
# Wrong: rebinds the local variable, role is unchanged
prod.price = new_prices
Warning
Direct assignment (role.field = value) creates a new array instead of
updating the existing one. Other events that hold a reference to the original
array will not see the change. Always use ops.assign().
Conditional#
ops.where is the vectorized if-then-else, the workhorse for conditional
logic in events:
# Increase price if sold out, decrease if overstocked
new_price = ops.where(
inventory == 0, # condition
price * (1 + shock), # value when True
price * (1 - shock), # value when False
)
ops.assign(prod.price, new_price)
Aggregation#
Reduce operations across agent arrays:
total_output = ops.sum(prod.production)
avg_price = ops.mean(prod.price)
any_bankrupt = ops.any(bor.net_worth < 0)
# With mask: aggregate only employed workers
avg_employed_wage = ops.mean(wrk.wage, where=wrk.employed)
Array Creation#
Create arrays sized to the agent population:
# Zero-initialized array for n_firms agents
scratch = ops.zeros(sim.n_firms)
# Constant-filled array
base_rate = ops.full(sim.n_banks, 0.02)
Random#
ops.uniform draws from a uniform distribution, but requires an explicit
RNG argument to maintain determinism:
# Draw production shocks for all firms
shock = ops.uniform(sim.rng, 0, sim.h_rho, size=sim.n_firms)
Common Patterns#
These patterns appear frequently in BAM Engine events:
Safe denominator pattern. When dividing by a value that may be zero,
guard the denominator with ops.where:
# Avoid division by zero for zero-NW firms
safe_nw = ops.where(bor.net_worth > 0, bor.net_worth, 1.0)
leverage = ops.where(bor.net_worth > 0, ops.divide(debt, safe_nw), max_leverage)
Masked update. Update only agents that meet a condition:
# Only raise wages for firms with vacancies
ops.assign(
emp.wage_offer,
ops.where(emp.n_vacancies > 0, emp.wage_offer * (1 + shock), emp.wage_offer),
)
Aggregate then broadcast. Compute an economy-wide statistic and use it per-agent:
avg_savings = ops.mean(con.savings)
relative_savings = ops.divide(con.savings, avg_savings)
When to Use ops vs. NumPy Directly#
Use ops for:
Role mutations: Any write to a role field must use
ops.assign()Division: Use
ops.divide()whenever the denominator might be zeroConditional logic:
ops.where()is clearer than nestednp.where
NumPy directly is fine for:
Local computation: Intermediate calculations that don’t mutate roles
Advanced operations: Functions not in
ops(e.g.,np.cumsum,np.histogram,np.linalg)Indexing: Fancy indexing and slicing work the same on role arrays
import numpy as np
from bamengine import ops
# NumPy fine for local computation
log_prices = np.log(prod.price)
sorted_idx = np.argsort(prod.price)
# ops required for role mutation
ops.assign(prod.price, np.exp(log_prices * 1.01))
See also
Custom Events for using ops in event implementations
bamengine.opsAPI reference for full function signatures