# Bio Applied Flow Cytometry

> Read FCS 2.0/3.0/3.1 files with FlowKit/flowio, apply spillover compensation, logicle/arcsinh transforms, build gating hierarchies, and compute population statistics. Use when analyzing flow cytometry data, .fcs files, panels, compensation matrices, or gating trees.

- Skill: `pavel-kravchenko/bio-applied-flow-cytometry` (Agent Skill)
- Install (CLI): `npx skillmds@latest add pavel-kravchenko/bio-applied-flow-cytometry`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pavel-kravchenko/bio-applied-flow-cytometry/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: pavel-kravchenko (https://skillmd.com/u/pavel-kravchenko)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/pavel-kravchenko/bio-applied-flow-cytometry

---


# Flow Cytometry Analysis

## When to Use
- Loading raw `.fcs` files and inspecting channels, keywords, or spillover metadata before analysis
- Compensating fluorescence spillover and choosing a display transform (logicle vs arcsinh vs biexponential)
- Building a manual gating hierarchy (e.g. Time -> Singlets -> Live -> Lymphocytes -> CD4+/CD8+) and exporting/replicating it via GatingML or a FlowJo `.wsp`
- Computing per-gate population statistics (% parent, % total, event counts) across a batch of samples for a panel
- Deciding between FlowKit (full gating engine), flowio (low-level FCS I/O only), and FlowCytometryTools (older pandas-based scripting)

## Version Compatibility
- FlowKit >=1.3.2 (Python >=3.9), bundles `flowutils` for compensation/transform C extensions
- flowio >=1.3 (low-level FCS 2.0/3.0/3.1 parser/writer; FlowKit's `Sample` is built on it)
- FlowCytometryTools ~0.5.1 (pandas-based, no active gating-hierarchy engine; fine for quick one-off scripts)
- CytoML / GatingML (R/Bioconductor `flowCore`/`openCyto` ecosystem) for exchanging gating strategies with FlowJo/Cytobank as GatingML 2.0 XML

## Prerequisites
- `pip install flowkit flowio`
- FCS files with a spillover matrix in the `$SPILL`/`SPILLOVER` keyword, or an external compensation CSV
- Familiarity with `bio-applied-cite-seq-integration` is useful for the analogous ADT-normalization tradeoffs (CLR vs DSB parallels arcsinh vs logicle here)

## Reading and Compensating FCS Files

**Goal:** load an FCS file, inspect channels/keywords, and correct fluorescence spillover so signal reflects single fluorophores rather than mixed detector output.

**Approach:** `flowkit.Sample` parses the FCS TEXT/DATA segments (via `flowio` internally) and keeps raw, compensated, and transformed event arrays addressable by `source=`. Always compensate before transforming — logicle/arcsinh parameters assume a compensated scale, and re-applying transforms after `apply_compensation` is automatic.

```python
import flowkit as fk
import numpy as np


def load_and_compensate(fcs_path: str, comp_csv: str | None = None) -> fk.Sample:
    """Load an FCS file and apply spillover compensation.

    fcs_path: path to a .fcs (2.0/3.0/3.1) file.
    comp_csv: optional path to a CSV compensation matrix (detector names as
    header row); if None, use the matrix embedded in the FCS $SPILL keyword.
    Returns the Sample with a 'comp' event source available.
    """
    sample = fk.Sample(fcs_path)

    if comp_csv is not None:
        comp_matrix = fk.Matrix(comp_csv, detectors=sample.pnn_labels)
        sample.apply_compensation(comp_matrix)
    elif sample.metadata.get("spill") is not None:
        sample.apply_compensation(sample.metadata["spill"])
    else:
        raise ValueError(f"no spillover matrix found or supplied for {fcs_path}")

    return sample


if __name__ == "__main__":
    # self-check with a synthetic 2-detector Sample (no .fcs file needed)
    channels = ["FSC-A", "SSC-A", "FITC-A", "PE-A"]
    rng = np.random.default_rng(0)
    events = rng.normal(5000, 500, size=(1000, 4))
    events[:, 3] += 0.3 * events[:, 2]  # simulate FITC->PE spillover

    s = fk.Sample(events, channel_labels=channels, sample_id="synthetic")
    spill = fk.Matrix(
        np.array([[1.0, 0.0], [0.3, 1.0]]),
        detectors=["FITC-A", "PE-A"],
    )
    s.apply_compensation(spill)
    comp_events = s.get_events(source="comp")
    assert comp_events.shape == events.shape
    print("compensated PE-A mean:", comp_events[:, 3].mean().round(1))
```

## Logicle / Arcsinh Transforms for Display and Analysis

**Goal:** rescale compensated fluorescence so dim/negative and bright/positive cells are both visible, without the artificial banding a plain log transform causes near and below zero.

**Approach:** logicle is a data-driven biexponential (the FlowJo/Cytobank default) parameterized by `param_t` (top of scale, e.g. instrument max ~262144 for 18-bit), `param_w` (width of the linear region around zero, from negative-population spread), `param_m` (number of decades), and `param_a` (extra negative decades). Arcsinh with a fixed cofactor is simpler and standard for CyTOF/spectral panels (cofactor ~5 for mass cytometry, ~150-1000 for fluorescence).

```python
import flowkit as fk
import numpy as np


def transform_fluorescence(sample: fk.Sample, fluoro_channels: list[str],
                            method: str = "logicle") -> fk.Sample:
    """Apply a display/analysis transform to compensated fluorescence channels.

    sample: a Sample that already has compensation applied via apply_compensation.
    fluoro_channels: PnN channel names to transform (exclude FSC/SSC/Time).
    method: 'logicle' (biexponential) or 'asinh' (fixed-cofactor arcsinh).
    """
    if method == "logicle":
        xform = fk.transforms.LogicleTransform(param_t=262144, param_w=0.5, param_m=4.5, param_a=0)
    elif method == "asinh":
        xform = fk.transforms.AsinhTransform(param_t=262144, param_m=4.5, param_a=0)
    else:
        raise ValueError("method must be 'logicle' or 'asinh'")

    sample.apply_transform({ch: xform for ch in fluoro_channels})
    return sample


if __name__ == "__main__":
    xform = fk.transforms.LogicleTransform(param_t=262144, param_w=0.5, param_m=4.5, param_a=0)
    raw = np.array([-50.0, 0.0, 500.0, 262144.0]).reshape(-1, 1)
    scaled = xform.apply(raw).ravel()
    assert 0.0 <= scaled.min() and scaled.max() <= 1.0 + 1e-6
    assert np.all(np.diff(scaled) > 0), "logicle transform must be monotonic"
    print("logicle-scaled:", scaled.round(3))
```

## Gating Hierarchies and Population Statistics

**Goal:** encode a gating tree (e.g. Singlets -> Live -> Lymphocytes -> CD3+) once and apply it consistently across many samples, then pull per-gate counts and percentages for reporting.

**Approach:** a `Session` bundles Samples with one `GatingStrategy`; gates are added by `gate_path` tuples starting at `'root'`. `RectangleGate`/`PolygonGate` dimensions reference channel names plus optional `transformation_ref`. After `analyze_samples`, `get_gate_membership` returns a boolean event mask per gate and `get_gate_counts`/the `GatingResults.report` DataFrame give ready-made % of parent and % of total.

```python
import flowkit as fk
import numpy as np


def build_and_run_gating(sample: fk.Sample) -> "fk.gating_results.GatingResults":
    """Build a two-level gating hierarchy and analyze one sample.

    sample: Sample with FSC-A/SSC-A (scatter) and FITC-A (marker) channels,
    already compensated (transform is applied inline via transformation_ref).
    Hierarchy: root -> Lymphocytes (scatter gate) -> FITC+ (marker gate).
    """
    strategy = fk.GatingStrategy()

    scatter_dims = [
        fk.Dimension("FSC-A", range_min=2000, range_max=8000),
        fk.Dimension("SSC-A", range_min=500, range_max=6000),
    ]
    lymph_gate = fk.gates.RectangleGate("Lymphocytes", scatter_dims)
    strategy.add_gate(lymph_gate, gate_path=("root",))

    marker_dim = [fk.Dimension("FITC-A", range_min=1000, range_max=None)]
    pos_gate = fk.gates.RectangleGate("FITC+", marker_dim)
    strategy.add_gate(pos_gate, gate_path=("root", "Lymphocytes"))

    session = fk.Session(gating_strategy=strategy)
    session.add_samples(sample)
    session.analyze_samples()

    return session.get_gating_results(sample.id)


if __name__ == "__main__":
    channels = ["FSC-A", "SSC-A", "FITC-A"]
    rng = np.random.default_rng(1)
    events = np.column_stack([
        rng.normal(5000, 800, 2000),
        rng.normal(3000, 700, 2000),
        rng.exponential(1500, 2000),
    ])
    demo_sample = fk.Sample(events, channel_labels=channels, sample_id="demo")

    results = build_and_run_gating(demo_sample)
    report = results.report
    assert "FITC+" in report["gate_name"].values
    print(report[["gate_name", "count", "absolute_percent"]])
```

## Pitfalls

- **Compensate before transforming, never after.** Logicle/arcsinh `param_w`/cofactor choices assume a compensated scale; transforming raw spillover-contaminated data produces gates that don't reproduce in FlowJo.
- **`$SPILL` isn't always present or correct.** Some instrument exports embed a stale or acquisition-only matrix — always visually confirm compensation (no diagonal smear) rather than trusting the keyword blindly.
- **Logicle `param_w` needs real data, not defaults.** A `param_w` too small clips negative populations into a spike at zero; too large flattens resolution near zero. Estimate it from the actual negative-population spread per channel, not one global value.
- **Gate order and `gate_path` matter.** FlowKit's `add_gate` requires the full ordered ancestor tuple; a wrong or misspelled path silently creates a disconnected gate rather than raising, and it won't appear in the intended hierarchy's report rows.
- **FCS 3.1 vs 2.0 keyword differences.** Older FCS 2.0 files may lack `$SPILL`/standardized channel naming (`$PnS` vs `$PnN`); check both when writing code meant to run across archives spanning cytometer generations.

## See Also
- `bio-applied-cite-seq-integration` — analogous background/scale normalization problem (CLR/DSB) for CITE-seq ADT counts
- `bio-applied-immune-repertoire` — downstream immunophenotyping once cytometry populations are gated
- `bio-applied-clinical-genomics` — integrating flow-defined cell fractions with clinical/genomic variables
- `bio-applied-statistics-for-bioinformatics` — group comparisons and multiple-testing correction for population-frequency statistics

