Components

sim.Component groups related declarations and process methods. It is useful when a model has repeated subsystems, such as several clinic desks, triage areas, departments, or teams that each own their own queues and workers.

import cimba.sim as sim

import cimba.random as random

class Desk(sim.Component):
    waiting: sim.Queue
    completed: sim.State

    @sim.process
    def clerk(self, env):
        while True:
            self.waiting.get(1)
            sim.hold(random.exponential(env.mean_service))
            self.completed += 1


class Clinic(sim.Model):
    mean_service: sim.Param
    served: sim.Output
    front_desk: Desk = Desk()

Component process methods use top-level @sim.process. When a model is constructed, Cimba Python lowers those methods into ordinary model processes. Inside the method, self.waiting and self.completed refer to the component-owned fields for this trial.

When a component publishes process handles, predicates, or events, bind its declared field explicitly and keep the callback name distinct. Bare predicates and events remain useful when only component code needs the handle: they generate self._pred_<name> and self._ev_<name> respectively.

class Gate(sim.Component):
    workers: sim.Processes
    ready: sim.Predicate
    alarm: sim.Event

    @sim.process(field="workers")
    def run_worker(self, env):
        ...

    @sim.predicate(field="ready")
    def is_ready(self, env) -> bool:
        ...

    @sim.event(field="alarm")
    def on_alarm(self, env, data):
        ...

Component process signatures continue to be (self, env[, index]). They may receive a final annotated sim.Struct view or use @sim.process(struct=MyStruct) to allocate process storage without a view.

Component parameters can also carry defaults. A class default applies to every instance unless its constructor assigns an instance-specific value. Components without a custom constructor can configure declared Param and Const fields directly with keywords:

class FixedLot(sim.Component):
    lot_size: sim.Const[float] = 100.0

class Inventory(sim.Model):
    policy: FixedLot = FixedLot(lot_size=250)

Const[T] values are converted with T(value) (so the example stores 250.0), while Param values must be real scalars and are normalized to float. Inherited declarations and postponed annotations are supported. The base constructor also accepts Ref and Refs declarations. Runtime fields such as states, queues, and nested components must still be assigned by a custom constructor. Custom constructors forward declaration keywords explicitly:

class SingleSource(SupplierPolicy):
    def __init__(self, source: Supplier, **kwargs):
        super().__init__(**kwargs)
        self.source = source

For a component collection, every item must provide a default or the flattened parameter remains required as a whole. Experiment arguments such as desks__service_rate=[0.2, 0.3] override the flattened defaults.

Primitive per-instance settings can be marked explicitly with sim.Const. Constants are captured from each component instance at model construction time and lowered as compile-time values or small lookup tables:

class Desk(sim.Component):
    server_count: sim.Const[int]
    waiting: sim.Queue

    def __init__(self, server_count: int):
        self.server_count = server_count

    @sim.process(copies="server_count")
    def clerk(self, env, idx):
        ...

Synchronous component functions

Use @sim.function for immediate, value-returning component behavior such as routing, replenishment, pricing, or admission rules:

class OrderPolicy(sim.Component):
    reorder_point: sim.Param
    target_level: sim.Param

    @sim.function
    def decide(self, inventory: float) -> float:
        if inventory < self.reorder_point:
            return self.target_level - inventory
        return 0.0


class Inventory(sim.Model):
    policy: OrderPolicy = OrderPolicy()
    inventory: sim.Param
    order: sim.Output

    @sim.process
    def replenish(self: "Inventory"):
        self.order = self.policy.decide(self.inventory)

model = Inventory()

Function arguments and the return value require explicit bool, int (sim.Handle), or float annotations and calls use positional arguments. Functions may read Param, Output, State, FloatState, and scalar Const fields. They can call other @sim.function methods and follow nested component, Ref, Refs, and indexed component-collection paths. The same function on a collection item is called as env.policies[i].decide(value).

Component functions are read-only: assigning through self, using queue or resource operations, controlling processes, scheduling events, or recursively calling component functions is rejected when the model is constructed. The generated helper runs in Numba nopython mode, while experiment parameters keep their normal flattened names such as policy__reorder_point.

Models can define a root helper with the same marker and a self receiver:

class Inventory(sim.Model):
    policy: OrderPolicy = OrderPolicy()
    service_level: sim.Param

    @sim.function
    def reorder(self: "Inventory", inventory: float) -> float:
        return self.policy.decide(inventory) * self.service_level

    @sim.process
    def replenish(self: "Inventory"):
        amount = self.reorder(12.0)

Root helpers are callable from component callbacks too, using env.reorder(...). They read the same scalar model/component namespace and follow the same no-mutation, no-scheduling, and no-recursion rules.

A collection index may be a function argument or a value the function computes for itself, so a function can weigh a whole collection and return its choice:

class Buyer(sim.Component):
    suppliers: list[Supplier] = [Supplier(), Supplier(), Supplier()]

    @sim.function
    def cheapest(self) -> int:
        best = 0
        for i in range(len(self.suppliers)):
            if self.suppliers[i].price < self.suppliers[best].price:
                best = i
        return best

Collection lengths are known when the model is built, so compiled code can use len directly; a manually synchronized sim.Const[int] count field is not needed. Reading a field at a self-computed index requires every instance of the collection to declare that field, and the field must be a Param, Output, State, or FloatState – scalar Const values live in a side table and have to be indexed by a function argument. Both restrictions are reported when the model is constructed.

Component-owned statistics

Components can also own their statistics collection. A method marked with top-level @sim.collect takes (self, env) and runs once per instance at the end of each trial, typically assigning the component’s declared sim.Output fields:

Each component class has at most one effective collector. A subclass replaces an inherited collector by decorating the same method name, or removes it with an undecorated override.

class Desk(sim.Component):
    waiting: sim.Queue
    avg_queue: sim.Output

    @sim.process
    def clerk(self, env):
        while True:
            self.waiting.get(1)
            sim.hold(random.exponential(env.mean_service))

    @sim.collect
    def desk_stats(self, env):
        self.avg_queue = self.waiting.mean_level()

Every instance of a component collection runs its own collect, so per-desk outputs land in per-instance output slots. Component collects run before the model-level @sim.collect callback, which can therefore aggregate over the component outputs:

class Clinic(sim.Model):
    worst_queue: sim.Output
    desks: list[Desk] = [Desk(), Desk(), Desk()]

    @sim.collect
    def clinic_stats(self: "Clinic"):
        self.worst_queue = self.desks[0].avg_queue
        for i in range(1, 3):
            if self.desks[i].avg_queue > self.worst_queue:
                self.worst_queue = self.desks[i].avg_queue

Nested components

Components can own other components. This lets the model declaration become a table of contents for the simulated world:

class StaffTeam(sim.Component):
    capacity: sim.Pool = sim.capacity("staff_count")


class Intake(sim.Component):
    line: sim.Queue
    staff: StaffTeam = StaffTeam()


class Clinic(sim.Model):
    staff_count: sim.Param
    intake: Intake = Intake()

Model code can read nested paths such as self.intake.staff.capacity. The trial table stores flattened fields internally, but process source can stay close to the domain structure.

Component collections

Use a list[ComponentType] declaration for fixed repeated subsystems:

class Clinic(sim.Model):
    mean_service: sim.Param
    desks: list[Desk] = [Desk(), Desk(), Desk()]

    @sim.process
    def router(self: "Clinic"):
        target = 0
        best = self.desks[0].waiting.level()
        for i in range(1, 3):
            length = self.desks[i].waiting.level()
            if length < best:
                target = i
                best = length
        self.desks[target].waiting.put(1)

The collection length is fixed by the model class. This is a good fit for known departments, stations, gates, or desks. If the number of active entities changes during a trial, use dynamic processes instead.

Concrete component specialization

A component annotation is a compatibility constraint; the default instance selects the concrete implementation compiled for that field. This makes normal policy and strategy composition work without specialized container classes:

class Policy(sim.Component):
    @sim.function
    def calculate(self, value: float) -> float:
        return value


class DoublePolicy(Policy):
    @sim.function
    def calculate(self, value: float) -> float:
        return value * 2.0


class Material(sim.Component):
    policy: Policy

    def __init__(self, policy):
        self.policy = policy


class SupplyModel(sim.Model):
    materials: list[Material] = [
        Material(DoublePolicy()),
        Material(Policy()),
    ]

Cimba validates each instance against its annotation, then discovers fields, @sim.function, @sim.process, and @sim.collect methods from the concrete class. Different items in a collection may use different concrete classes, including nested paths such as materials[].policy.

The instance-to-implementation mapping is fixed when the model is constructed. A call through a computed index, such as env.materials[i].policy.calculate(value), becomes a compiled switch over that fixed mapping. Every possible implementation of a dynamically dispatched function must have compatible argument and return annotations. Instances with the same concrete class and the same recursive child specialization share one compiled implementation, even when they are separated in the collection.

Fields declared by only some concrete classes keep their natural flattened name and are packed over the owning items in collection order. For policies [FixedLot(), EOQ(), FixedLot()], materials__policy__lot_size therefore has two slots, corresponding to the first and third materials. Parameter overrides may use that owner-only packed order, or an index mapping that names the logical collection items explicitly:

model.experiment(
    materials__policy__lot_size={0: 50, 2: 75},
)

The mapping form is especially useful when subtype fields are sparse. Its keys must be owning logical indexes; omitted owners use declared defaults when available. Values may also be equal-length one-dimensional arrays to define a parameter sweep.

model.component_schema() exposes the layout without requiring callers to infer it from the flattened dtype. Pass either an authoring path or flattened name to inspect one field:

schema = model.component_schema("materials[].policy.lot_size")
assert schema.flattened_name == "materials__policy__lot_size"
assert schema.owners == (0, 2)
assert schema.packed

The schema also reports the field kind, logical instance count, packed shape, and the concrete type of each owner. Direct access through a dynamic index is accepted only when every possible concrete type declares the field; access to a subtype-only field must be through a statically known item or from the subtype’s own compiled methods.

If different concrete classes give the same flattened field name incompatible kinds or structural declarations, model construction fails with a type error. Homogeneous component fields and collections retain their existing names, shapes, and lowering behavior.

Wiring components together

Routing between components can be declared where the components are declared. Accessing a declared Queue/Resource/Pool/Store/Condition field on a component instance yields a wiring reference; passing it as another instance’s same-kind field value makes both fields name the same entity:

class Station(sim.Component):
    inbox: sim.Store
    outbox: sim.Store

    def __init__(self, mean_time: float, *, inbox=None):
        self.mean_time = mean_time
        if inbox is not None:
            self.inbox = inbox

    @sim.process
    def server(self, env):
        while True:
            item = self.inbox.take()
            sim.hold(random.exponential(self.mean_time))
            self.outbox.put(item)


class AssemblyLine(sim.Model):
    station_1: Station = Station(5.0)
    station_2: Station = Station(7.0, inbox=station_1.outbox)
    station_3: Station = Station(4.0, inbox=station_2.outbox)

Here station_2.inbox is an alias for station_1.outbox: only one store is created, station_1’s server feeds it, and station_2’s server takes from it, so parts flow down the line without hand-written routing processes. Unwired fields (the first inbox, the last outbox) stay ordinary stores that model-level processes can feed and drain.

Wiring targets must be declared somewhere on the model, both fields must have the same kind, and wired fields do not appear in the trial table (use the target’s flattened name, e.g. station_1__outbox). Wiring chains are resolved to the final target after the component tree is built. Component collections cannot be wired yet.

Routing with component references

Wiring merges two fields into one entity, which fixes the flow at declaration time. When the code must choose a target — sequences, by-condition transfer to one of several stations — declare a component reference with sim.Ref or an indexable reference table with sim.Refs:

class Station(sim.Component):
    inbox: sim.Store
    downstream: sim.Ref["Station"]

    def __init__(self, mean_time: float, downstream=None):
        self.mean_time = mean_time
        if downstream is not None:
            self.downstream = downstream

    @sim.process
    def server(self, env):
        while True:
            item = self.inbox.take()
            sim.hold(random.exponential(self.mean_time))
            self.downstream.inbox.put(item)

The reference value is another component instance declared on the model. Inside compiled code, self.downstream.inbox resolves to the target’s fields, constants, and processes exactly as if they were accessed through their own path. Unlike wiring, references are resolved after the whole model class is processed, so the target may be declared after the component that references it (values can also be attached post-declaration, e.g. Line.station_1.downstream = Line.station_2 before instantiating).

sim.Refs declares a routing table for runtime decisions. All entries must be items of a single component collection – a collection of one included, so a table need not be special-cased at its degenerate size – and the lookup lowers to array indexing:

The base component constructor can configure both kinds of reference directly; references are assigned as provided and validated when the model declaration tree is built:

class Dispatcher(sim.Component):
    source: sim.Ref[Supplier]
    routes: sim.Refs[Supplier]

dispatcher = Dispatcher(
    source=supplier,
    routes=(supplier_a, supplier_b),
)
class Dispatcher(sim.Component):
    inbox: sim.Store
    routes: sim.Refs[Station]

    def __init__(self, routes=()):
        self.routes = tuple(routes)

    @sim.process
    def route(self, env):
        while True:
            item = self.inbox.take()
            self.routes[item % 3].inbox.put(item)


class Shop(sim.Model):
    stations: list[Station] = [Station(5.0), Station(7.0), Station(4.0)]
    dispatch: Dispatcher = Dispatcher(
        routes=(stations[0], stations[1], stations[2]))

Model callbacks can follow references too (self.dispatch.routes[1].inbox, self.stations[j].downstream.inbox). A fixed sim.Ref may target any declared component; following a reference under a dynamic collection index requires every item to reference the same component declaration. Component methods that need mixed per-instance targets are lowered per instance instead.

Prefer wiring for fixed linear flows (one shared entity, no extra hop) and references when the model routes items among alternatives at runtime.

Flattened outputs and trial data

Component fields are flattened in experiment arrays with __ separators. For example, env.front_desk.completed becomes a trial field named front_desk__completed. Most process code should use the natural component path, while analysis code may sometimes inspect the flattened field names in exp.trials.

Use components to express model structure, not to hide model behavior. If a component method needs many details from unrelated components, move that coordination to a model-level process or split the model into clearer domains.

For the complete API surface, see Models, Components and Experiments.