Experimental Designs
CHESSExperiments describes what an experiment is meant to test, before anything is prepared: a design matrix of treatments, the factors its columns stand for, and the controls and replicates it needs. It turns that design into CHESSCore stocks and per-plate conditions, and, with RunMaps and PlateMaps loaded, into a plate layout. It is a separate package that is loaded separately from CHESS.
Experiments
An Experiment has two fields. design is a DataFrame with one row per planned trial and one column per factor; a treatment run three times is the same row three times. metadata holds everything that describes, schedules, or processes the design without being part of it: the name, the plate layout once scheduled, per-plate conditions, and so on.
julia> using CHESSExperiments
julia> expt = Experiment(DataFrame(glucose = [10, 5]); name = "glucose screen");
julia> get_parameter(expt, :name)
"glucose screen"Metadata keys can be registered as a ParameterKind with register_parameter!, giving them a type, a default, and an optional validator that get_parameter enforces. parameter_registry holds the registered kinds, keyed by name. Unregistered keys remain ordinary Dict entries. with_parameter returns a copy of the experiment with one key set, leaving the original unchanged.
How design columns are read
Every column in a design must mean something CHESS understands. factor_destination checks a column name in this order:
- One of the
RESERVED_DESIGN_COLUMNS(:control_role,:duplicates), used by scheduling. - A registered reagent: the column holds an amount of that reagent (destination
:reagent). - A registered organism: the column holds a biomass of that organism (destination
:organism). - A registered
Factor, which names its own destination, usually:condition.
Anything else is an error. Reagent and organism columns need no registration of their own, since CHESS already knows those names. Other factors do. A CategoricalFactor takes values from a set of levels; a ContinuousFactor is numeric. Either can be blocking: two rows that differ in a blocking factor must never share a plate, as with an incubation atmosphere:
julia> register_factor!(CategoricalFactor(:atmosphere;
levels = () -> (:aerobic, :anaerobic), blocking = true, destination = :condition));factor_registry holds the registered factors, keyed by name, and get_factor looks one up and raises a KeyError for an unregistered name. factor_levels returns the currently valid levels of a categorical factor, and is_blocking tests whether a factor is blocking:
julia> factor = get_factor(:atmosphere);
julia> factor_levels(factor)
(:aerobic, :anaerobic)
julia> is_blocking(factor)
trueRegistering a name that is already taken raises a DuplicateRegistrationError.
Parsing a spreadsheet
parse_design reads a design from a CSV file. Spreadsheet headers and values rarely match CHESS's names, so a DesignColumnMap translates them for this one file: columns maps each header to a factor name, values maps free-text cell values, and units gives one unit per reagent or organism column. Here glucose is a concentration, the S. mutans inoculum is a biomass, and the last two rows are control templates (see Controls and replicates):
julia> path = joinpath(mktempdir(), "design.csv");
julia> write(path, """
Glucose (g/L),S. mutans (OD*mL),Atmosphere,control_role
10,0.01,aerobic,
5,0.01,aerobic,
10,0.01,anaerobic,
5,0.01,anaerobic,
0,0.01,,positive
0,,,negative
""");
julia> cmap = DesignColumnMap(
columns = Dict("Glucose (g/L)" => :glucose, "S. mutans (OD*mL)" => :SMU_UA159,
"Atmosphere" => :atmosphere),
values = Dict(:atmosphere => Dict("aerobic" => :aerobic, "anaerobic" => :anaerobic)),
units = Dict(:glucose => "g/L", :SMU_UA159 => "OD*mL"));
julia> ctx = [CHESSCore, CHESSLabConstants];
julia> expt = parse_design(path; column_map = cmap, reagent_context = ctx, org_context = ctx);
julia> expt.design
6×4 DataFrame
Row │ glucose SMU_UA159 atmosphere control_role
│ Int64 Float64? Symbol? String15?
─────┼───────────────────────────────────────────────
1 │ 10 0.01 aerobic missing
2 │ 5 0.01 aerobic missing
3 │ 10 0.01 anaerobic missing
4 │ 5 0.01 anaerobic missing
5 │ 0 0.01 missing positive
6 │ 0 missing missing negative
julia> classify_columns(propertynames(expt.design); reagent_context = ctx, org_context = ctx)
(reagent = [:glucose], organism = [:SMU_UA159], condition = [:atmosphere])reagent_context and org_context list the modules whose registered names count, here CHESS's own constants. is_registered_reagent is the test that identifies a reagent column:
julia> is_registered_reagent(:glucose; reagent_context = ctx), is_registered_reagent(:atmosphere; reagent_context = ctx)
(true, false)The column map is stored in the experiment's metadata, so the translation stays on record.
Resolving a row
resolve_stock turns one row's reagent and organism columns into a single CHESSCore Stock. Concentrations need a total volume, and solvent fills whatever volume the other reagents leave. It also returns each organism's biomass value, for per-well records:
julia> stock, organisms = resolve_stock(expt.design[1, :], [:glucose], [:SMU_UA159];
units = cmap.units, reagent_context = ctx, org_context = ctx,
total_volume = 200u"µL", solvent = :water);
julia> stock
200 μL Culture (2 reagent(s))
Organisms Name Biomass OD
──────────────────────────────────────────────────────────────
SMU_UA159 Streptococcus mutans UA159 10.0 μL OD 0.0500 OD
Solids Name Amount Concentration
────────────────────────────────────────────
glucose D-glucose 2.00 mg 10.0 mg mL⁻¹
Liquids Name Amount Concentration
───────────────────────────────────────
water water 200 μL 100 %
julia> organisms
Dict{Symbol, Any} with 1 entry:
:SMU_UA159 => 0.01resolve_conditions returns a row's :condition values as a plain Dict, checking categorical values against their levels. Conditions are only recorded. A plate cannot be set to "anaerobic" the way a well can be filled.
julia> resolve_conditions(expt.design[1, :], [:atmosphere])
Dict{Symbol, Any} with 1 entry:
:atmosphere => :aerobicControls and replicates
Controls are not ordinary design rows. A row with a :control_role is a template: a blank cell in a blocking column means "one copy for every block". expand_control_templates makes those copies, one per blocking combination that the sample rows actually use, and tags each with the block it belongs to. The positive and negative templates above each become two controls, one per atmosphere:
julia> expand_control_templates(expt).design
8×5 DataFrame
Row │ glucose SMU_UA159 atmosphere control_role _block_key
│ Int64 Float64? Symbol? String15? Tuple…?
─────┼──────────────────────────────────────────────────────────────
1 │ 10 0.01 aerobic missing missing
2 │ 5 0.01 aerobic missing missing
3 │ 10 0.01 anaerobic missing missing
4 │ 5 0.01 anaerobic missing missing
5 │ 0 0.01 aerobic positive (:aerobic,)
6 │ 0 0.01 anaerobic positive (:anaerobic,)
7 │ 0 missing aerobic negative (:aerobic,)
8 │ 0 missing anaerobic negative (:anaerobic,)partition_by_blocking groups the rows of an expanded design by the values of its blocking factors. It returns the names of the blocking columns and a list of groups, each with the shared values of the blocking factors and the indices of its rows. Rows in different groups never share a plate:
julia> blocking_cols, groups = partition_by_blocking(expand_control_templates(expt));
julia> blocking_cols
1-element Vector{Symbol}:
:atmosphere
julia> [(g.key, length(g.rows)) for g in groups]
2-element Vector{Tuple{Tuple{Symbol}, Int64}}:
((:aerobic,), 4)
((:anaerobic,), 4)A :duplicates column (not used here) gives a row more than one physical well; the extra wells are linked to the original so CHESSProcessing can average them later.
Scheduling onto plates
With RunMaps and PlateMaps also loaded, schedule_blocked_layout expands the control templates, splits the rows by blocking factor, and schedules each group onto its own plates. It takes the usable wells of one plate as a BitMatrix and the control roles to place:
julia> using RunMaps, PlateMaps
julia> scheduled = schedule_blocked_layout(expt, trues(2, 4), (:positive, :negative);
reagent_context = ctx, org_context = ctx);
julia> unique(layout(scheduled).labware)
2-element Vector{Union{Missing, String}}:
"g1_p1"
"g2_p1"
julia> sort(collect(get_parameter(scheduled, :plate_conditions)); by = first)
2-element Vector{Pair{Any, Dict{Symbol, Any}}}:
"g1_p1" => Dict(:atmosphere => :aerobic)
"g2_p1" => Dict(:atmosphere => :anaerobic)
The aerobic rows went to plate g1_p1 and the anaerobic rows to g2_p1. The result records:
:layout, aDataFramewith one row per well giving the design row it holds (run_index) and whether it is a positive or negative control;:plate_conditions, each plate's shared blocking values;:well_conditions, each placed well's organism and non-blocking condition values, keyed by(labware, well);:run_mapand:plate_maps, the scheduling structuresCHESSProcessinguses later.
The layout has the columns in LAYOUT_COLUMNS. populate_well_conditions fills :well_conditions for any scheduled experiment, however it was scheduled, because it reads only the layout and the design:
julia> LAYOUT_COLUMNS
10-element Vector{Symbol}:
:well
:row
:col
:run
:positive
:negative
:run_index
:labware
:location_id
:metadata
julia> repopulated = populate_well_conditions(scheduled; reagent_context = ctx, org_context = ctx);
julia> length(get_parameter(repopulated, :well_conditions)) == length(get_parameter(scheduled, :well_conditions))
truePlacement within a plate is randomized, so the exact wells vary from run to run. Looking a well up by its design row works regardless. Design row 7 is the aerobic negative control, which has no inoculum:
julia> lay = layout(scheduled);
julia> well7 = only(filter(r -> isequal(r.run_index, 7), eachrow(lay)));
julia> get_parameter(scheduled, :well_conditions)[(well7.labware, well7.well)]
Dict{Symbol, Any} with 1 entry:
:SMU_UA159 => missingschedule_layout is the lower-level form for a RunMap and plates you have already built yourself.
Saving designs and runs
CHESSDatabase stores designs and runs in the database of a lab (see Database Architecture). upload_experiment creates an experiment record from a name and a user and returns its ID. Protocols and runs belong to an experiment. upload_design stores an Experiment under an experiment record and returns the ID of the design. It stores the design matrix, the metadata, and, if the design is scheduled, one row for each well of the layout. get_design rebuilds the Experiment from the ID, including its layout:
julia> path = joinpath(mktempdir(), "lab.db");
julia> create_db(path);
julia> connect_SQLite(path)
julia> exp_id = upload_experiment("glucose screen", "docs")
1
julia> design_id = upload_design(scheduled, exp_id)
1
julia> restored = get_design(design_id);
julia> size(restored.design) == size(scheduled.design)
true
julia> nrow(layout(restored)) == nrow(layout(scheduled))
trueA scheduled well is only a name until a real location is made for it. commit_run_location! attaches a committed location to a well of the layout. It takes the design ID, the name of the labware, the well, and the ID of the location. The name of the labware is needed because the same well name occurs on every plate:
julia> first_well = first(eachrow(layout(scheduled)));
julia> plate = generate_location(loc"WP96", first_well.labware);
julia> well_id = CHESSCore.location_id(plate[first_well.well]);
julia> commit_run_location!(design_id, first_well.labware, first_well.well, well_id)A Run is one run of an experiment. It holds the location that the run was performed in, the ID of the experiment, and the location IDs of its controls and blanks. upload_run records a run and returns its ID, get_run looks it up, and get_all_runs lists the runs of an experiment:
julia> run_id = upload_run(Run(plate, exp_id))
1
julia> length(get_all_runs(exp_id))
1
julia> get_run(run_id) isa Run
trueQC methods
A QCMethod is a quality-control or correction method. register_qc_method! lets another package register a method type under a name, qc_method finds it again, and qc_methods lists the registered names. Registering a name that is taken raises a DuplicateRegistrationError. CHESSExperiments owns only the registry. The methods themselves live in the packages that implement them.
The CHESSExperiments API reference lists every function.