Audience: Equipment Integrator Prerequisites: Wiring the Node Time: ~25 minutes
Many laboratory instruments interact with specific physical locations -- deck slots on a liquid handler, sample positions on a plate reader, or waypoints for a robot arm. MADSci uses a layered ownership model to manage these locations:
- Node-managed locations -- intrinsic to a node's hardware, auto-created on startup, lifecycle tied to the node.
- Training -- cross-node representation additions that teach one node how to access another node's locations.
- Lab-managed locations -- defined in a reconcilable lab config file, lifecycle managed by the operator.
Each node needs its own "view" of a location (a representation): the robot arm needs joint angles and gripper configuration, while the liquid handler needs a deck slot number and plate type. Representation templates define the schema and defaults for these views.
Every location has a managed_by field set to one of two values from the LocationManagement enum:
| Value | Meaning | Lifecycle |
|---|---|---|
LocationManagement.NODE |
Created by a node via intrinsic_locations |
Tied to node startup; auto-created idempotently |
LocationManagement.LAB |
Created by lab config or API | Managed by operator/integrator |
Locations also carry an optional owner field (OwnershipInfo) for provenance tracking. Node-managed locations automatically set owner.node_id to the owning node's ID.
Nodes declare locations that are intrinsic to their hardware using the intrinsic_locations class variable. Each entry is a NodeIntrinsicLocationDefinition. On startup, the node's intrinsic_location_handler() registers these with the Location Manager via the idempotent POST /location/init endpoint.
Location names are automatically prefixed with {node_name}. to ensure uniqueness across node instances. For example, location_name="deck_1" on a node named liquidhandler_1 becomes liquidhandler_1.deck_1.
| Field | Type | Description |
|---|---|---|
location_name |
str |
Suffix for the name (auto-prefixed with {node_name}.) |
description |
str (optional) |
Human-readable description |
representation_template_name |
str |
Name of the representation template to use |
representation_overrides |
dict |
Per-location overrides merged with template defaults |
resource_template_name |
str (optional) |
Resource template for creating a resource at this location |
resource_template_overrides |
dict (optional) |
Overrides for the resource template |
allow_transfers |
bool |
Whether this location participates in transfer planning (default True) |
tags |
list[str] (optional) |
Tags for categorization |
from typing import ClassVar
from madsci.common.types.node_types import (
NodeIntrinsicLocationDefinition,
NodeRepresentationTemplateDefinition,
RestNodeConfig,
)
from madsci.node_module.rest_node_module import RestNode
class LiquidHandlerNode(RestNode):
config: RestNodeConfig = RestNodeConfig()
config_model = RestNodeConfig
# Representation templates -- registered by template_handler()
location_representation_templates: ClassVar[
list[NodeRepresentationTemplateDefinition]
] = [
NodeRepresentationTemplateDefinition(
template_name="lh_deck_repr",
default_values={"deck_type": "standard", "max_plates": 1},
schema_def={
"type": "object",
"properties": {
"deck_position": {
"type": "integer",
"minimum": 1,
"description": "Deck slot number on the liquid handler",
},
"deck_type": {
"type": "string",
"enum": ["standard", "deep_well", "pcr"],
},
},
"required": ["deck_position"],
},
required_overrides=["deck_position"],
tags=["liquid_handler", "deck"],
version="1.1.0",
description="Liquid handler deck slot representation",
),
]
# Intrinsic locations -- auto-created on startup with '{node_name}.' prefix
intrinsic_locations: ClassVar[list[NodeIntrinsicLocationDefinition]] = [
NodeIntrinsicLocationDefinition(
location_name=f"deck_{i}",
description=f"Deck slot {i}",
representation_template_name="lh_deck_repr",
representation_overrides={"deck_position": i},
resource_template_name="liquid_handler_deck_slot",
allow_transfers=True,
)
for i in range(1, 5)
]When this node starts as liquidhandler_1, four locations are created:
liquidhandler_1.deck_1liquidhandler_1.deck_2liquidhandler_1.deck_3liquidhandler_1.deck_4
Each is marked managed_by=NODE with owner.node_id set to the liquid handler's node ID.
Define location_representation_templates as a ClassVar on your node class. Each entry is a NodeRepresentationTemplateDefinition with the following fields:
| Field | Type | Description |
|---|---|---|
template_name |
str |
Unique name for this template (e.g., "robotarm_deck_access") |
default_values |
dict |
Default field values merged with overrides at instantiation |
schema_def |
dict (optional) |
JSON Schema for validating representation data |
required_overrides |
list[str] (optional) |
Fields that must be provided per-location |
tags |
list[str] (optional) |
Tags for discovery and filtering |
version |
str |
Semantic version of this template (default "1.0.0") |
description |
str (optional) |
Human-readable description |
class RobotArmNode(RestNode):
location_representation_templates: ClassVar[
list[NodeRepresentationTemplateDefinition]
] = [
NodeRepresentationTemplateDefinition(
template_name="robotarm_deck_access",
default_values={
"gripper_config": "standard",
"max_payload": 2.0,
},
schema_def={
"type": "object",
"properties": {
"position": {
"type": "array",
"items": {"type": "number"},
"minItems": 3,
"maxItems": 4,
"description": "Joint angles or XYZ(+rotation) for this location",
},
"gripper_config": {
"type": "string",
"enum": ["standard", "wide", "vacuum"],
},
"max_payload": {
"type": "number",
"minimum": 0,
},
},
"required": ["position"],
},
required_overrides=["position"],
tags=["robot_arm", "deck"],
version="1.1.0",
description="Robot arm deck access with joint positions",
),
]Key points:
schema_defprovides a JSON Schema that the Location Manager and dashboard use to validate data and render form fields. If omitted, representation data is freeform JSON.default_valuesare merged with overrides when a location is created. In this example, every location getsgripper_config: "standard"unless the operator overrides it.required_overrideslists fields that have no sensible default and must be supplied per-location. Thepositionfield varies for every physical location, so it is required.
Location templates compose multiple representation templates into a reusable blueprint. They use abstract role names instead of concrete node instance names, so the same template works across different lab configurations.
from madsci.common.types.location_types import LocationTemplate
# Location templates can be defined in code, via the API, or in a lab config file.
template = LocationTemplate(
template_name="lh_accessible_deck_slot",
description="Deck slot accessible by both liquid handler and robot arm",
representation_templates={
"deck_controller": "lh_deck_repr",
"transfer_arm": "robotarm_deck_access",
},
resource_template_name="location_container",
tags=["liquid_handler", "deck", "accessible"],
version="1.0.0",
)The representation_templates mapping uses abstract role names as keys:
"deck_controller"-- the liquid handler that owns this deck slot"transfer_arm"-- the robot arm that can access this slot
When a location is instantiated from this template, the operator provides node bindings that map roles to concrete node instances (e.g., deck_controller: liquidhandler_1, transfer_arm: robotarm_1).
Training teaches a node how to access locations it does not own. For example, a robot arm needs to know the coordinates for reaching a liquid handler's deck slot, but the deck slot is owned by the liquid handler.
Training entries are defined in the lab config file (locations.yaml) and applied during reconciliation:
training:
- location_name: liquidhandler_1.deck_1
node_name: robotarm_1
representation_template_name: robotarm_deck_access
overrides:
position: [10, 15, 5]
- location_name: liquidhandler_1.deck_3
node_name: robotarm_1
representation_template_name: robotarm_deck_access
overrides:
position: [20, 15, 5]Each RepresentationTrainingEntry has the following fields:
| Field | Type | Description |
|---|---|---|
location_name |
str |
Name of the existing location to add a representation to |
node_name |
str |
Name of the node providing the representation |
representation_template_name |
str (optional) |
Representation template to use for defaults/schema |
overrides |
dict |
Representation values merged with template defaults |
When training is applied, the specified node's representation is added to the location. If a representation_template_name is provided, its defaults are merged with the overrides; otherwise, the overrides are used as-is.
Training is idempotent -- applying the same entry twice is safe.
The LabLocationConfig format provides a reconcilable living document for lab-level location management. The Location Manager reads this file (default: locations.yaml) on each reconciliation cycle and merges its contents with the live database using desired-state semantics.
Set the file path via the lab_config_file setting on LocationManagerSettings:
# In settings.yaml
location_lab_config_file: locations.yamlOr via environment variable:
export LOCATION_LAB_CONFIG_FILE=locations.yamlThe file is discovered using walk-up search from the current working directory.
# locations.yaml — LabLocationConfig format
# Lab-level representation templates (optional)
representation_templates:
- template_name: my_custom_repr
default_values: { slot_index: 0 }
version: "1.0.0"
# Reusable location blueprints (optional)
location_templates:
- template_name: accessible_deck_slot
representation_templates:
deck_controller: lh_deck_repr
transfer_arm: robotarm_deck_access
version: "1.0.0"
# Cross-node representation additions
training:
- location_name: liquidhandler_1.deck_1
node_name: robotarm_1
representation_template_name: robotarm_deck_access
overrides:
position: [10, 15, 5]
# Lab-managed locations (not owned by any node)
locations:
- location_name: storage_rack
description: "High-capacity storage accessible only by robot arm"
representations:
robotarm_1:
gripper_config: wide
max_payload: 10.0
position: [30, 25, 10]
resource_template_name: location_containerThe lab config file uses desired-state-with-warnings semantics:
- Representation templates: Synced via init (get-or-create, version-update).
- Location templates: Synced via init (get-or-create, version-update).
- Locations: Get-or-create. Existing locations are not overwritten.
- Training: Applied idempotently. If the target location does not yet exist (e.g., the node has not started), the entry is skipped with a warning and retried on the next cycle.
The file is cached by mtime -- the Location Manager only re-reads it when the file modification time changes.
| Method | Path | Description |
|---|---|---|
POST |
/reconcile |
Manually trigger a reconciliation cycle |
GET |
/reconciliation/status |
Get status of the last reconciliation cycle |
Background reconciliation runs automatically at a configurable interval when reconciliation_enabled is True (default).
The template_handler() method on AbstractNode automatically registers all representation templates with the Location Manager at startup. The intrinsic_location_handler() then registers intrinsic locations. Both run before startup_handler(), so your templates and locations are available by the time your node initializes.
Registration is idempotent: if the template or location already exists with the same version/name, it is not re-created. If a template's version number has increased, the template is updated.
Errors are handled per-item and are non-blocking -- a single failed registration (e.g., because the Location Manager is temporarily unreachable) does not prevent the node from starting. Failed registrations are logged as warnings.
You do not need to call template_handler() or intrinsic_location_handler() yourself; the framework invokes them automatically.
When a node starts, it populates its NodeInfo with the declared templates and intrinsic locations. Consumers (other services, the dashboard, scripts) can discover these by querying the /info endpoint:
curl http://localhost:2002/info | jq '.intrinsic_locations'
curl http://localhost:2002/info | jq '.location_representation_templates'The NodeInfo model exposes:
intrinsic_locations-- list ofNodeIntrinsicLocationDefinitionlocation_representation_templates-- list ofNodeRepresentationTemplateDefinition
POST /location/init
Get-or-create a location. If a location with the given name exists, return it unchanged. If it does not exist, create it with lazy resource resolution. Used by nodes to register intrinsic locations.
GET /locations?managed_by=node
GET /locations?managed_by=lab
Filter locations by management type. Useful for dashboards and debugging.
The health endpoint (GET /health) includes location management counts:
num_node_managed_locations-- number of locations withmanaged_by=NODEnum_lab_managed_locations-- number of locations withmanaged_by=LABlast_reconciliation_at-- timestamp of the last reconciliation cycle
Representations enable the Location Manager to plan transfers between locations. When a robot arm has a representation for two deck slots, the transfer planner knows the arm can move items between them.
Training is what makes transfers possible across node boundaries. Without training the robot arm on the liquid handler's deck slots, the transfer planner would not know the arm can reach those locations.
The transfer graph is automatically rebuilt when:
- A new location is created (via API or node startup)
- Lab config reconciliation creates or updates locations
- Training is applied
The LocationClient provides methods for working with the ownership model programmatically.
from madsci.client.location_client import LocationClient
from madsci.common.types.location_types import LocationManagement
client = LocationClient(location_server_url="http://localhost:8006")
# Idempotent location init (used internally by nodes)
location = client.init_location(
location_name="mynode.slot_1",
representations={"mynode": {"slot_index": 1}},
managed_by=LocationManagement.NODE,
resource_template_name="plate_nest",
)# Get all node-managed locations
node_locations = client.get_locations(managed_by="node")
# Get all lab-managed locations
lab_locations = client.get_locations(managed_by="lab")import httpx
response = httpx.get("http://localhost:8006/reconciliation/status")
status = response.json()
print(status["last_reconciliation_at"])
print(status["reconciliation_enabled"])Declare intrinsic locations on the node class. If a location is physically part of your hardware (deck slots, sample positions), declare it in intrinsic_locations. This ensures locations are created automatically on startup and tied to the node's lifecycle.
Use training for cross-node access. When a robot arm needs to access a liquid handler's deck, define training entries in locations.yaml rather than hard-coding representations on the liquid handler's intrinsic locations.
Use schema_def for structured representations. JSON Schema enables the dashboard to render typed form fields and validates data on the server side.
Put location-specific values in required_overrides. Fields like position or deck_position vary per physical location and should not have defaults.
Version your templates. Use semantic versioning for templates. The init methods perform version-aware updates: if the registered template has an older version, it is updated; if it has the same or newer version, it is left unchanged.
Keep representation template names globally unique. Use a prefix based on your node type (e.g., robotarm_, lh_) to avoid collisions.
Use lab-managed locations for shared infrastructure. Storage racks, waste bins, and other locations not intrinsic to any single node belong in the lab config file.
- Testing Strategies -- test nodes that use intrinsic locations
- Example Lab README -- see the ownership model in action
- Node Development Quick Reference -- concise template reference