Containers and Content
Containers and content are separate concepts.
A Minimal Container
let target = tube(label = "Target", capacity = 100uL);This creates a logical tube container. The label is human-readable metadata. The capacity is a volume bound.
Container Families
Culsma includes these constructor families:
| Constructor | Meaning |
|---|---|
container(...) | Generic container constructor. |
tube(...) | Tube-like container. |
well(...) | Plate-well container. |
chamber(...) | Chamber-like container. |
surface(...) | Surface-like target; volume capacity is not allowed. |
plate(...) | Plate descriptor used to select concrete well positions. |
Common container arguments:
| Argument | Meaning |
|---|---|
spec | Container specification text. |
carrier_kind | Carrier type, such as plate context. |
carrier_id | Carrier identifier. |
carrier_position | Position inside a carrier. |
capacity | Volume capacity, except for surface(...). |
open | Open/closed state flag. |
label | Human-readable label. |
barcode | Barcode or stable external identifier. |
load | Initial content load list. |
tube(...), well(...), chamber(...), and surface(...) inject their container kind implicitly. Authors normally do not write kind when using those sugar forms.
Plate Selectors and Per-Well Capacity
plate(...) describes a carrier rather than allocating one volume-bearing container. A selector materializes the selected positions as concrete wells:
let plate24 = plate(
label = "EditingPlate",
format = PlateFormat.WELL_24,
carrier_id = "EditingPlate",
capacity = 3mL
);
let editing_wells = plate24[A1:A3];format accepts the PlateFormat members WELL_6, WELL_12, WELL_24, WELL_48, WELL_96 and WELL_384. Strings such as "24well" remain accepted. Custom layouts use explicit rows/cols; selector bounds follow the actual bound format or layout.
For a plate descriptor, capacity is the capacity of each selected well, not the sum of all wells on the plate. Each well synthesized from editing_wells therefore has a capacity of 3mL.
Initial Load
let source = tube(
label = "Source",
capacity = 100uL,
load = [content(kind = ContentKind.FORMULATION, type = ContentType.BUFFER, code = "BUF01", attrs = { role: ContentRole.WASH }):10uL]
);The load list belongs to the constructor/init boundary. Each item must be a content spec paired with a quantity:
load = [content_spec:quantity]The quantity must carry a volume, mass, or count unit. It may be a literal or a calculated quantity, such as total_volume / dilution. A cells quantity requires cellular content. Supply the carrier liquid separately when its volume is known.
Reading the Current Volume
The source above starts with 10uL in a tube whose capacity is 100uL. After initialization, transfers can change the amount inside. Read that amount from the container when it is needed:
let current_volume = source.contents.volume;At this point, current_volume is 10uL. The read returns the modeled aggregate contents volume in uL, independently of the tube's capacity. It does not measure the liquid or change the material. Later transfers affect new reads; the value already saved in current_volume stays the same.
The quantity can be used in calculations, including an aliquot based on the current contents.
Content Specs
Content describes material identity. It is not the container holding the material.
Canonical content constructor:
| Constructor | Meaning |
|---|---|
content(kind = ..., type = ..., ...) | Content form; kind and type are explicit. |
Compatibility sugar forms such as blood(...), reagent(...), and buffer(...) are accepted for older source. New example protocols should use content(...) directly.
Common content arguments:
| Argument | Meaning |
|---|---|
kind | Broad material family; required only for content(...). |
type | Canonical material type token for the selected kind. |
code | Stable content identifier. |
name | Human-readable content name. |
attrs | Structured metadata record, for details such as role or state. |
Kind and Type
kind is the broad material family. type is the material refinement inside that family.
kind | Canonical type values |
|---|---|
bio_entity | organism, organ, tissue, other_bio_entity |
bio_fluid | whole_blood, plasma, serum, buffy_coat, urine, saliva, lymph, cerebrospinal_fluid, tears, semen, ascites, synovial_fluid, bronchoalveolar_lavage_fluid, other_body_fluid |
bio_cellular | cell_line, primary_cells, cell_population, microbial_cells, other_cellular_material |
bio_subcellular | organelle, membrane, vesicle, cytoskeletal_structure, other_subcellular_structure |
bio_molecule_or_virus | dna, rna, protein, virus, other_biomolecule_or_virus |
chemical | organic_compound, inorganic_compound, solvent, detergent, dye, other_chemical |
particulate | beads, resin, particle, other_particulate |
formulation | medium, buffer, supplement, master_mix, gradient_medium, other_formulation |
Common examples:
content(kind = ContentKind.FORMULATION, type = ContentType.BUFFER, code = "BUF01", attrs = { role: ContentRole.WASH })
content(kind = ContentKind.BIO_MOLECULE_OR_VIRUS, type = ContentType.DNA, code = "DNA01", attrs = { role: ContentRole.TEMPLATE })
content(kind = ContentKind.CHEMICAL, type = ContentType.SOLVENT, code = "WATER")For local or uncommon material names, keep kind and type canonical and put the local detail in name, code, or attrs. The attrs value is a record literal, for example { role: wash, state: ready }:
content(
kind = ContentKind.BIO_FLUID,
type = ContentType.OTHER_BODY_FLUID,
code = "AF01",
name = "amniotic fluid"
)Older custom_... or unknown type strings are accepted in compatibility mode with a warning. New source should use the canonical other_* type for the selected kind instead.
Content Attributes
attrs is an optional metadata record. Its standard role, state and bead-property values use separate enum families:
| Attribute | Example | Python extension base |
|---|---|---|
role | ContentRole.WASH, ContentRole.CULTURE | ContentRoleBase |
state | ContentState.SUSPENSION, ContentState.SOLUTION | ContentStateBase |
bead_property | BeadProperty.MAGNETIC | BeadPropertyBase |
These types belong to culsma.domains.content. Attributes remain optional and open: unknown keys and historical values are preserved. A registered metadata member does not grant physical behavior or impose a new kind/type/role restriction. Standard values persist as text; extensions retain their registered identity.
Declaring and Changing Material
Use content(...) inside a container's load list to describe its initial material. The replacement operation described below also accepts content descriptors for its products. A standalone content declaration is not an executable material operation; DefineContent(...) and LoadContent(...) are lowering targets rather than normal authoring forms.
After initialization, choose the operation according to what changes:
| Change | Form |
|---|---|
| Move existing material between containers | target << [source:5uL]; |
| Move all of one source and a measured amount of another, in order | target << [source_a, source_b:5uL]; |
| Distribute existing material into outputs | sep(sample = source, program = ...); |
| Change an existing entry's relationship in a separation output | sep(..., transitions = [...]); |
| Declare new material identities derived from a cellular entry | source.materials.replace(subject = ..., products = [...]); |
Replacement is a development prototype for author-declared composition changes. Its supported input and product quantities are described below. Ordinary local assignment does not edit the material store.
Material Entries and Selectors
Runtime represents the material inside a container as normalized component entries. Each live entry has two distinct identities:
| Field | Meaning |
|---|---|
content_ref | Identifies what the entry contains. Multiple entries may have the same content reference. |
entry_id | Identifies that physical component entry within its containing container. It is non-empty and container-local unique. |
When runtime generates entry IDs, it uses the unsuffixed content_ref when available. A collision receives the first available underscore ordinal: DNA, DNA_1, DNA_2, and so on. Existing IDs are opaque and are not rewritten; for example, an imported DNA::1 remains valid even though runtime does not generate new IDs in that form.
A material-entry selector identifies the subject of a replacement or separation transition. Selection itself does not change material:
source.materials[0]
source.materials.get("DNA_1")materials[index] selects by zero-based position in the normalized ordered list of live entries: entries with positive known quantity, or unknown quantity with a retained positive share. Its index must be statically known and non-negative. Positions can change when an entry is replaced.
materials.get(entry_id) matches one exact, statically known, non-empty entry ID in the same list. It does not search content_ref, code, name, labels, or prefixes, and it does not fall back to another entry containing the same content. A missing or duplicate ID is an error rather than an ambiguous match.
Both forms identify one entry. For replacement, the subject must use the same container binding as the materials.replace(...) receiver. For a separation transition, subject and component-bound association selectors must use the sep(...) sample. Ordinary sep_group[index] still selects an output container.
Declaring Replacement Products
A disruption step does not by itself specify which new components to track or how much of each was produced. After collecting the material into a named container, an author can declare the products explicitly. This example starts with a collected sample whose cells and carrier volume are already recorded:
let lysate = tube(
label = "Lysate",
capacity = 1mL,
load = [
content(kind = ContentKind.BIO_CELLULAR, type = ContentType.CELL_LINE, code = "CELLS"):200000cells,
content(kind = ContentKind.FORMULATION, type = ContentType.BUFFER, code = "LYSIS_BUFFER"):300uL
]
);
lysate.materials.replace(
subject = lysate.materials.get("CELLS"),
products = [
content(kind = ContentKind.BIO_MOLECULE_OR_VIRUS, type = ContentType.PROTEIN, code = "PROTEIN"):unknown(dimension = mass)
]
);The cellular entry is retired and a protein entry takes its place. The container and its 300uL of buffer remain; the protein mass is unknown. Runtime records which source entry the product came from. This is an authored composition declaration, not a calculated yield or a complete enumeration of cell contents. For example, it does not declare a debris pellet unless debris is also listed.
The current prototype accepts one counted cellular subject and a nonempty list of products with unknown(dimension = mass). Each product must introduce a new content identity in that container. It does not accept a supplied numeric yield, convert cell count into mass, or replace an entry already quantified by volume or mass. Material represented by active indexed contents must first be collected into a container. Validation and replacement commit together; a rejected product leaves the material and content registry unchanged.
Continuing with the Products
The new protein identity can now appear in an authored separation rule. The following continuation assigns all tracked protein and buffer to the supernatant, then takes one third of its known liquid volume:
let clarified = sep(
sample = lysate,
program = centrifuge_program(drive = 12000g),
component_fates = {
PROTEIN: {supernatant: 100%, pellet: 0%},
LYSIS_BUFFER: {supernatant: 100%, pellet: 0%}
}
);
let aliquot = tube(label = "Aliquot");
aliquot << [clarified[0]:100uL];The aliquot has 100uL of liquid and one third of the original unknown protein amount. Its protein mass remains unknown. The separation percentages are required author choices for these unknown-mass products; they do not follow automatically from replacement. Products remain free in this prototype, so relationship transitions on them are not supported. See Units for quantity limits and Separation and Fractionation for output roles and relationship transitions.
