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.
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].
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:
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.
| Program | Required fields | Optional fields | Meaning |
|---|---|---|---|
centrifuge_program(...) | drive | keep_source | Centrifuge separation. |
magnetic_program(...) | none | duration, device | Magnetic separation. |
disrupt_program(...) | none | method, duration | Disruption or lysis-oriented separation. |
field_program(...) | exactly one of voltage, current, or field | duration | Electric-field-driven separation under constant-voltage or constant-current control. |
filtration_program(...) | membrane, drive | none | Filtration using a named non-centrifugal drive. |
centrifugal_filtration_program(...) | membrane, drive | duration | Membrane or column filtration driven by centrifugal force. |
phase_partition_program(...) | solvent | none | Phase partition. |
precipitation_program(...) | reagent | duration | Precipitation. |
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:
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:
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].
| Program | Output enum | group[0] role / member | group[1] role / member |
|---|---|---|---|
centrifuge_program | CentrifugeProgramOutput | supernatant / SUPERNATANT | pellet / PELLET |
magnetic_program | MagneticProgramOutput | bound / BOUND | flowthrough / FLOWTHROUGH |
disrupt_program | DisruptProgramOutput | lysate / LYSATE | debris_or_residue / DEBRIS_OR_RESIDUE |
field_program | FieldProgramOutput | target_band_fraction / TARGET_BAND_FRACTION | non_target_fraction / NON_TARGET_FRACTION |
filtration_program | FiltrationProgramOutput | filtrate / FILTRATE | retentate / RETENTATE |
centrifugal_filtration_program | CentrifugalFiltrationProgramOutput | filtrate / FILTRATE | retentate / RETENTATE |
phase_partition_program | PhasePartitionProgramOutput | target_phase / TARGET_PHASE | other_phase / OTHER_PHASE |
precipitation_program | PrecipitationProgramOutput | precipitate / PRECIPITATE | supernatant / 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.
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.
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:
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:
| Argument | Contract |
|---|---|
subject | One same-sample material selected by static materials[index] or exact materials.get(entry_id). |
output | A namespaced member of the concrete separation program's output enum. |
to | A namespaced, author-settable MaterialRelation member. |
associated_with | Required 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:
| Target | Association behavior |
|---|---|
MaterialRelation.FREE | Clears the current association. |
MaterialRelation.CONTAINER_SURFACE | Associates with the concrete output container. |
MaterialRelation.PELLET | Associates with the concrete output container. |
MaterialRelation.PRECIPITATE | Associates with the concrete output container. |
MaterialRelation.DISRUPTED | Records output-container-scoped disrupted state. |
MaterialRelation.FIELD_RETAINED | Records output-container-scoped field retention. |
MaterialRelation.BEAD_BOUND | Requires associated_with to select another component entry in the same output. |
MaterialRelation.MEMBRANE_BOUND | Requires associated_with to select another component entry in the same output. |
MaterialRelation.CELL_BOUND | Requires 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(...).
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.
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
| Program | Required fields | Meaning |
|---|---|---|
density_gradient_program(...) | axis, order, bins | Density or layer-based fractionation. |
chromatography_program(...) | axis, order, bins | Retention-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_groupsupports onlygroup[0]andgroup[1].source.partition(program)[i]supports only static indexes0and1.fraction_group[i]must be within0 <= i < binswhenbinsis known.- Slices, multidimensional indexes, and dynamic index semantics are not part of this authoring surface.
