Skip to content

Separation and Fractionation ​

Culsma separates material with sep(...), selects source-local separation portions inside transfer statements, and creates ordered fractions with frac(...).

Both forms take a sample and a program. They distribute the material identities already present in the sample. A disruption program does not implicitly introduce protein, DNA, or debris identities; use an author-declared replacement when new components need to be tracked. Relationship transitions described below preserve an entry's identity.

sep(...) ​

sep(...) splits one container into a fixed two-slot group.

text
let g = sep(sample = lysate, program = centrifuge_program(drive = 12000g));

The result is a sep_container_group. Access its slots with group[0] and group[1].

text
output << [g[0]:40uL];

When a separated portion is used only as the source of one transfer, write the selection directly on the source container:

text
waste << [sample.partition(magnetic_program(duration = 5min))[1]:120uL];

source.partition(program)[i] uses the same two-slot separation semantics as sep(...), but it is not a group-producing operation. It is valid only inside the right-hand source list of <<. Use sep(...) when the separated slots need names, later reuse, or multiple downstream operations.

Separation Programs ​

sep(...) and source.partition(program)[i] accept mechanism-specific binary separation programs.

ProgramRequired fieldsOptional fieldsMeaning
centrifuge_program(...)drivekeep_sourceCentrifuge separation.
magnetic_program(...)noneduration, deviceMagnetic separation.
disrupt_program(...)nonemethod, durationDisruption or lysis-oriented separation.
field_program(...)exactly one of voltage, current, or fielddurationElectric-field-driven separation under constant-voltage or constant-current control.
filtration_program(...)membrane, drivenoneFiltration using a named non-centrifugal drive.
centrifugal_filtration_program(...)membrane, drivedurationMembrane or column filtration driven by centrifugal force.
phase_partition_program(...)solventnonePhase partition.
precipitation_program(...)reagentdurationPrecipitation.

field_program(...) describes electric-field-driven separation and requires exactly one control parameter: voltage for an electric-potential setpoint or current for an electric-current setpoint. The legacy field parameter is a compatibility alias for voltage and therefore accepts only voltage units. Magnetic separation uses magnetic_program(...) instead.

filtration_program.drive accepts FiltrationDrive.PRESSURE, FiltrationDrive.ASPIRATION, or FiltrationDrive.CELL_LIFTER. Open extensions inherit FiltrationDriveBase from culsma.domains.separation and require explicit registration. Existing drive text remains compatible; a custom member does not inherit standard physical handling merely through a similar name. Centrifugal programs still use a quantity-valued drive, not this enum.

disrupt_program.method uses the closed DisruptionMethod enum: MECHANICAL, SONICATION, SHEAR_HOMOGENIZATION, HIGH_PRESSURE_DISRUPTION and BEAD_IMPACT. Write a qualified member such as DisruptionMethod.SONICATION; equivalent lowercase tokens and strings remain accepted. The method describes disruption independently of equipment. A concrete device is selected by a planner or driver; omit method when the source protocol does not specify it.

Use centrifugal_filtration_program(...) when a membrane or spin column is run in a centrifuge. Its drive must be a centrifugal setting in g or rpm, and its optional duration describes the membrane run itself:

text
let g = sep(
    sample = spin_column,
    program = centrifugal_filtration_program(
        membrane = "silica",
        drive = 8000g,
        duration = 1min
    )
);

This remains filtration: its outputs are filtrate and retentate. Use centrifuge_program(...) instead when the intended outputs are supernatant and pellet.

A non-fractionating centrifuge action belongs to agit(mode=CENTRIFUGE, drive=..., duration=...). It keeps the same container and does not establish separation slots.

centrifuge_program(...) does not take duration. To express "centrifuge for 5 min at 4C", put the sep(...) inside an environment block:

text
with env(thermal = 4C, duration = 5min) {
    let g = sep(sample = lysate, program = centrifuge_program(drive = 12000g));
}

Slot Meaning and Output Enums ​

The two slots have stable meanings determined by the program. Each program also owns a closed output enum. A transition uses the enum member, while ordinary group access continues to use group[0] and group[1].

ProgramOutput enumgroup[0] role / membergroup[1] role / member
centrifuge_programCentrifugeProgramOutputsupernatant / SUPERNATANTpellet / PELLET
magnetic_programMagneticProgramOutputbound / BOUNDflowthrough / FLOWTHROUGH
disrupt_programDisruptProgramOutputlysate / LYSATEdebris_or_residue / DEBRIS_OR_RESIDUE
field_programFieldProgramOutputtarget_band_fraction / TARGET_BAND_FRACTIONnon_target_fraction / NON_TARGET_FRACTION
filtration_programFiltrationProgramOutputfiltrate / FILTRATEretentate / RETENTATE
centrifugal_filtration_programCentrifugalFiltrationProgramOutputfiltrate / FILTRATEretentate / RETENTATE
phase_partition_programPhasePartitionProgramOutputtarget_phase / TARGET_PHASEother_phase / OTHER_PHASE
precipitation_programPrecipitationProgramOutputprecipitate / PRECIPITATEsupernatant / SUPERNATANT

