Observations

An Observation is the typed snapshot of every sensor reading the robot publishes each frame. Observations flow from the robot through the relay server to every controller, and they are the same Python object you record to disk, replay, and feed to a learned policy.

Defined in solarian/core/observation.py.

What is an Observation?

An Observation is a top-level container of ModuleObs instances, one per Module on your robot. Each ModuleObs in turn is a container of typed Data values — the actual sensor readings.

Observation
 ├── arm: ArmObs
 │    ├── shoulder_pan_pos: PosData
 │    └── shoulder_lift_pos: PosData
 └── gripper_camera: CameraObs
      ├── rgb: ImageData
      └── bbox: BoundingBoxData    (computed on the server)

The class hierarchy is mirrored on disk and on the wire: every ModuleObs attribute matches the corresponding module's attribute name on your Robot, and every SensorField references a specific Data descriptor on a sensor.

ModuleObs

Subclass ModuleObs and bind each field to a Data reference on your Robot:

from solarian.core.observation import SensorField, ModuleObs
from myrobot.robot import MyRobot

class ArmObs(ModuleObs):
    shoulder_pan_pos = SensorField(MyRobot.arm.shoulder_pan.pos)
    shoulder_lift_pos = SensorField(MyRobot.arm.shoulder_lift.pos)
    elbow_flex_pos = SensorField(MyRobot.arm.elbow_flex.pos)

MyRobot.arm.shoulder_pan.pos is a PosData descriptor on the FeetechServo actuator. Solarian uses it to extract:

  • Type information (dtype, shape) — used to allocate shared-memory buffers and validate runtime values.
  • Path ((module_name, component_name, data_name)) — used to route the sensor read into the right field on the right module.

Observation

Subclass Observation and attach ModuleObs instances. The attribute name on Observation must equal the attribute name on Robot — Solarian validates this at class definition time:

from solarian.core.observation import Observation
from myrobot.robot import MyRobot

class MyObservation(Observation):
    arm = ArmObs()                # MyRobot.arm
    gripper_camera = CameraObs()  # MyRobot.gripper_camera

If the names disagree, you'll get a TypeError at import time:

MyObservation.arm: SensorField 'shoulder_pan_pos' references module 'arm',
but the Observation attribute is named 'gripper_camera'. Observation
attribute names must match the module names declared on the Robot subclass.

This catches schema drift before any of it makes it to the wire.

Declaring sensor fields

A SensorField binds an observation field to a single Data point on a sensor. It carries dtype/shape information from that Data and is used at runtime to wrap raw values.

from solarian.core.observation import SensorField

class CameraObs(ModuleObs):
    rgb = SensorField(MyRobot.gripper_camera.sensor.rgb_image)
    depth = SensorField(MyRobot.gripper_camera.sensor.depth_image)

You can introspect a ModuleObs subclass to discover its fields:

ArmObs.get_sensor_fields_spec()
# {'shoulder_pan_pos': SensorField(...), 'shoulder_lift_pos': SensorField(...), ...}

Calculated fields

Some observation fields aren't direct sensor reads — they're computed from other fields. A CalculatedField declares the inputs, the processor that produces the output, and where the processor runs: ExecutionPlace.SERVER or ExecutionPlace.ROBOT, the same enum that places skills.

from solarian.core.observation import CalculatedField
from solarian.core.data import BoundingBoxData
from solarian.core.execution import ExecutionPlace
from myproject.processors import BBoxProcessor

class CameraObs(ModuleObs):
    rgb = SensorField(MyRobot.gripper_camera.sensor.rgb_image)
    bbox = CalculatedField(
        inputs=[MyRobot.gripper_camera.sensor.rgb_image],
        processor=BBoxProcessor,
        execution_place=ExecutionPlace.SERVER,
        output=BoundingBoxData(),
        conf="conf/services/bbox_tracker.yaml",
    )

Leave out execution_place= to read it from an execution_place: key in the conf instead, so a deployment can move the processor between hosts. At startup, each host calls Observation.create_processors(place) to instantiate every processor whose execution_place is its own. Processors run inline as observations stream through, so by the time the controller sees the message, obs.gripper_camera.bbox is already populated.

