Entity Object Representation¶
Hybrid-analog computers are complicated devices that combine analog circuitry
with digital electronics (microcontrollers). Seemingly simple operations, such
as setting a coefficient, require a large amount of steps to happen on the
device before the electrical components are configured as required. The goal
of the entity object model is to build a high-level abstraction over these
mechanics. Within the LUCIstack, pybrid occupies the architecture layer
in which users operate on the level of components (a.k.a. entities) instead
of electrical switches. The firmware, the layer offering services to pybrid,
represents the microarchitectural level and turns configurations produced by
pybrid into actual realizations on the analog computer.
Structure of the entity object representation¶
The main class is pybrid.base.hybrid.entities.Entity, where an entity is a
configurable object (a component with some dedicated function) in the
hybrid-analog device. Each entity has a Path where the layout is roughly
<Carrier MAC>/<Cluster ID>/<Block>[/{function}], with:
Carrier MACis the (unique) MAC address of the mREDAC or LUCIDAC board that houses the Teensy microcontroller.Cluster IDis either0for the LUCIDAC or one of{0, 1, 2}for mREDACs.Blockis one of{M0, M1, C, U, I, T, ST0, ST1, ST2, FP}according to the functional unit.functionis optional and can address individual entity parts, such as a single lane in the C-Block.
For more information on the hardware structure and how the paths above map to physical components, refer to the section on hardware architecture.
In order to retrieve the entity object model for a device, we connect to it and
let pybrid automatically create the model:
import asyncio
from pybrid.redac.controller import Controller as REDACController
async def main():
# connect to an anabrid device in your network
controller = REDACController()
await controller.add_device("<DEVICE IP>", 5732)
# the `.computer` attribute then contains the entity object representation
redac = controller.computer
carrier = redac.carriers[0]
cluster_1 = carrier.clusters[1]
m0_block = cluster_1.m0block
m0_block_entity = m0_block.entity_type
print("Type:", m0_block_entity.type_)
print("Version:", m0_block_entity.version)
# continue to apply settings to the M0Block, e.g. an integrator's IC
m0_block.elements[0].ic = -0.42
# ...
if __name__ == "__main__":
asyncio.run(main())
Each entity has a type and a version, both stored in the device's EEPROM
and firmware. Behind the scenes, the model is constructed from the protobuf
messages the device sends to the client to describe its hardware (see below).
After configuration, via the process of serialization (also covered below),
the entity object model's configuration is turned into a protobuf message and
sent to the device, where the firmware applies it to the hardware. This is the
preferred way of working with an analog computer: connect, retrieve the entity
object model, configure the model, serialize the configuration, apply it to
the analog computer, integrate, and receive results. It corresponds roughly to
assembly-level programming in the digital world. When going via the redacc
compiler, we do not need to go through pybrid's entities; the compiler
implements its own entity representation and directly emits protobuf files.
Relation to analog protobuf¶
anabrid's protobuf protocol (open-source, see
analog-protobuf) contains all
possible messages exchanged with the analog devices. This includes the
configurations we send as part of the ConfigureCommand; a message is not
necessarily a message in the sense of being sent over the wire, but can also
translate to an object in the object representation. More specifically,
configurations can be expressed as a Bundle containing Items, where each
Item carries the configuration for one functional block addressed by a path
(see above). The protocol therefore serves a dual purpose: it types the
communication messages between host and device, and it makes it possible to
store configurations (see the File message) to disk and exchange them.
Specification vs. configuration¶
When looking at the Items in a Module, we generally distinguish between two
kinds of entry. An EntitySpecification carries the definition of an entity
(its type and address), and thereby dictates the hierarchical makeup of the
device. All other items carry configuration data. When referring to a
specification inside a Module, we mean only the EntitySpecifications; the
remaining parts are called a configuration.
A specification shows what hardware is present within an analog computer
(think, e.g., of M-blocks which can be freely exchanged) and what the hierarchy
of blocks is, including their types and versions. A configuration states how
entities are configured, i.e. how their functions are set; in a C-block, for
example, this contains the coefficients the user has set. Both parts can be
combined into a single message, and in most cases you want to include the
specification alongside a configuration so the device can check it and report
changed hardware (e.g. when applying an old circuit). Specifications and
configurations can be retrieved separately with different flags to the
pybrid extract command.
Serialization and deserialization¶
Serialization in pybrid is the process of converting a populated entity
object representation (a tree of Entity instances, hanging off the
AnalogComputer / REDAC root) into a protobuf Module containing Items,
where each Item either carries an EntitySpecification (the "what is there")
or a block-specific config message (CoefConfig, ItorConfig, MDRConfig,
...) addressed by an EntityId.path. Deserialization is the inverse: take a
Module (loaded from disk, or received from a device after extract) and
either build the entity tree from scratch (when only EntitySpecification
items are present) or apply the configuration items onto an existing entity
tree.
Both operations live in the dedicated protocol subpackage of each device
family: REDAC uses pybrid.redac.protocol.serializer with REDACSerializer
and REDACDeserializer; LUCIDAC uses pybrid.lucidac.protocol.serializer
with LUCIDACSerializer and LUCIDACDeserializer (subclassing the REDAC
versions); the simulator's variants live under pybrid.sim.protocol.serializer.
Both classes use Python's functools.singledispatchmethod to dispatch on the
concrete entity / config type, so adding a new block usually means adding two
@_serialize_configuration.register / @_deserialize_configuration.register
methods (see the worked example below).
flowchart LR
A["Entity tree<br/>REDAC/Carrier/Cluster/Block"] -- serialize --> B["pb.Module<br/>EntitySpecification + Config Items"]
B -- store_module --> C[".apb file on disk"]
B -- ConfigCommand --> D["Analog device firmware"]
D -- extract response --> B
C -- load_module --> B
B -- deserialize_specification --> A
B -- deserialize_configuration --> A
A typical end-to-end "configure → apply" workflow uses serialization implicitly via a session:
from pybrid.redac.controller import Controller as REDACController
async def main():
controller = REDACController()
await controller.add_device("<DEVICE IP>", 5732)
async with controller:
redac = controller.computer
m0_block = redac.carriers[0].clusters[0].m0block
# configure entities in-place
m0_block.elements[0].ic = -0.42
# the session serializes `redac` and sends a ConfigCommand
await controller.create_session().set_config(redac).execute()
If you want to inspect or persist the serialized representation directly, use
the serializer class together with ProtoIO:
from pybrid.redac.protocol.serializer import REDACSerializer
from pybrid.base.proto.io import ProtoIO
serializer = REDACSerializer()
module = serializer.serialize(redac) # pb.Module
ProtoIO.store_module(module, "my_circuit.apb")
The reverse path uses the matching deserializer. Note that
deserialize_configuration requires the target entity tree to already exist;
the deserializer matches each Item to its entity via the path:
from pybrid.redac.protocol.serializer import REDACDeserializer
from pybrid.base.proto.io import ProtoIO
module = ProtoIO.load_module("my_circuit.apb")
deserializer = REDACDeserializer(computer=redac)
deserializer.deserialize_configuration(module)
The pybrid extract CLI command is the shell-level equivalent of the above
and supports flags to limit the dump to specification only, configuration only,
or both (see the protocol primer).
Extending the representation¶
Because the entity object representation is a mapping between Python classes,
protobuf messages, and what the firmware understands, extending the
representation always touches at least three layers: the Python entity class
plus its registration in EntityType, the (de)serialization handlers
translating between Python and protobuf, and the protobuf schema itself (and
the firmware on the device that consumes the new message). The MDR block
(MMDRBlock) is a recent addition and serves as a good worked example for
what each of those three steps looks like in practice.
Worked example: the MDR block¶
The MDR block is a math block (M-block) whose four elements can each be
configured as one of MULTIPLY, SQUARE, DIVIDE, SQRT, IDENT — i.e.,
in addition to the per-element computation, the block needs a per-element
operation type to be communicated to the firmware. The relevant locations in
the codebase are:
- Entity class:
packages/pybrid-computing/src/pybrid/redac/blocks/mblock.pydefinesMMDRBlocknext toMIntBlockandMMulBlock. It is registered via@EntityType.register(EntityClass.MBLOCK, 3)so a device reporting an M-block oftype=3is automatically mapped to this class. - Computation type:
packages/pybrid-computing/src/pybrid/redac/computations.pyintroducesMDROperation, aBaseComputationwhoseopfield holds one ofMultiplication | Square | Division | SquareRoot | Identity. - Export: the new class is re-exported from
packages/pybrid-computing/src/pybrid/redac/blocks/__init__.pyso the serializer and downstream code can import it from the package root. - Serializer: in
packages/pybrid-computing/src/pybrid/redac/protocol/serializer.pythe_serialize_configuration.registerhandler forMMDRBlockbuilds apb.MDRConfigby mapping each element'sopinstance to the matching enum value. - Deserializer: the inverse handler in the same file dispatches on
pb.MDRConfigand writes the correspondingMultiplication() / Square() / ...instance back intoentity.elements[i].op. - Protobuf schema: in the upstream
analog-protobufrepository,MDRConfigis defined with anoperationsfield (repeated Operation) and anOperationenum (MULTIPLY,SQUARE,DIVIDE,SQRT,IDENT). TheItemoneof was extended with anmdr_configfield so a singleItemcan carry an MDR configuration.
Adding a new block — step by step¶
When extending pybrid with a new (M-)block, you generally walk through the
same set of steps as above; the rough recipe is:
- Extend the protobuf schema in the
analog-protobufrepository. Add a new<Block>Configmessage describing the block's configuration payload (enums, lane lists, calibration data, ...) and extend theItemoneof (kind) with a new field referencing the new config message, picking an unused field number. Regenerate the Python bindings (main_pb2.py) and bump the protocolVersion; if the change is backwards-compatible (a new optional field) increment the minor, otherwise major. - Add a Python entity class under
packages/pybrid-computing/src/pybrid/redac/blocks/. SubclassMBlock(orElementBlockfor non-M blocks), declareELEMENTSandelementswith the appropriateComputationElement[...]parametrization, and register it with@EntityType.register(EntityClass.MBLOCK, <new_type_id>)so the deserializer can map the entity tree reported by the device. Finally, re-export the class fromblocks/__init__.py. - If needed, add a computation type in
computations.pythat captures the block's per-element configuration (e.g., a wrapper similar toMDROperation). - Register serializer and deserializer handlers in
redac/protocol/serializer.py. Add one@_serialize_configuration.registerfor the new block class that creates a freshpb.Itemviaself.cc.new_config(entity)and fills the new config field, and one@_deserialize_configuration.registerfor the newpb.<Block>Configmessage that locates the entity by path and writes the values back. - Extend the firmware so the device can consume the new
Itemkind. The firmware dispatches on theItem.kindoneof, applies the configuration to the actual hardware, and reports the block under the matchingEntityClass/type/versiontriple when answering aDescribeCommand. - Teach the compiler (
redacc/ lucipy) about the new block, both for allocating the new resource type when mapping computations and for emitting the matching configuration items. Without this step the block is reachable only via direct entity-API use, not via the higher-level circuit description languages. - Add tests at the boundary: at minimum a round-trip test
(
serialize → deserialize → compare) for the new config, plus a device test behind thedevicemarker once firmware support lands.