Skip to content

feat(preview): FCS multi-panel gating grid with biological marker labels#5109

Open
Austin-s-h wants to merge 2 commits into
quiltdata:masterfrom
Austin-s-h:feat/fcs-gating-grid
Open

feat(preview): FCS multi-panel gating grid with biological marker labels#5109
Austin-s-h wants to merge 2 commits into
quiltdata:masterfrom
Austin-s-h:feat/fcs-gating-grid

Conversation

@Austin-s-h

@Austin-s-h Austin-s-h commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

What

Replaces the single-scatter FCS preview with a canonical flow-cytometry gating grid:

  • Cells (FSC-A × SSC-A), Singlets (FSC-H × FSC-A), Singlets (SSC) (SSC-H × SSC-A), then the fluorescence channel pairs present in the file.
  • Emits only panels whose both channels exist; caps at 6 panels; falls back to the first two columns for non-standard files.
  • Axis titles prefer the $PnS biological marker (e.g. CD3 (FL1-A)), fall back to the detector name, and skip redundant marker == channel labels.
  • A single-channel file keeps the original responsive brush-select panel; multi-panel grids get explicit cell sizing (Vega-Lite ignores width: container inside a concat).
  • Catalog renders the emitted vegaLite spec via the existing Vega renderer, plus responsive layout CSS (overflow / word-wrap) on the FCS preview.

Why / approach

This is built on top of master's existing fcsparser-based extract_fcs — no parser/dependency swap. The gating grid is derived entirely from the parsed pandas DataFrame's columns, and the vegaLite spec is emitted from extract_fcs alongside the existing HTML/metadata.

The one subtlety: master calls fcsparser.parse(..., reformat_meta=True), which collapses the per-channel $PnN/$PnS keywords into a _channels_ DataFrame and drops the flat $P<idx>N keys. So _fcs_channel_markers reads the marker map from that _channels_ DataFrame first, with fallbacks to flat $P<idx>N (raw fcsparser) and lower-cased p<idx>n (other parsers) — the marker labels work whichever metadata shape is handed to it.

No new deps and no new test fixtures: the existing lambdas/shared/tests/data/fcs/normal.fcs (14 channels, 191 events) already exercises the full multi-panel grid.

Tests

  • Lambda: lambdas/shared/tests/test_preview.py — augmented test_fcs to assert the gating grid on normal.fcs, plus new unit tests for panel selection, marker parsing (flat + _channels_), axis-label dedup, downsampling/seeding, single-vs-multi panel structure. uv run pytest tests/test_preview.py -k fcs14 passed. Full file: 20 passed, 1 unrelated failure (test_excel hits the external W3C validator; SSL/network only).
  • Catalog: new loaders/Fcs.spec.tsx verifies vegaLite flows from preview info into PreviewData.Fcs.
  • ruff check clean on both Python files.

🤖 Generated with Claude Code

Greptile Summary

This PR replaces the single-scatter FCS preview with a canonical flow-cytometry gating grid using Vega-Lite, built entirely on top of the existing fcsparser-based extract_fcs infrastructure. The new grid selects up to six biologically meaningful channel pairs (Cells, Singlets, fluorescence), labels axes with $PnS biological marker names, and falls back to a responsive single-panel view for non-standard files.

  • Backend (preview.py): Adds _fcs_channel_markers, _build_fcs_panel, _build_fcs_scatter_spec, and _select_fcs_panels to build a complete Vega-Lite spec (with inline event data) stored in info['vegaLite']; includes FCS_SCATTER_LIMIT = 50_000 events per panel and a cap of six panels.
  • Frontend (Fcs.js, Fcs.jsx, types.js): Threads the new vegaLite field through the loader and renders it via the existing Vega (vega-embed) renderer, with updated CSS for responsive layout.

Confidence Score: 3/5

Safe to merge for small FCS files, but large clinical samples with canonical channels could trigger a Lambda response failure due to the unbounded inline data payload.

The frontend wiring and test coverage are solid. The main risk is in preview.py: with 50,000 events per panel and up to 6 panels, the Vega-Lite spec embeds up to 300,000 x,y dicts directly in the JSON response. make_json_response calls json.dumps with no size guard on the vegaLite key, while the Lambda handler defines LAMBDA_MAX_OUT = 6,000,000 bytes as its documented ceiling. A clinical FCS file with 50k+ events and a full set of FSC/SSC/FL channels will produce a response that exceeds that limit, causing the preview to fail for exactly the files where the gating grid would be most useful.

lambdas/shared/src/t4_lambda_shared/preview.py — specifically the FCS_SCATTER_LIMIT constant and the absence of a cross-panel total-event budget.

Important Files Changed