See Server for the relay-side processor lifecycle.

  • Name
    ExecutionPlace.SERVER
    Type
    server
    Description

    Run on the relay server (typical for vision models that need a beefy machine). Robots never see the field.

  • Name
    ExecutionPlace.ROBOT
    Type
    robot
    Description

    Run on the robot itself, in the owning module's process (typical for low-latency processing of high-rate sensor data). The relay and controllers receive the result.

No host builds CONTROLLER processors.

Data classes

A Data is the typed wrapper around every value that flows through Solarian. Sensor reads return Data instances; actuator writes accept them; observations and actions are containers of them. A component declares its channels as Data class attributes, and a SensorField or ActuatorField binds to one of those.

Defined in solarian/core/data.py. All Data subclasses are Pydantic models, so they validate on construction and serialize cleanly.

Every Data carries:

  • Name
    value
    Type
    property
    Description

    The actual payload. Auto-uncompresses on read if the data is currently compressed.

  • Name
    timestamp
    Type
    float
    Description

    Unix-epoch timestamp recorded when the value was sampled.

  • Name
    dtype
    Type
    type
    Description

    Python type of the payload (float, bool, np.uint8, …).

  • Name
    shape
    Type
    tuple | None
    Description

    For tensor-valued data; None for scalars.

  • Name
    path
    Type
    (module, component, field)
    Description

    Set automatically when used as a class-attribute descriptor on a Component.

  • Name
    is_compressed
    Type
    bool
    Description

    Whether the payload is currently in compressed (bytes) form.

The built-in subclasses:

  • Name
    PosData
    Type
    float
    Description

    Joint or linear position. Default value 0.0.

  • Name
    SpeedData
    Type
    float
    Description

    Joint or linear velocity.

  • Name
    TorqueEnableData
    Type
    bool
    Description

    Whether torque is enabled on a servo.

  • Name
    ImageData
    Type
    np.ndarray
    Description

    HxWxC uint8 image. Compresses to JPEG on the wire (requires opencv-python).

  • Name
    BoundingBoxData
    Type
    np.ndarray
    Description

    Axis-aligned box (x0, y0, x1, y1) in source-image pixels, int32 of shape (4,). All zeros means no box.

  • Name
    TimestampData
    Type
    float
    Description

    Wall-clock timestamp.

Custom Data subclasses

Define your own when you need a typed channel that isn't covered by the built-ins:

from solarian.core.data import Data
from pydantic import Field

class IMUData(Data):
    dtype: type = float
    shape: tuple = (6,)   # (ax, ay, az, gx, gy, gz)
    payload: list[float] = Field(default_factory=lambda: [0.0]*6, alias="value")

Then attach it to a sensor:

class IMUSensor(Sensor):
    bus_class = IMUBus
    imu = IMUData()

IMUSensor.imu becomes the Data reference you bind to a SensorField in your ModuleObs.

Reading an observation

In a controller, the latest observation is always available as self.last_observation, protected by self.observation_lock:

class MyController(Controller):
    def control_step(self) -> MyAction:
        with self.observation_lock:
            obs = self.last_observation
        pan = obs.arm.shoulder_pan_pos.value     # auto-uncompresses if needed
        bbox = obs.gripper_camera.bbox.value
        ...

Data.value is a property that auto-uncompresses on read — useful when working with ImageData, which is JPEG-encoded on the wire and decoded only when you actually access .value.

Building observations from scratch

The easiest way to allocate a zero-initialized observation is Observation.zeros():

obs = MyObservation.zeros()
obs.arm.shoulder_pan_pos.value = 0.42
print(obs.to_dict())

Observation.zeros() walks every declared ModuleObs and SensorField/CalculatedField, allocates an empty Data of the right type, and returns a fully populated container. Useful in tests, replay, and policy code.

Compression and serialization

Each Data subclass picks its codec with _default_codec(): ImageData uses JPEG via OpenCV, and everything else falls back to zlib + pickle. Compression is opt-in: the framework calls obs.compress() before sending, and the consumer's lazy .value access decompresses on first read. The relay-bound code path is roughly:

robot:        obs.compress()  →  msgpack(obs.to_dict())  →  ZMQ
controller:   ZMQ  →  msgpack.unpackb()  →  Observation(data)
              .value access auto-uncompresses lazily

This means the controller pays the JPEG decode only for fields it actually reads.

See also

  • Actions — the counterpart that flows the other way.
  • Modules — where the underlying sensor data lives.
  • Server — running CalculatedFields on the relay.

Was this page helpful?