For example, MagneticProgramOutput.BOUND selects group[0], whose semantic role is bound. A member from a different program's enum is invalid even when it happens to have a similar name.

Per-Content Fate Rules ​

The optional component_fates argument lets an author override the reference partition prediction for named source content. The named content must already exist in the sample. For a replacement product with unknown mass, a fate rule is required to assign shares of that unknown amount; it does not determine an absolute yield. These products must retain a free relationship in the current prototype.

text
let filtered = sep(
    sample = source,
    program = filtration_program(membrane = "adherent_cell_surface", drive = "aspiration"),
    component_fates = {
        RPE1: { filtrate: 0%, retentate: 100% }
    }
);

Use the output names defined by the selected program. Both outputs are required, every ratio must be between 0 and 1 (or 0% and 100%), and the pair must sum to 1 (100%). Runtime also checks that each named content exists in the source.

Without an authored rule, runtime first considers physical association and the complete operation conditions. Content whose surface, bead, membrane, pellet, or other association is preserved follows the preserving output. Freely mobile content then uses the reference partition strategy. Quantity units do not decide mobility: cell count, volume, and mass are allocated independently on their native axes after physical fate is resolved.

For source-local selection, the index has the same meaning: sample.partition(program)[0] selects the slot described in the group[0] column, and sample.partition(program)[1] selects the slot described in the group[1] column.

Author-Controlled Material Relationship Transitions ​

The optional transitions argument lets an author declare the accepted relationship state of a specific material entry in a specific separation output. It does not change how much material enters that output. Use component_fates to route quantity and transitions to update relationship state.

text
let captured = sep(
    sample = source,
    program = magnetic_program(duration = 2min),
    component_fates = {
        MAGNETIC_BEADS: { bound: 100%, flowthrough: 0% },
        TARGET_PROTEIN: { bound: 100%, flowthrough: 0% }
    },
    transitions = [
        transition(
            subject = source.materials.get("TARGET_PROTEIN"),
            output = MagneticProgramOutput.BOUND,
            to = MaterialRelation.BEAD_BOUND,
            associated_with = source.materials.get("MAGNETIC_BEADS")
        )
    ]
);

Release from a Culture Surface ​

Surface release can use the existing two-output separation contract. With filtration_program(membrane = "adherent_cell_surface", drive = "cell_lifter"), the surface is the retaining boundary: filtrate denotes the released liquid fraction and retentate the fraction left on the surface. This authoring pattern does not introduce a physical filter or infer cell rupture.

For a sample whose first live material entry is the adherent cell population:

text
with env(thermal = 0C) {
    let lifted = sep(
        sample = source,
        program = filtration_program(membrane = "adherent_cell_surface", drive = "cell_lifter"),
        component_fates = {
            CELLS: { filtrate: 100%, retentate: 0% },
            RIPA: { filtrate: 100%, retentate: 0% }
        },
        transitions = [transition(
            subject = source.materials[0],
            output = FiltrationProgramOutput.FILTRATE,
            to = MaterialRelation.FREE
        )]
    );
    lysate << [lifted[0]];
}

The fate declares complete recovery; the transition clears the cell population's surface association before collection. Both preserve the existing cell count and buffer volume. Chemical lysis and any later materials.replace declaration remain separate. No scraping duration, force, stroke count, or protein yield is inferred. The human driver instructs use of a cell lifter; the robot projection identifies surface release for an equipment adapter to implement.

Transition Eligibility and Selection ​

Relationship transitions on unknown-mass replacement products are outside the current prototype; those entries remain free. Their separation still uses explicit component_fates.

A transition(...) has these arguments:

ArgumentContract
subjectOne same-sample material selected by static materials[index] or exact materials.get(entry_id).
outputA namespaced member of the concrete separation program's output enum.
toA namespaced, author-settable MaterialRelation member.
associated_withRequired only for component-bound target relations; selects another same-sample material entry.

Selectors resolve against the normalized, ordered, live material entries immediately before the separation. The exact-ID form does not match a content code, label, or prefix as a fallback. See Material Entries and Selectors.

The author-settable relationship states are:

TargetAssociation behavior
MaterialRelation.FREEClears the current association.
MaterialRelation.CONTAINER_SURFACEAssociates with the concrete output container.
MaterialRelation.PELLETAssociates with the concrete output container.
MaterialRelation.PRECIPITATEAssociates with the concrete output container.
MaterialRelation.DISRUPTEDRecords output-container-scoped disrupted state.
MaterialRelation.FIELD_RETAINEDRecords output-container-scoped field retention.
MaterialRelation.BEAD_BOUNDRequires associated_with to select another component entry in the same output.
MaterialRelation.MEMBRANE_BOUNDRequires associated_with to select another component entry in the same output.
MaterialRelation.CELL_BOUNDRequires associated_with to select another component entry in the same output.