Filename Overview
lambdas/shared/src/t4_lambda_shared/preview.py Adds ~186 lines of FCS gating-grid logic; per-panel 50k event limit with 6 panels creates a potential 9MB+ inline JSON payload that can exceed the Lambda 6MB response ceiling for large clinical FCS files.
lambdas/shared/tests/test_preview.py Comprehensive new unit tests cover panel selection, marker parsing, downsampling, NaN/inf filtering, single vs multi panel structure, and the integration test on normal.fcs; coverage is thorough.
catalog/app/components/Preview/renderers/Fcs.jsx Adds vegaLite rendering via existing Vega component; CSS layout updates are safe and well-scoped.
catalog/app/components/Preview/loaders/Fcs.js One-line addition threads vegaLite from preview info into PreviewData.Fcs; straightforward and correct.
catalog/app/components/Preview/loaders/Fcs.spec.tsx New test correctly verifies vegaLite is propagated through the loader into PreviewData.Fcs using appropriate mocks.
catalog/app/components/Preview/types.js Comment-only update marking vegaLite as an optional field in the Fcs tagged union; non-breaking.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[FCS file via S3] --> B[extract_fcs]
    B --> C[fcsparser.parse reformat_meta=True]
    C --> D{data is not None?}
    D -- Yes --> E[_fcs_channel_markers meta]
    E --> F[_build_fcs_scatter_spec data]
    F --> G[_select_fcs_panels columns]
    G --> H{Canonical panels match?}
    H -- Yes --> I[Up to 6 canonical pairs\nCells / Singlets / FL pairs]
    H -- No --> J[Fallback: first 2 columns]
    I --> K[_build_fcs_panel per pair\nfilter NaN/inf, downsample ≤50k]
    J --> K
    K --> L{1 sub-spec?}
    L -- Single --> M[Single responsive panel\nwidth=container, brush params]
    L -- Multi --> N[Gating grid concat\nexplicit 300px cells]
    M --> O[info.vegaLite]
    N --> O
    O --> P[json.dumps via make_json_response\nno size guard applied to vegaLite]
    P --> Q[Catalog FCS Loader\ninfo.vegaLite]
    Q --> R[Vega renderer\nvega-embed]
    D -- No --> S[metadata only]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[FCS file via S3] --> B[extract_fcs]
    B --> C[fcsparser.parse reformat_meta=True]
    C --> D{data is not None?}
    D -- Yes --> E[_fcs_channel_markers meta]
    E --> F[_build_fcs_scatter_spec data]
    F --> G[_select_fcs_panels columns]
    G --> H{Canonical panels match?}
    H -- Yes --> I[Up to 6 canonical pairs\nCells / Singlets / FL pairs]
    H -- No --> J[Fallback: first 2 columns]
    I --> K[_build_fcs_panel per pair\nfilter NaN/inf, downsample ≤50k]
    J --> K
    K --> L{1 sub-spec?}
    L -- Single --> M[Single responsive panel\nwidth=container, brush params]
    L -- Multi --> N[Gating grid concat\nexplicit 300px cells]
    M --> O[info.vegaLite]
    N --> O
    O --> P[json.dumps via make_json_response\nno size guard applied to vegaLite]
    P --> Q[Catalog FCS Loader\ninfo.vegaLite]
    Q --> R[Vega renderer\nvega-embed]
    D -- No --> S[metadata only]
Loading

Reviews (1): Last reviewed commit: "feat(preview): FCS multi-panel gating gr..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

Replace the single-scatter FCS preview with a canonical flow-cytometry gating
grid: Cells (FSC-A×SSC-A), Singlets (FSC-H×FSC-A), then the fluorescence
channel pairs present in the file, capped at 6 panels. Axis titles prefer the
$PnS biological marker (e.g. "CD3 (FL1-A)"), falling back to the detector name
and skipping redundant marker==channel labels. A single-channel file keeps the
original responsive brush-select panel; multi-panel grids get explicit cell
sizing (Vega-Lite ignores `container` width inside a concat).

Built on master's existing fcsparser-based extract_fcs (no dependency swap):
the grid is derived from the parsed pandas DataFrame's columns, and markers are
read from fcsparser's reformat_meta `_channels_` DataFrame ($PnN/$PnS columns),
with fallbacks to flat $P<idx>N / lower-cased p<idx>n keys for other parsers.

Catalog side renders the emitted vegaLite spec via the existing Vega renderer
and adds responsive layout CSS (overflow/word-wrap) to the FCS preview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.61404% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.62%. Comparing base (3d20021) to head (b03b6e5).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
catalog/app/components/Preview/renderers/Fcs.jsx 0.00% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5109      +/-   ##
==========================================
+ Coverage   49.46%   49.62%   +0.16%     
==========================================
  Files         843      843              
  Lines       34411    34518     +107     
  Branches     5826     5829       +3     
==========================================
+ Hits        17020    17130     +110     
+ Misses      15502    15499       -3     
  Partials     1889     1889              
