Making Numba Modules
NumbaModel is a Basilisk module type that achieves near-C execution speed
while being written entirely in Python. It is a good fit for numerically
intensive inner loops (attitude control laws, filter update steps, trajectory
integrators) that are too slow as plain Python modules.
Under the hood, NumbaModel uses Numba
to JIT-compile the user-supplied UpdateStateImpl static method into a
cfunc (a C-callable function pointer) that the Basilisk C++ scheduler
calls directly every tick. The compilation happens once, at Reset time.
Note
Because UpdateStateImpl is compiled by Numba in nopython mode, a
subset of Python and NumPy is available inside it. Read the
What is Allowed in UpdateStateImpl section carefully before writing complex
logic.
Defining a Numba Module
Subclass NumbaModel from Basilisk.architecture.numbaModel and
implement UpdateStateImpl as a @staticmethod. All message attributes
and memory fields must be created before Reset; the parameter names of
UpdateStateImpl tell the framework which of those attributes to wire up.
1import numpy as np
2from Basilisk.architecture.numbaModel import NumbaModel
3from Basilisk.architecture import messaging, bskLogging
4from Basilisk.utilities import SimulationBaseClass, macros
5
6
7def run():
8 """
9 Illustration of NumbaModel: two JIT-compiled modules exchanging messages.
10 """
11
12 scSim = SimulationBaseClass.SimBaseClass()
13 proc = scSim.CreateNewProcess("proc")
14 proc.addTask(scSim.CreateNewTask("task", macros.sec2nano(1.0)))
15
16 # AccumulatorModel tracks elapsed time in memory; it has no input messages.
17 prod = AccumulatorModel()
18 prod.ModelTag = "accumulator"
19 scSim.AddModelToTask("task", prod)
20
21 # FilterModel reads from AccumulatorModel and scales by a configurable gain.
22 filt = FilterModel()
23 filt.ModelTag = "filter"
24 filt.memory.gain = 2.0 # override default before Reset
25 filt.dataInMsg.subscribeTo(prod.dataOutMsg)
26 scSim.AddModelToTask("task", filt)
27
28 recorder = filt.dataOutMsg.recorder()
29 scSim.AddModelToTask("task", recorder)
30
31 scSim.InitializeSimulation()
32 scSim.ConfigureStopTime(macros.sec2nano(3.0)) # 4 ticks: t = 0, 1, 2, 3 s
33 scSim.ExecuteSimulation()
34
35 print("ticks recorded by accumulator:", int(prod.memory.ticks))
36 print("filtered dataVector at t=3s: ", recorder.dataVector[-1])
37
38
39# =============================================================================
40# Module 1: AccumulatorModel
41#
42# Demonstrates: memory namespace, CurrentSimNanos, no readers.
43# =============================================================================
44class AccumulatorModel(NumbaModel):
45 """Outputs the cumulative simulation time (seconds) and a tick counter."""
46
47 def __init__(self):
48 """Declare the output message and persistent accumulator state."""
49 super().__init__()
50 self.dataOutMsg = messaging.CModuleTemplateMsg()
51
52 # Fields declared here become a C-level structured buffer after Reset.
53 # The dtype is inferred from the value: 0.0 -> float64, 0 -> int64,
54 # np.int32(0) -> int32, np.array(...) -> array field.
55 self.memory.total_s = 0.0
56 self.memory.ticks = 0
57
58 @staticmethod
59 def UpdateStateImpl(dataOutMsgPayload, CurrentSimNanos, memory):
60 """Accumulate elapsed time and publish the running totals."""
61 # Everything in this method is compiled by Numba in nopython mode.
62 # See the "What is allowed" section in the documentation.
63 memory.total_s += np.float64(CurrentSimNanos) * 1e-9
64 memory.ticks += 1
65 dataOutMsgPayload.dataVector[0] = memory.total_s
66 dataOutMsgPayload.dataVector[1] = float(memory.ticks)
67
68
69# =============================================================================
70# Module 2: FilterModel
71#
72# Demonstrates: scalar reader with IsLinked guard, bskLogger, memory gain.
73# =============================================================================
74class FilterModel(NumbaModel):
75 """Scales each element of the input dataVector by a configurable gain."""
76
77 def __init__(self):
78 """Set up one reader, one writer, and the configurable gain term."""
79 super().__init__()
80 self.dataInMsg = messaging.CModuleTemplateMsgReader()
81 self.dataOutMsg = messaging.CModuleTemplateMsg()
82 self.memory.gain = 1.0 # default; caller may override
83
84 @staticmethod
85 def UpdateStateImpl(dataInMsgPayload, dataInMsgIsLinked,
86 dataOutMsgPayload, bskLogger, memory):
87 """Scale the input vector when linked and warn otherwise."""
88 if not dataInMsgIsLinked:
89 bskLogger.bskLog(bskLogging.BSK_WARNING, "FilterModel: input not linked")
90 return
91 for i in range(3):
92 dataOutMsgPayload.dataVector[i] = dataInMsgPayload.dataVector[i] * memory.gain
93
94
95if __name__ == "__main__":
96 run()
Parameter Naming Convention
Unlike regular Python or C++ modules, NumbaModel does not require you
to write a Reset or UpdateState method. Instead, the framework
introspects the parameter names of UpdateStateImpl at Reset time and
automatically wires each one to the correct buffer.
You may override Reset (e.g. to perform additional Python-level
setup), but you must call super().Reset(CurrentSimNanos) so that the
cfunc is compiled and registered:
def Reset(self, CurrentSimNanos=0):
# ... additional setup ...
super().Reset(CurrentSimNanos) # must be called
Do not override UpdateState. The C++ scheduler calls the
JIT-compiled cfunc registered by NumbaModel.Reset directly.
The recognised UpdateStateImpl parameter names are:
Parameter name pattern |
Type inside |
What it provides |
|---|---|---|
|
Numba Record (struct-like) |
Writable payload of |
|
Numba Record or array of Records |
Read-only snapshot of |
|
|
|
|
|
Current simulation time in nanoseconds |
|
|
This module’s unique integer ID |
|
Numba Record |
Persistent state fields (see Persistent Memory) |
|
|
Logging proxy (see Logging with bskLogger) |
|
xoshiro256++ PRNG |
Per-module PRNG seeded from |
Any parameter name not matching one of the patterns above raises an
AttributeError at Reset time with a descriptive message.
Note
Parameter order inside UpdateStateImpl does not matter; the
framework matches by name, not position.
Output Messages
Declare output messages as messaging.<Type>Msg() attributes in __init__:
self.dataOutMsg = messaging.CModuleTemplateMsg()
Use the suffix OutMsgPayload in UpdateStateImpl to receive a writable
view of the payload:
@staticmethod
def UpdateStateImpl(dataOutMsgPayload, ...):
dataOutMsgPayload.dataVector[0] = 42.0
The Basilisk header timestamp is written automatically after UpdateStateImpl
returns. You do not need to call .write().
For multiple output messages, assign a list or dict to the attribute:
self.dataOutMsg = [messaging.CModuleTemplateMsg(),
messaging.CModuleTemplateMsg()]
Inside UpdateStateImpl, access them by integer index:
dataOutMsgPayload[0].dataVector[0] = v
dataOutMsgPayload[1].dataVector[0] = v * 2.0
Or by string key when using a dict:
self.dataOutMsg = {'pos': messaging.CModuleTemplateMsg(),
'neg': messaging.CModuleTemplateMsg()}
# inside UpdateStateImpl:
dataOutMsgPayload['pos'].dataVector[0] = v
dataOutMsgPayload['neg'].dataVector[0] = -v
Input Messages (Readers)
Declare readers as messaging.<Type>MsgReader() attributes:
self.dataInMsg = messaging.CModuleTemplateMsgReader()
Subscribe before InitializeSimulation:
mod.dataInMsg.subscribeTo(src.dataOutMsg)
The payload is refreshed from the source every tick by the C++ scheduler.
Access it in UpdateStateImpl via the InMsgPayload parameter.
If a reader may be unlinked, declare the corresponding IsLinked
parameter and guard reads behind it:
@staticmethod
def UpdateStateImpl(dataInMsgPayload, dataInMsgIsLinked, dataOutMsgPayload):
if dataInMsgIsLinked:
dataOutMsgPayload.dataVector[0] = dataInMsgPayload.dataVector[0]
If <name>InMsgPayload is declared without a corresponding
<name>InMsgIsLinked, Reset raises RuntimeError when the reader
is not subscribed. This is an intentional early failure rather than a silent
wrong-data scenario.
List and dict attributes work the same way as for output messages. For a
list reader, IsLinked is a uint8[:] array indexed by position
(dataInMsgIsLinked[0], dataInMsgIsLinked[1], …). For a dict
reader, IsLinked is a Record keyed by the same names as the payload, so
both can be accessed identically:
if dataInMsgIsLinked['a']:
val = dataInMsgPayload['a'].dataVector[0]
Persistent Memory
self.memory is a namespace that holds persistent module state across ticks.
Fields are defined as attributes in __init__:
self.memory.step = 0 # scalar integer (→ int64)
self.memory.gain = 1.5 # scalar float (→ float64)
self.memory.bias = [0.1, 0.2, 0.3] # 1-D array (converted to numpy)
self.memory.matrix = np.eye(3) # 2-D array
At Reset time the fields are packed into a single structured numpy array
whose pointer is handed to the C++ scheduler. After Reset, the fields are
accessible from Python via the same self.memory.<name> syntax and any
assignment is forwarded directly to the underlying C buffer - no re-compilation
is needed.
Rules:
All fields must be defined before
Resetis called (i.e., in__init__or beforeInitializeSimulation).New fields cannot be added after
Reset.The numpy dtype of each field is inferred from the value assigned in
__init__: a plain0becomesint64, a plain0.0becomesfloat64. Use an explicit numpy type (e.g.np.int32(0)) only when a specific width is required.Inside
UpdateStateImpl,memorybehaves like a NumbaRecord. Array fields are accessed with standard bracket indexing:memory.bias[1],memory.matrix[i, j].
Logging with bskLogger
Add bskLogger as a parameter to receive a Numba-compatible logging proxy:
@staticmethod
def UpdateStateImpl(dataOutMsgPayload, bskLogger, memory):
bskLogger.bskLog(bskLogging.BSK_INFORMATION, "tick")
bskLogger.bskLog1(bskLogging.BSK_WARNING, "step:", memory.step)
The proxy respects the log level set on self.bskLogger at Reset time:
messages below the current threshold are suppressed. Output goes to stdout via
print; ANSI colour codes match the rest of Basilisk’s log output.
Available methods:
Method |
Prints |
|---|---|
|
|
|
|
|
|
|
|
Level constants (bskLogging.BSK_DEBUG, BSK_INFORMATION,
BSK_WARNING, BSK_ERROR) work inside UpdateStateImpl because Numba
resolves module-level integer attributes at compile time.
msg must be a string literal - variables of string type are not
supported in nopython mode. Values v0–v2 can be any numeric type
that Numba supports (int32, float64, etc.).
Tip
Plain print("label:", value) also works in UpdateStateImpl with no
additional setup.
Per-module Random Number Generator (rng)
Add rng as a parameter to receive a per-module xoshiro256++ PRNG that is
private to this module instance and seeded from self.RNGSeed at Reset
time:
@staticmethod
def UpdateStateImpl(dataOutMsgPayload, rng, memory):
noise = rng.standard_normal(3) # independent per-module stream
dataOutMsgPayload.dataVector[:3] = memory.signal + noise
The generator state persists across ticks, so each call advances the sequence
deterministically. self.RNGSeed is inherited from SysModel and
defaults to 0x1badcad1; set it before InitializeSimulation() for
reproducible Monte Carlo runs:
sNav.RNGSeed = 12345
Key properties:
Per-module isolation: each instance owns an independent generator — two modules with the same seed produce the same sequence independently, and two modules with different seeds never interfere.
Re-seeded on Reset: calling
Resetagain seeds a fresh generator from the currentself.RNGSeed, resetting the sequence.
Available methods:
Method |
Description |
|---|---|
|
Return |
|
Uniform |
|
Uniform |
|
Uniform |
What is Allowed in UpdateStateImpl
UpdateStateImpl is compiled by Numba in nopython mode. The following
is a summary of what is and is not supported.
Allowed
NumPy array operations on pre-allocated arrays (element-wise arithmetic,
[i]/[i, j]indexing,array.copy(),np.zeros(n),np.dot, reductions, etc.). See the Numba NumPy support list for the full set.Explicit numpy scalars such as
np.int32(1),np.float64(3.14). Use these when a specific width is required (e.g. to match a memory field declared asnp.int32); otherwise plain literals are fine.Control flow:
if/elif/else,foroverrange(n),while,break,continue,return.Math functions:
math.sqrt,math.sin, etc. (importmathat module level, use inside the function normally).Calling other
@nb.njitfunctions - factor complex logic into helper functions decorated with@numba.njit.``print(label, value, …)`` - variadic print to stdout works and is the simplest way to emit debug output.
``bskLogger`` methods as described in Logging with bskLogger.
Reading from
memoryRecord fields and writing to them.Reading from
InMsgPayloadRecord fields and writing toOutMsgPayloadRecord fields.
Not Allowed
Python objects of any kind: no lists, dicts, sets, tuples, classes, generators, or iterators. Use numpy arrays for all collections.
String variables - string literals in
printandbskLoggercalls work, but you cannot assign a string to a variable or pass one as a function argument (other than via thebskLog*API).f-strings and %-formatting - these require Python object creation. Use
bskLog1/bskLog2to attach numeric values to log messages.Exceptions -
raise,try/except/finallyare not supported. Use conditional logic andbskLoggerto handle error conditions.Calling Python functions that are not themselves
@nb.njit-compiled. Standard library functions (os,sys, etc.) are unavailable.Dynamic memory allocation with arbitrary Python types. NumPy array creation with a constant size (
np.zeros(3)) is fine; allocation whose size depends on a runtime variable may require care.Global mutable state - do not capture mutable Python objects in the closure of
UpdateStateImpl. Usememoryfor persistent state.Messaging API - do not call
.write(),.read(), or.subscribeTo()insideUpdateStateImpl. Payload structs are passed as already-refreshed Records; writing back through them is sufficient.
Re-use and Re-initialisation
Re-wiring messages mid-simulation (calling subscribeTo without a
Reset) is fully supported. The C++ scheduler refreshes the payload
pointer dynamically every tick via registered reader slots.
Calling ``Reset`` a second time re-compiles the cfunc and re-initialises
memory from the current buffer state. The buffer is always the source of
truth: Python-side writes (self.memory.gain = 2.0) and cfunc-internal
writes (memory.step += 1 inside UpdateStateImpl) are treated
identically; both persist through a subsequent Reset. The next run always
continues from whatever value was last written, by any means.
RigidBodyKinematicsNumba
Basilisk.utilities.RigidBodyKinematicsNumba provides
@nb.njit-compiled versions of every function in
Basilisk.utilities.RigidBodyKinematics, making the full rigid-body
kinematics library callable from within compiled UpdateStateImpl kernels.
Import individual functions at module level (outside UpdateStateImpl):
from Basilisk.utilities.RigidBodyKinematicsNumba import MRP2C, addMRP, subMRP
class MyModule(NumbaModel):
@staticmethod
def UpdateStateImpl(attNavInMsgPayload, attGuidOutMsgPayload, memory):
C_BN = MRP2C(attNavInMsgPayload.sigma_BN)
sigma_BR = subMRP(attNavInMsgPayload.sigma_BN, memory.sigma_RN)
attGuidOutMsgPayload.sigma_BR[:3] = sigma_BR
All kinematics functions (MRP2C, C2MRP, addMRP, subMRP,
EP2C, C2EP, PRV2C, and more) are available. Because they are
decorated with @nb.njit(inline='always'), calls inside UpdateStateImpl
compile inline with no per-call overhead.
Note
RigidBodyKinematicsNumba requires scipy to be installed for
BLAS-backed np.linalg operations inside Numba nopython mode.
Continuous-Time States (StatefulNumbaModel)
StatefulNumbaModel (from Basilisk.simulation.StatefulNumbaModel)
combines NumbaModel with StatefulSysModel to add continuous-time states
that are integrated by a MJScene dynamics scene. Use it when a Numba
module needs to register differentiable states (position, velocity, etc.) for
integration alongside MuJoCo dynamics.
Defining a StatefulNumbaModel
Subclass StatefulNumbaModel, implement registerStates to create
StateData objects, and write the derivatives in UpdateStateImpl:
from Basilisk.simulation.StatefulNumbaModel import StatefulNumbaModel
class HarmonicOscillator(StatefulNumbaModel):
def registerStates(self, registerer):
self.posState = registerer.registerState(3, 1, "pos")
self.velState = registerer.registerState(3, 1, "vel")
def Reset(self, CurrentSimNanos=0):
self.memory.omega = 2.0
super().Reset(CurrentSimNanos) # compiles the cfunc
@staticmethod
def UpdateStateImpl(posState, posStateDeriv,
velState, velStateDeriv, memory):
posStateDeriv[:, 0] = velState[:, 0]
velStateDeriv[:, 0] = -memory.omega**2 * posState[:, 0]
registerStates is called by MJScene before Reset(). Always call
super().Reset(CurrentSimNanos) in your Reset override so that the cfunc
is compiled after the state objects exist.
Additional UpdateStateImpl parameters
All NumbaModel parameters are supported, plus:
Parameter name pattern |
Type inside |
What it provides |
|---|---|---|
|
|
Read-only current state value; resolved from |
|
|
Writable state derivative; resolved from the same |
|
|
Writable diffusion matrix for the |
States are registered as nRow × nCol matrices with a column-major
(Fortran-order) backing store, so arr[i, j] equals matrix element
M(i, j). Even 1-D states are exposed as 2-D arrays — a 3×1 column
vector is accessed as posState[i, 0].
Performance Notes
Compilation happens once at
Resettime and is cached to disk by Numba (keyed on the bytecode ofUpdateStateImpl). Subsequent runs with the same code load the cached binary instantly.The cfunc is called from C++ with zero Python overhead per tick. Execution speed is comparable to a hand-written C extension for the same algorithm.
Keep
UpdateStateImplfree of Python-level function calls; every call that Numba cannot inline adds compilation complexity and may defeat caching.