diff --git a/agents/flow-cytometry-referee/README.md b/agents/flow-cytometry-referee/README.md new file mode 100644 index 00000000..b9279ac9 --- /dev/null +++ b/agents/flow-cytometry-referee/README.md @@ -0,0 +1,66 @@ +# Flow Cytometry Referee + +Flow Cytometry Referee reviews standard FCS flow-cytometry files and supporting analysis material for technical +quality and reproducibility. It reports observed measurement and metadata evidence, missing controls, and gating +documentation gaps. It is designed for research QC and is not a clinical, diagnostic, regulatory, or assay-validation tool. + +## Overview + +Researchers often receive event files, a gating figure, and partial panel documentation from different systems. This +agent makes the technical review explicit: it inventories evidence, measures file-level QC facts, and identifies what +must be supplied before a gating workflow can be considered reproducible. + +## Architecture + +The prompt agent uses a parameterized Foundry chat model and the `flow-qc` Python environment. The environment reads +FCS events with `flowio`, calculates deterministic channel and acquisition summaries, validates the shape and channel +names of a stored spillover matrix when present, and can calculate an optional exploratory Isolation Forest outlier +fraction. The agent combines those results with supplied SOPs, gating plans, and control inventories. The outlier +score flags unusual measurement events only; it does not classify cells or samples or establish a QC threshold. + +## Prerequisites + +- An Azure subscription with Microsoft Discovery deployment permissions. +- An Azure AI Foundry project and a chat-model deployment for `{{CHAT-MODEL}}`. +- A container image built from `tools/flow-qc/Dockerfile` and published to the deployment ACR. +- Standard FCS files and, for a complete review, the acquisition template, panel specification, gating plan, and + single-stain, FMO, viability, and relevant biological controls. + +## Configuration + +| Parameter | Description | Example | +| --- | --- | --- | +| `{{CHAT-MODEL}}` | Foundry chat-model deployment | `gpt-4o-deployment` | +| `{{flowQcToolId}}` | Deployed Discovery tool ARM resource ID | `/subscriptions/.../tools/flow-qc` | + +## Usage + +1. Deploy the agent and the `flow-qc` tool through Discovery. +2. Supply one or more FCS files and the available supporting documents. +3. Ask for a technical review, for example: “Review this FCS file and gating plan for compensation evidence, control + coverage, acquisition integrity, and reproducibility gaps.” +4. Review PASS, WARN, NEEDS EVIDENCE, and FAIL findings with a qualified flow-cytometry scientist. + +## Tools + +`flow-qc` provides a Python code environment with `flowio`, NumPy, pandas, scikit-learn, and `flow_qc_utils`. +It can read standard FCS files, summarize event and channel values, inspect and validate stored compensation-related +metadata, evaluate acquisition-time continuity, calculate an exploratory event-outlier fraction, and create a +technical QC dashboard. A declared-range-limit fraction is a measurement-range check, not proof of detector +saturation. Derived reports belong in the configured output directory. + +## Known Limitations + +- It does not inspect microscopy images or imaging flow-cytometry imagery. +- It cannot prove compensation is correct from FCS metadata alone. +- It does not create, validate, or optimize gates without a documented gating plan and appropriate controls. +- It must not be used for diagnosis, treatment, patient triage, or regulatory release decisions. +- The unsupervised outlier metric is sensitive to panel design and batch effects and is not a trained classifier. + +## Support + +For issues or questions, open an issue at https://github.com/AzizMuminov/discovery/issues. + +## Contributing + +See [CONTRIBUTING.md](../../CONTRIBUTING.md) for the catalog contribution workflow. diff --git a/agents/flow-cytometry-referee/agent.yaml b/agents/flow-cytometry-referee/agent.yaml new file mode 100644 index 00000000..34ac8197 --- /dev/null +++ b/agents/flow-cytometry-referee/agent.yaml @@ -0,0 +1,70 @@ +kind: prompt +name: FlowCytometryReferee +displayName: Flow Cytometry Referee +description: > + An evidence-based technical reviewer for FCS quality, compensation, controls, and proposed gating plans. +model: + id: '{{CHAT-MODEL}}' + options: + temperature: 0 + maxOutputTokens: 4096 +instructions: | + You are the Flow Cytometry Referee, a technical quality-control agent for research flow-cytometry analysis. + + Your job is to evaluate the reproducibility and technical adequacy of an FCS dataset and, when supplied, a + gating plan, panel description, control inventory, or laboratory SOP. You do not diagnose disease, classify + patients, recommend treatment, or claim that a dataset is biologically valid. Your findings concern only the + supplied data and documented analysis process. + + ## Operating rules + - Invoke only one tool at a time and wait for its result before the next tool call. + - For every FCS file, run the flow-qc Python environment before drawing conclusions about event count, + channels, compensation metadata, non-finite values, saturation, time ordering, or outlier score. + - Treat a missing document, control, or metadata field as missing evidence, never as proof of an error. + - Do not infer cell type, disease state, treatment effect, or sample identity from fluorescence values. + - Do not use the exploratory unsupervised outlier score as a classifier or a validated QC threshold. It identifies + unusual measurement events only. + - State panel- and instrument-specific assumptions explicitly. Ask for the acquisition template, gating plan, + and single-stain/FMO controls when their absence prevents a reliable review. + + ## Review sequence + 1. Inventory the supplied FCS files and supporting material. + 2. Run a QC script with flow_qc_utils.load_fcs and flow_qc_utils.build_qc_report. + 3. Compare available evidence with this minimum technical checklist: + - event count and acquisition-time integrity; + - compensation or spillover metadata and single-stain controls; + - viability, singlet, debris, and relevant fluorescence-minus-one controls; + - documented gate order, parent populations, thresholds, and counts; + - transformations, exclusions, and batch/sample identifiers; + - reproducible output files and analysis software/version. + 4. Return findings grouped as PASS, WARN, NEEDS EVIDENCE, or FAIL. Use FAIL only for a directly observed, + technically invalid condition such as an unreadable FCS file, no events, or non-finite event values that + make the requested analysis impossible. + 5. Produce a concise remediation plan ordered by impact. Preserve the original data; write any derived JSON, + CSV, and figures to the output directory. When FCS event data is available, create a technical QC dashboard + with scatter, fluorescence-distribution, acquisition-time, and declared-range-limit panels through + `write_qc_artifacts`. + + ## Dashboard presentation + - Inspect the generated dashboard through the host's image viewer before finalizing the review. + - Treat dashboard, CSV, and JSON files as tool artifacts. Do not write Markdown image tags, `file:` URLs, + workspace-relative links, or guessed artifact paths: VS Code does not reliably resolve them in chat responses. + - If the host displays the dashboard as native image content, refer to that attached dashboard under a short + `## Dashboard` heading. Otherwise, report the artifact names without inventing a link. + + ## Required response format + - Scope and supplied evidence + - QC metrics observed from the FCS file + - Findings, each with severity, evidence, and remediation + - Missing evidence and the exact document or control needed + - Reproducibility checklist + - Limitations + + Never present this report as clinical, regulatory, or diagnostic validation. +discoveryExtensions: + humanInTheLoop: Disabled + tools: + - toolId: '{{flowQcToolId}}' + confirmation: Disabled + disableDataHandlingTools: false + disableDiscoveryInjectedTools: false diff --git a/agents/flow-cytometry-referee/metadata.yaml b/agents/flow-cytometry-referee/metadata.yaml new file mode 100644 index 00000000..e121f152 --- /dev/null +++ b/agents/flow-cytometry-referee/metadata.yaml @@ -0,0 +1,19 @@ +name: flow-cytometry-referee +type: agent +version: 1.0.0 +associated_tools: + - agents/flow-cytometry-referee/tools/flow-qc +publisher: + name: Aziz Muminov + contact: azizbekmuminov52@gmail.com + support_url: https://github.com/AzizMuminov/discovery/issues + party: 3p +description: > + Reviews flow-cytometry FCS data and proposed gating plans for technical quality, compensation evidence, + control coverage, and reproducibility. Produces an evidence-backed QC report; it does not diagnose disease + or make clinical decisions. +tags: + - single-cell + - bioinformatics + - biomedical + - data-analysis diff --git a/agents/flow-cytometry-referee/tools/flow-qc/Dockerfile b/agents/flow-cytometry-referee/tools/flow-qc/Dockerfile new file mode 100644 index 00000000..6f1dfea2 --- /dev/null +++ b/agents/flow-cytometry-referee/tools/flow-qc/Dockerfile @@ -0,0 +1,36 @@ +FROM mcr.microsoft.com/azurelinux/base/python:3.12 + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV MPLBACKEND=Agg +ENV MPLCONFIGDIR=/tmp/matplotlib +ENV OMP_NUM_THREADS=1 +ENV MKL_NUM_THREADS=1 +ENV OPENBLAS_NUM_THREADS=1 + +RUN tdnf -y update && \ + tdnf -y install ca-certificates shadow-utils && \ + update-ca-trust && \ + tdnf clean all + +RUN pip3 install --no-cache-dir \ + flowio \ + matplotlib \ + numpy \ + pandas \ + scikit-learn \ + scipy + +RUN mkdir -p /app /workdir /input /output /tmp/matplotlib && \ + chmod 755 /app /workdir /input /output + +COPY flow_qc_utils.py /app/flow_qc_utils.py + +RUN groupadd -r appuser && useradd -r -g appuser appuser && \ + chown -R appuser:appuser /app /workdir /input /output /tmp/matplotlib + +USER appuser +WORKDIR /workdir +ENV PYTHONPATH=/app + +CMD ["/bin/bash"] diff --git a/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py new file mode 100644 index 00000000..cc56322d --- /dev/null +++ b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py @@ -0,0 +1,268 @@ +"""Utilities for technical quality review of standard FCS flow-cytometry files. + +The functions in this module intentionally report measurement and metadata facts. +They do not assign cell types, make clinical predictions, or validate a laboratory assay. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +from flowio import FlowData +from flowio.exceptions import FCSParsingError +from sklearn.ensemble import IsolationForest + + +def load_fcs(path: str | Path) -> tuple[dict[str, Any], pd.DataFrame]: + """Read an FCS file into metadata and an events-by-channel DataFrame.""" + parse_warnings: list[str] = [] + try: + flow_data = FlowData(str(path)) + except FCSParsingError as error: + if "offset" not in str(error).lower(): + raise + flow_data = FlowData(str(path), ignore_offset_error=True) + parse_warnings.append( + "The FCS data offset was inconsistent; parsed with FlowIO's offset-recovery option." + ) + channel_numbers = sorted(flow_data.channels, key=lambda value: int(value)) + channel_names = [ + flow_data.channels[number].get("pnn") or f"channel_{number}" + for number in channel_numbers + ] + event_count = int(flow_data.event_count) + channel_count = len(channel_names) + events = np.asarray(flow_data.events, dtype=float).reshape(event_count, channel_count) + metadata = {str(key): value for key, value in flow_data.text.items()} + metadata["_channel_ranges"] = { + name: flow_data.channels[number].get("pnr") + for name, number in zip(channel_names, channel_numbers, strict=True) + } + metadata["_channel_stains"] = { + name: flow_data.channels[number].get("pns") + for name, number in zip(channel_names, channel_numbers, strict=True) + } + metadata["_parse_warnings"] = parse_warnings + return metadata, pd.DataFrame(events, columns=channel_names) + + +def _channel_roles(events: pd.DataFrame, stains: dict[str, Any]) -> dict[str, list[str]]: + """Classify measurement channels for QC display, without biological inference.""" + roles = {"scatter": [], "fluorescence": [], "time": [], "index": [], "auxiliary": []} + for name in events.columns: + normalized = name.lower().replace(" ", "") + if normalized in {"time", "acquisition_time"} or normalized.endswith("time"): + roles["time"].append(name) + elif "event" in normalized or normalized.endswith("index"): + roles["index"].append(name) + elif normalized.startswith(("fsc", "ssc")): + roles["scatter"].append(name) + elif normalized.startswith(("width", "pulsewidth")): + roles["auxiliary"].append(name) + else: + roles["fluorescence"].append(name) + return roles + + +def _compensation_summary(metadata: dict[str, Any], events: pd.DataFrame) -> dict[str, Any]: + raw = next( + (value for key, value in metadata.items() if key.lower().replace("$", "") in {"spillover", "spill"}), + None, + ) + if raw is None: + return {"status": "absent", "matrix_channels": []} + tokens = [token.strip() for token in str(raw).split(",")] + try: + dimension = int(tokens[0]) + except (IndexError, ValueError): + return {"status": "present_unparsed", "matrix_channels": []} + channels = tokens[1 : dimension + 1] + values = tokens[dimension + 1 :] + missing = [channel for channel in channels if channel not in events.columns] + valid = len(channels) == dimension and len(values) == dimension * dimension and not missing + return { + "status": "present_valid" if valid else "present_invalid", + "matrix_channels": channels, + "matrix_dimension": dimension, + "channels_missing_from_fcs": missing, + } + + +def _channel_summary( + events: pd.DataFrame, ranges: dict[str, Any], range_check_channels: set[str] +) -> list[dict[str, Any]]: + summaries: list[dict[str, Any]] = [] + for name in events.columns: + values = events[name].to_numpy(dtype=float) + finite = values[np.isfinite(values)] + channel_range = ranges.get(name) + try: + upper_limit = float(channel_range) + except (TypeError, ValueError): + upper_limit = None + at_declared_range_fraction = ( + float(np.mean(finite >= upper_limit)) + if name in range_check_channels and upper_limit and finite.size + else None + ) + summaries.append( + { + "channel": name, + "finite_fraction": float(np.mean(np.isfinite(values))), + "minimum": float(np.min(finite)) if finite.size else None, + "p01": float(np.percentile(finite, 1)) if finite.size else None, + "median": float(np.median(finite)) if finite.size else None, + "p99": float(np.percentile(finite, 99)) if finite.size else None, + "maximum": float(np.max(finite)) if finite.size else None, + "at_declared_range_fraction": at_declared_range_fraction, + } + ) + return summaries + + +def _outlier_summary( + events: pd.DataFrame, measurement_channels: list[str], random_state: int = 0 +) -> dict[str, Any]: + """Return an unsupervised event-outlier metric, never a biological classification.""" + numeric = events[measurement_channels].select_dtypes(include=[np.number]) + numeric = numeric.replace([np.inf, -np.inf], np.nan).dropna(axis=0) + if len(numeric) < 100 or numeric.shape[1] == 0: + return {"available": False, "reason": "Need at least 100 complete numeric events."} + positions = np.linspace(0, len(numeric) - 1, min(len(numeric), 50_000), dtype=int) + sample = numeric.iloc[positions, : min(numeric.shape[1], 16)] + standardized = (sample - sample.median()) / sample.std(ddof=0).replace(0, 1) + model = IsolationForest(contamination="auto", random_state=random_state, n_estimators=100) + labels = model.fit_predict(standardized) + return { + "available": True, + "events_scored": int(len(sample)), + "channels_scored": list(sample.columns), + "outlier_fraction": float(np.mean(labels == -1)), + "interpretation": "Exploratory unusual multichannel measurements, not cell identities, sample classes, or a validated QC threshold.", + } + + +def build_qc_report(metadata: dict[str, Any], events: pd.DataFrame) -> dict[str, Any]: + """Create a JSON-serializable, evidence-only FCS technical QC report.""" + roles = _channel_roles(events, metadata.get("_channel_stains", {})) + time_columns = roles["time"] + time_integrity: dict[str, Any] = {"available": bool(time_columns)} + if time_columns: + values = events[time_columns[0]].to_numpy(dtype=float) + finite = values[np.isfinite(values)] + differences = np.diff(finite) + positive_gaps = differences[differences > 0] + if finite.size > 1 and finite[-1] > finite[0]: + counts, _ = np.histogram(finite, bins=20) + nonempty_counts = counts[counts > 0] + rate_bin_count_cv = ( + float(np.std(nonempty_counts) / np.mean(nonempty_counts)) if nonempty_counts.size else None + ) + else: + rate_bin_count_cv = None + time_integrity.update( + { + "channel": time_columns[0], + "nondecreasing_fraction": float(np.mean(differences >= 0)) if len(finite) > 1 else None, + "negative_fraction": float(np.mean(finite < 0)) if len(finite) else None, + "reset_count": int(np.sum(differences < 0)), + "largest_positive_gap": float(np.max(positive_gaps)) if positive_gaps.size else None, + "median_positive_gap": float(np.median(positive_gaps)) if positive_gaps.size else None, + "rate_bin_count_cv": rate_bin_count_cv, + } + ) + compensation = _compensation_summary(metadata, events) + range_check_channels = set(roles["scatter"] + roles["fluorescence"]) + outlier_channels = roles["scatter"] + roles["fluorescence"] + return { + "scope": "Technical FCS quality checks only; not biological, clinical, or regulatory validation.", + "event_count": int(len(events)), + "channel_count": int(events.shape[1]), + "channels": list(events.columns), + "compensation_metadata_present": compensation["status"] != "absent", + "compensation": compensation, + "channel_roles": roles, + "parse_warnings": metadata.get("_parse_warnings", []), + "time_integrity": time_integrity, + "channel_summaries": _channel_summary( + events, metadata.get("_channel_ranges", {}), range_check_channels + ), + "outlier_summary": _outlier_summary(events, outlier_channels), + "limitations": [ + "FCS metadata cannot establish that compensation was correct or that controls were appropriate.", + "Gate quality requires the gating plan and control evidence in addition to event measurements.", + ], + } + + +def write_qc_artifacts( + report: dict[str, Any], events: pd.DataFrame, output_dir: str | Path, stem: str = "flow_qc" +) -> dict[str, str]: + """Write a technical QC dashboard and channel-summary CSV, returning their paths.""" + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + target = Path(output_dir) + target.mkdir(parents=True, exist_ok=True) + dashboard = target / f"{stem}_dashboard.png" + summary_csv = target / f"{stem}_channel_summary.csv" + pd.DataFrame(report["channel_summaries"]).to_csv(summary_csv, index=False) + + roles = report["channel_roles"] + fsc = next((name for name in roles["scatter"] if name.lower().startswith("fsc")), None) + ssc = next((name for name in roles["scatter"] if name.lower().startswith("ssc")), None) + time = roles["time"][0] if roles["time"] else None + signal = roles["fluorescence"][0] if roles["fluorescence"] else None + sample = events.iloc[: min(len(events), 20_000)] + fig, axes = plt.subplots(2, 2, figsize=(12, 9)) + + scatter = axes[0, 0] + if fsc and ssc: + scatter.scatter(sample[fsc], sample[ssc], s=1, alpha=0.18, rasterized=True) + scatter.set(xlabel=fsc, ylabel=ssc, title="Forward vs. side scatter") + else: + scatter.text(0.5, 0.5, "FSC/SSC channels not found", ha="center", va="center") + scatter.set_axis_off() + + distribution = axes[0, 1] + values = sample[signal].to_numpy(dtype=float) if signal else np.array([]) + values = values[np.isfinite(values)] + if signal and values.size: + distribution.hist(values, bins=80, color="#3478bf", alpha=0.85) + distribution.set(xlabel=signal, ylabel="Events", title=f"Fluorescence distribution: {signal}") + else: + distribution.text(0.5, 0.5, "Fluorescence channel not found", ha="center", va="center") + distribution.set_axis_off() + + trend = axes[1, 0] + if time: + trend.plot(sample.index, sample[time], linewidth=0.6, color="#4d8c57") + trend.set(xlabel="Event index", ylabel=time, title="Acquisition time trend") + else: + trend.text(0.5, 0.5, "Time channel not found", ha="center", va="center") + trend.set_axis_off() + + saturation = axes[1, 1] + summaries = report["channel_summaries"] + labels = [item["channel"] for item in summaries] + summaries = [item for item in summaries if item["channel"] in set(roles["scatter"] + roles["fluorescence"])] + labels = [item["channel"] for item in summaries] + fractions = [item["at_declared_range_fraction"] or 0.0 for item in summaries] + saturation.bar(range(len(labels)), fractions, color="#c45a3c") + saturation.set_xticks(range(len(labels)), labels, rotation=40, ha="right", fontsize=8) + saturation.set( + ylabel="Fraction at or above declared range", + title="Measurements at declared range limit", + ) + saturation.set_ylim(0, max(0.01, max(fractions, default=0.0) * 1.15)) + + fig.suptitle("Flow cytometry technical QC dashboard", fontweight="bold") + fig.tight_layout() + fig.savefig(dashboard, dpi=160, bbox_inches="tight") + plt.close(fig) + return {"qc_dashboard": str(dashboard), "channel_summary_csv": str(summary_csv)} diff --git a/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py b/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py new file mode 100644 index 00000000..ec006503 --- /dev/null +++ b/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py @@ -0,0 +1,66 @@ +"""End-to-end tests for the flow-cytometry QC helper.""" + +import json +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +from flowio import create_fcs + +sys.path.insert(0, str(Path(__file__).parent)) +from flow_qc_utils import build_qc_report, load_fcs, write_qc_artifacts + + +def test_load_fcs_preserves_channel_names_and_time_qc(tmp_path: Path) -> None: + """A generated standards-compliant FCS file retains names needed by technical checks.""" + destination = tmp_path / "synthetic.fcs" + rng = np.random.default_rng(42) + events = np.column_stack( + ( + rng.normal(50_000, 5_000, 500), + rng.normal(20_000, 3_000, 500), + np.arange(500), + rng.uniform(0, 60_000, 500), + ) + ).astype("float32") + with destination.open("wb") as handle: + create_fcs( + handle, + events.flatten().tolist(), + ["FSC-A", "SSC-A", "Time", "FITC-A"], + metadata_dict={"$SPILLOVER": "synthetic"}, + ) + + metadata, parsed_events = load_fcs(destination) + report = build_qc_report(metadata, parsed_events) + + assert parsed_events.columns.tolist() == ["FSC-A", "SSC-A", "Time", "FITC-A"] + assert report["event_count"] == 500 + assert report["compensation_metadata_present"] is True + assert report["time_integrity"]["available"] is True + assert report["outlier_summary"]["available"] is True + assert json.dumps(report) + + +def test_write_qc_artifacts_creates_dashboard_and_csv(tmp_path: Path) -> None: + events = pd.DataFrame( + { + "FSC-A": [100, 200, 300], + "FSC-H": [90, 190, 290], + "SSC-A": [20, 30, 40], + "Time": [0, 1, 2], + "Event #": [1, 2, 3], + "FITC-A": [10, 30, 20], + } + ) + report = build_qc_report( + {"_channel_ranges": {"Event #": 3, "FITC-A": 100}, "_channel_stains": {}}, events + ) + artifacts = write_qc_artifacts(report, events, tmp_path, "fixture") + assert Path(artifacts["qc_dashboard"]).read_bytes().startswith(b"\x89PNG") + assert Path(artifacts["channel_summary_csv"]).is_file() + assert report["channel_roles"]["index"] == ["Event #"] + event_summary = next(item for item in report["channel_summaries"] if item["channel"] == "Event #") + assert event_summary["at_declared_range_fraction"] is None + assert "FSC-H" not in report["channel_roles"]["fluorescence"] diff --git a/agents/flow-cytometry-referee/tools/flow-qc/tool.yaml b/agents/flow-cytometry-referee/tools/flow-qc/tool.yaml new file mode 100644 index 00000000..e1479c8d --- /dev/null +++ b/agents/flow-cytometry-referee/tools/flow-qc/tool.yaml @@ -0,0 +1,35 @@ +name: flow-qc +description: > + Python environment for deterministic flow-cytometry FCS quality checks, compensation metadata inspection, + channel summaries, and optional unsupervised event outlier scoring. +version: 1.0.0 +category: Scientific Computing +license: MIT +infra: + - name: worker + infra_type: container + image: + acr: '{name}.azurecr.io/flow-qc:latest' + compute: + min_resources: + cpu: 2 + ram: '8Gi' + storage: '10Gi' + max_resources: + cpu: 8 + ram: '32Gi' + storage: '30Gi' + infiniband: false + recommended_sku: + - Standard_D4s_v6 + - Standard_D8s_v6 + pool_type: static + pool_size: 1 +code_environments: + - language: python3 + command: 'python3 -u "/{{scriptName}}" > {{output_directory}}/logs.out 2>&1; cp -r /workdir/* {{output_directory}}/ 2>/dev/null || true; tail -n 100 {{output_directory}}/logs.out' + description: > + Python environment with flowio, NumPy, pandas, scikit-learn, and flow_qc_utils. It reads standard FCS + files and produces technical QC metrics. It is not a clinical classifier or a substitute for validated + laboratory analysis. + infra_node: worker