MaterialRelation.UNRESOLVED is an internal sentinel and cannot be authored. Every author-settable state may transition to every other author-settable state; there is no source/target-pair whitelist. associated_with is forbidden for targets other than BEAD_BOUND, MEMBRANE_BOUND, and CELL_BOUND because their association is either absent or determined by the output container.

The selected subject and any component association target must have positive quantity in the selected output. Multiple independent transitions may appear in one sep(...); duplicate transitions for the same subject and output are invalid. Runtime validates the complete candidate and commits it atomically, so an invalid selector, output, relation, association, or quantity leaves the previous material state unchanged.

An authored transition overrides only relationship-state resolution for that material and output. It does not create material, infer a chemical reaction, replace component_fates, or add a new separation mechanism.

Material Partition Estimates ​

Material allocation follows an accepted decision for each source component. An authored component_fates rule takes precedence for its named content; otherwise the selected Scientific Model provider must resolve the allocation. The built-in Material Rulebook is the default provider. An external provider may replace it through the same validated contract.

The built-in provider uses exact ideal routing for supported cases. For example, centrifuge_program(...) routes freely mobile buffer to supernatant and sedimentable cells to pellet. The cellular route is 0% / 100%, regardless of whether the cells are quantified by count, volume, or mass. This describes the provider's bookkeeping rule, not measured experimental recovery. Use component_fates or an applicable provider to represent a different allocation.

A program name or material class does not always supply enough information. For example, naming a filtration membrane does not by itself establish whether it retains a particular component. If the selected provider cannot resolve a required decision, runtime reports MAT_SCIENTIFIC_MODEL_UNRESOLVED and rejects the operation before committing its material state. It does not substitute a 50/50 split or silently try another provider. Unknown or composite material requires an applicable provider decision or an explicit authored fate, subject to the same relationship, quantity, and conservation checks.

transitions separately declares an entry's relationship state in an output; it does not supply missing allocation fractions. An explicit fate also does not bypass validation of the resulting relationship state. See Author-Controlled Material Relationship Transitions and Diagnostics.

Runtime applies each accepted fate to the component's native quantity, then derives output-container volume and mass from the routed details. Aggregate quantities are not split independently. At the material-compute API boundary, compatibility input with aggregate bulk but incomplete component quantities is first normalized into movable, provenance-bearing detail. Normalization does not authorize a guessed allocation; stale aggregate fields are reprojected from existing detail before separation.

keep_source ​

keep_source is only for centrifuge_program(...).

text
let g = sep(
    sample = lysate,
    program = centrifuge_program(drive = 12000g, keep_source = CentrifugeProgramOutput.PELLET)
);

CentrifugeProgramOutput.SUPERNATANT reuses the input container for group[0]; CentrifugeProgramOutput.PELLET reuses it for group[1]. If keep_source is omitted, both slots are new containers. The bare tokens and strings supernatant and pellet remain accepted. Outputs from other program families are not interchangeable.

frac(...) ​

frac(...) creates an ordered fraction group.

text
let fg = frac(
    sample = tube,
    program = density_gradient_program(
        axis = DensityGradientAxis.DENSITY,
        order = DensityGradientOrder.TOP_TO_BOTTOM,
        bins = 8
    )
);

The result is a fraction_group. Access slots with fg[0], fg[1], and so on.

Fractionation Programs ​

ProgramRequired fieldsMeaning
density_gradient_program(...)axis, order, binsDensity or layer-based fractionation.
chromatography_program(...)axis, order, binsRetention-order fractionation.

Density-gradient parameters use DensityGradientAxis.DENSITY and DensityGradientOrder.TOP_TO_BOTTOM or DensityGradientOrder.BOTTOM_TO_TOP. These are closed types; their old strings remain compatible.

Chromatography uses the standard pair ChromatographyAxis.RETENTION_TIME and ChromatographyOrder.EARLY_TO_LATE. Python extensions inherit ChromatographyAxisBase and ChromatographyOrderBase from culsma.domains.fractionation. Each order declares its exact axis; both types must be registered and paired correctly. A typed extension cannot pair with an unknown legacy string. Custom execution also requires driver support.

Index Rules ​

Group indexes are zero-based.

  • sep_container_group supports only group[0] and group[1].
  • source.partition(program)[i] supports only static indexes 0 and 1.
  • fraction_group[i] must be within 0 <= i < bins when bins is known.
  • Slices, multidimensional indexes, and dynamic index semantics are not part of this authoring surface.

Released under the Apache-2.0 license.