Structs and Spawnables

Core models often treat arrivals as counts. Agent-heavy models need each arrival to carry its own state: arrival time, priority, service choice, patience, or accumulated outcomes. Use sim.Struct for per-process fields and spawnable=True for processes created during a trial.

Per-process fields

A sim.Struct declares fields stored with a process. Fields may be int or float:

class Patient(sim.Struct):
    arrival: float
    acuity: int
    wait_started: float


class Clinic(sim.Model):
    completed: sim.State

    @sim.process(spawnable=True)
    def patients(self: "Clinic", patient: Patient):
        patient.wait_started = sim.now()
        # The process can use its own fields throughout the visit.

model = Clinic("clinic")

The final annotated process parameter receives the current process’s struct view. Multi-copy static processes can also receive a copy index before the struct view.

Dynamic process creation

The patients process above is decorated with spawnable=True. Spawn it from a regular process:

import cimba.random as random

class Clinic(sim.Model):
    arrival_rate: sim.Param

    @sim.process(spawnable=True)
    def patients(self: "Clinic", patient: Patient):
        patient.wait_started = sim.now()

    @sim.process
    def arrivals(self: "Clinic"):
        while True:
            sim.hold(random.exponential(1.0 / self.arrival_rate))
            handle = sim.spawn(self.patients, self)
            patient = Patient(handle)
            patient.arrival = sim.now()
            patient.acuity = 1 if random.uniform() < 0.2 else 0

The spawned process begins only after the current process blocks. That gives the spawning process a clean initialization window: create the process, write its struct fields through the handle, and then let simulated time continue.

Joining and reclaiming

sim.wait_process(handle) waits for a spawned process to finish. sim.despawn(handle) reclaims a finished spawned process:

class Clinic(sim.Model):
    @sim.process(spawnable=True)
    def patients(self: "Clinic", patient: Patient):
        sim.hold(1.0)

    @sim.process
    def arrivals(self: "Clinic"):
        handle = sim.spawn(self.patients, self)
        Patient(handle).arrival = sim.now()
        sim.wait_process(handle)
        sim.despawn(handle)

Long-running models should reclaim finished dynamic processes when they are no longer needed. A common pattern is to put finished handles into a sim.Store and have a cleanup process despawn them:

class Clinic(sim.Model):
    departures: sim.Store

    @sim.process
    def cleanup(self: "Clinic"):
        while True:
            handle = self.departures.take()
            sim.despawn(handle)

    @sim.process(spawnable=True)
    def patients(self: "Clinic", patient: Patient):
        # ... patient journey ...
        self.departures.put(sim.current())

Leftover spawned processes are stopped and reclaimed at the end of a trial, but explicit cleanup keeps long trials from accumulating completed agents.

Component-owned spawnables

Components can own spawnable processes. This keeps dynamic agents close to the subsystem that creates them:

class Intake(sim.Component):
    @sim.process
    def arrivals(self, env):
        handle = sim.spawn(self.patient, env)
        Patient(handle).arrival = sim.now()

    @sim.process(spawnable=True)
    def patient(self, env, p: Patient):
        sim.hold(random.exponential(env.mean_service))

Use this when the dynamic process is naturally part of a component. Use a model-level @sim.process(spawnable=True) when it crosses many domains.

For a larger worked example with dynamic agents and resources, see Tutorial: Modeling with Cimba Python.