Skip to content

Containers and Content ​

Containers and content are separate concepts.

A Minimal Container ​

text
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:

ConstructorMeaning
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:

ArgumentMeaning
specContainer specification text.
carrier_kindCarrier type, such as plate context.
carrier_idCarrier identifier.
carrier_positionPosition inside a carrier.
capacityVolume capacity, except for surface(...).
openOpen/closed state flag.
labelHuman-readable label.
barcodeBarcode or stable external identifier.
loadInitial 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:

text
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 ​

text
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:

text
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:

text
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:

ConstructorMeaning
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:

ArgumentMeaning
kindBroad material family; required only for content(...).
typeCanonical material type token for the selected kind.
codeStable content identifier.
nameHuman-readable content name.
attrsStructured 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.

kindCanonical type values
bio_entityorganism, organ, tissue, other_bio_entity
bio_fluidwhole_blood, plasma, serum, buffy_coat, urine, saliva, lymph, cerebrospinal_fluid, tears, semen, ascites, synovial_fluid, bronchoalveolar_lavage_fluid, other_body_fluid
bio_cellularcell_line, primary_cells, cell_population, microbial_cells, other_cellular_material
bio_subcellularorganelle, membrane, vesicle, cytoskeletal_structure, other_subcellular_structure
bio_molecule_or_virusdna, rna, protein, virus, other_biomolecule_or_virus
chemicalorganic_compound, inorganic_compound, solvent, detergent, dye, other_chemical
particulatebeads, resin, particle, other_particulate
formulationmedium, buffer, supplement, master_mix, gradient_medium, other_formulation

Common examples:

text
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 }:

text
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:

AttributeExamplePython extension base
roleContentRole.WASH, ContentRole.CULTUREContentRoleBase
stateContentState.SUSPENSION, ContentState.SOLUTIONContentStateBase
bead_propertyBeadProperty.MAGNETICBeadPropertyBase

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:

ChangeForm
Move existing material between containerstarget << [source:5uL];
Move all of one source and a measured amount of another, in ordertarget << [source_a, source_b:5uL];
Distribute existing material into outputssep(sample = source, program = ...);
Change an existing entry's relationship in a separation outputsep(..., transitions = [...]);
Declare new material identities derived from a cellular entrysource.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:

FieldMeaning
content_refIdentifies what the entry contains. Multiple entries may have the same content reference.
entry_idIdentifies 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:

text
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:

text
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:

text
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.

Released under the Apache-2.0 license.