Flag Coverage Δ
api-python 93.14% <ø> (ø)
catalog 25.10% <0.00%> (+0.02%) ⬆️
lambda 97.19% <100.00%> (+0.10%) ⬆️
py-shared 98.02% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment on lines +25 to +26
FCS_SCATTER_LIMIT = 50_000
FCS_SCATTER_RANDOM_SEED = 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 vegaLite payload can exceed the Lambda 6 MB response limit for large FCS files

FCS_SCATTER_LIMIT = 50_000 is applied per panel, not across all panels combined. With six panels active (FCS_MAX_PANELS = 6), the data.values arrays embedded inline in the Vega-Lite spec sum to up to 300,000 {x, y} dicts. At roughly 30 bytes of JSON per point that is ~9 MB of payload, well above the LAMBDA_MAX_OUT = 6_000_000 constant defined in the preview Lambda handler. make_json_response calls json.dumps directly without any size guard on info['vegaLite'], so a request for a large clinical FCS file (≥50k events, with FSC/SSC and two or more FL pairs present) would produce a response the Lambda infrastructure silently truncates or rejects.

A simple mitigation is to budget a total event count across all panels — e.g., per_panel_limit = max(1, total_limit // len(panels)) — or reduce FCS_SCATTER_LIMIT to a value that keeps the total payload inside 2–3 MB (≈ 5 000–10 000 events per panel is also sufficient to see gating structure). Flow cytometry tools commonly subsample to 5 000–20 000 events for scatter displays.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b03b6e5. Replaced the per-panel FCS_SCATTER_LIMIT with FCS_SCATTER_TOTAL_LIMIT = 60_000 budgeted across all selected panels (per_panel_limit = max(1, total // len(panels))), so a full 6-panel grid stays ~2-3 MB — well under the 6 MB LAMBDA_MAX_OUT. Added a test asserting a large synthetic FCS spec serializes under the cap.

Comment on lines +155 to +175
markers = {}

channels = meta.get('_channels_')
if (
channels is not None
and hasattr(channels, 'columns')
and '$PnN' in channels.columns
and '$PnS' in channels.columns
):
for name, marker in zip(channels['$PnN'], channels['$PnS']):
if name is None:
continue
marker = _keep_marker(marker)
if marker:
markers[str(name)] = marker
if markers:
return markers

idx = 1
while True:
name = meta.get(f'$P{idx}N') or meta.get(f'p{idx}n')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _fcs_channel_markers flat-key loop silently stops at the first index gap

The while True loop increments idx and breaks when meta.get(f'$P{idx}N') or meta.get(f'p{idx}n') evaluates to None. If a non-standard FCS file omits a channel parameter in the middle (e.g., has $P1N, $P2N, $P4N but not $P3N), the loop breaks at index 3 and $P4N's marker is silently dropped. The result is that axis labels for those channels fall back to raw channel names even when a $PnS marker was present in the file. This edge case is unlikely in well-formed FCS files but worth noting for robustness.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b03b6e5. The flat-key fallback now iterates a bounded range ($PAR count when present, else 512) and skips missing indices instead of breaking on the first gap. Added a test covering a $P3 gap with $P4S still picked up. The primary _channels_ path is unchanged.

Comment on lines +230 to +232
def _build_fcs_panel(data, x_axis, y_axis, label, channel_markers, *, limit):
import numpy
import pandas

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant import pandas inside _build_fcs_panel

pandas is already imported at module level (line 12). The inline import pandas here is a no-op after the first call but is mildly confusing. numpy is legitimately absent from the module-level imports, so that inline import is fine to keep; the pandas one can be dropped.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b03b6e5 — dropped the redundant inline import pandas in _build_fcs_panel; kept the inline import numpy as you noted.

…arse

Address Greptile review on the FCS gating-grid PR:

- P1: replace per-panel FCS_SCATTER_LIMIT with FCS_SCATTER_TOTAL_LIMIT
  (60k) budgeted ACROSS all selected panels. With up to 6 panels the old
  50k-per-panel cap could emit ~300k inline {x,y} dicts (~9 MB), over the
  6 MB LAMBDA_MAX_OUT response cap. Now _build_fcs_scatter_spec derives a
  per-panel cap (total // len(panels)) so the whole payload stays ~2-3 MB.
- P2: make the flat-key marker fallback in _fcs_channel_markers gap-tolerant
  -- iterate a bounded range ($PAR count, else 512) and skip missing indices
  instead of breaking on the first gap. Primary _channels_ path unchanged.
- P2 nit: drop the redundant inline `import pandas` in _build_fcs_panel
  (pandas is module-level; numpy stays inline since it is not).

Tests: rename to FCS_SCATTER_TOTAL_LIMIT, add a gap-tolerance test and a
test asserting a large synthetic FCS spec stays under the 6 MB cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant