diff --git a/.changeset/chain-matching-case-insensitive.md b/.changeset/chain-matching-case-insensitive.md new file mode 100644 index 0000000..6b7ebe9 --- /dev/null +++ b/.changeset/chain-matching-case-insensitive.md @@ -0,0 +1,12 @@ +--- +'@platforma-open/milaboratories.import-vdj.workflow': patch +--- + +Make chain matching case-insensitive across the Custom, ImmunoSeq and QIAseq import formats, and position-independent for Custom and ImmunoSeq. + +All three compared the chain-defining value with an exact, case-sensitive equality, so a lowercase gene call such as `ighv23` matched no chain at all. Every row was then dropped and the import completed without error, reporting 0 clones. + +- **Custom** and **ImmunoSeq** matched the V gene name with a position-anchored slice (`strSlice(0, N).eq("IGH")`). They now uppercase the value and look for the locus anywhere in the name, matching what `import-common` already did for the MiXCR, Cell Ranger and AIRR formats. Species-prefixed calls such as `musIGHV1-1` now match as well. +- **QIAseq** matches on its own `chain` column, which is uppercased before comparison. The existing suffix-stripping behaviour is unchanged, so a chain value that does not end in `C` is still not matched. + +Gene names continue to be stored exactly as they appear in the input — only the comparison is normalized, consistent with existing `import-common` behaviour. diff --git a/.changeset/empty-chain-samples-warning.md b/.changeset/empty-chain-samples-warning.md new file mode 100644 index 0000000..d5347b9 --- /dev/null +++ b/.changeset/empty-chain-samples-warning.md @@ -0,0 +1,9 @@ +--- +'@platforma-open/milaboratories.import-vdj.model': minor +'@platforma-open/milaboratories.import-vdj.ui': minor +'@platforma-open/milaboratories.import-vdj': minor +--- + +Warn when receptor chain filtering leaves a sample with no clonotypes. + +A dataset whose rows all fail the chain filter imported successfully and silently produced an empty result. This is the same failure mode the case-insensitive chain matching fix addresses, but visible to the user rather than only to whoever reads the counts. The block now sums `pl7.app/vdj/stat/clonotypeCount` across every imported chain per sample and shows a warning naming the samples that came out at zero, capped at five names plus an overflow count. diff --git a/model/src/index.ts b/model/src/index.ts index 2700e24..1d1479a 100644 --- a/model/src/index.ts +++ b/model/src/index.ts @@ -1,5 +1,10 @@ -import type { InferOutputsType } from "@platforma-sdk/model"; -import { BlockModelV3, DataColumn, createPlDataTableV3 } from "@platforma-sdk/model"; +import type { InferOutputsType, PColumnKey, PColumnValue } from "@platforma-sdk/model"; +import { + BlockModelV3, + DataColumn, + createPlDataTableV3, + TreeNodeAccessor, +} from "@platforma-sdk/model"; import { blockDataModel } from "./data-model"; import type { BlockArgs, BlockData, ColumnDescription, ColumnProfile } from "./types"; import { bareSetValid } from "./types"; @@ -7,6 +12,15 @@ import { bareSetValid } from "./types"; export * from "./types"; export { upgradeLegacyData } from "./data-model"; +/** Unpartitioned inline-JSON p-column storage; payload is `{ keyLength, data }`. */ +const RT_JSON = "PColumnData/Json"; + +type JsonColumnData = { + keyLength: number; + /** Keys are stringified axis tuples, e.g. `'["S1"]'`. */ + data: Record; +}; + /** * The mapping fields that belong to the dataset door and mean nothing on a bare set: the bare * path in the workflow reads `bareSet`, `fileSource` and `datasetRef` and nothing else. @@ -438,11 +452,10 @@ export const platforma = BlockModelV3.create(blockDataModel) return undefined; } - // Anchor on the annotated count. Every column here — the four counts and the chain label — - // keys on the single chain axis, so the choice does not affect the join; it decides what V3 - // discovers labels against and what stays permanently visible, since visibility rules are - // never applied to primary columns. The annotated count is emitted for every run. - const primary = pCols.find((c) => c.spec.name.endsWith("/annotatedCount")); + // Anchor on the count its own path always emits — the two paths share this output but no columns. + const primary = + pCols.find((c) => c.spec.name.endsWith("/annotatedCount")) ?? + pCols.find((c) => c.spec.name === "pl7.app/vdj/stat/clonotypeCount"); if (primary === undefined) { return undefined; } @@ -457,6 +470,48 @@ export const platforma = BlockModelV3.create(blockDataModel) }); }) + // Samples summing to zero clonotypes across every imported chain — normally chain filtering matching nothing. + .output("emptyChainSamples", (ctx) => { + const pCols = ctx.outputs?.resolve("stats")?.getPColumns(); + if (pCols === undefined) { + return undefined; + } + + const countCols = pCols.filter((c) => c.spec.name === "pl7.app/vdj/stat/clonotypeCount"); + const [firstCol] = countCols; + if (firstCol === undefined) { + return undefined; + } + + const totals = new Map(); + for (const col of countCols) { + const data = col.data; + if (!(data instanceof TreeNodeAccessor) || data.resourceType.name !== RT_JSON) { + return undefined; + } + const json = data.getDataAsJsonOrUndefined(); + if (json === undefined) { + return undefined; + } + for (const [keyStr, value] of Object.entries(json.data)) { + const [sampleId] = JSON.parse(keyStr) as PColumnKey; + if (sampleId === undefined) { + continue; + } + const count = typeof value === "number" ? value : 0; + totals.set(sampleId, (totals.get(sampleId) ?? 0) + count); + } + } + + const labels = ctx.resultPool.findLabelsForColumnAxis(firstCol.spec, 0); + const emptySamples = [...totals.entries()] + .filter(([, total]) => total === 0) + .map(([sampleId]) => labels?.[sampleId] ?? String(sampleId)) + .sort((a, b) => a.localeCompare(b)); + + return { emptySamples, sampleCount: totals.size }; + }) + .sections((_ctx) => [{ type: "link", href: "/", label: "Main" }]) .title(() => "Import V(D)J Data") diff --git a/ui/src/pages/MainPage.vue b/ui/src/pages/MainPage.vue index be25cb4..401fd9c 100644 --- a/ui/src/pages/MainPage.vue +++ b/ui/src/pages/MainPage.vue @@ -64,6 +64,20 @@ const receptorOptions = [ { value: "TCRGD", label: "TCR-ɣδ" }, ]; +// Warn when chain filtering left a sample with no clonotypes; cap the names so a big dataset can't flood the banner. +const EMPTY_SAMPLES_SHOWN = 5; + +const emptySamplesMessage = computed(() => { + const empty = app.model.outputs.emptyChainSamples?.emptySamples ?? []; + if (empty.length === 0) return undefined; + + const shown = empty.slice(0, EMPTY_SAMPLES_SHOWN).join(", "); + const overflow = empty.length - EMPTY_SAMPLES_SHOWN; + const samples = overflow > 0 ? `${shown} and ${overflow} more` : shown; + + return `After receptor chain filtering, no clonotypes found in sample(s) ${samples}`; +}); + const SCHEME_LABELS: Record = { imgt: "IMGT", kabat: "Kabat", @@ -907,6 +921,11 @@ watch( + + + {{ emptySamplesMessage }} + +