Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/chain-matching-case-insensitive.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .changeset/empty-chain-samples-warning.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 62 additions & 7 deletions model/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
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";

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<string, PColumnValue>;
};

/**
* 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.
Expand Down Expand Up @@ -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;
}
Expand All @@ -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<string | number, number>();
for (const col of countCols) {
const data = col.data;
if (!(data instanceof TreeNodeAccessor) || data.resourceType.name !== RT_JSON) {
return undefined;
}
const json = data.getDataAsJsonOrUndefined<JsonColumnData>();
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")
Expand Down
19 changes: 19 additions & 0 deletions ui/src/pages/MainPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<BareSetScheme, string> = {
imgt: "IMGT",
kabat: "Kabat",
Expand Down Expand Up @@ -907,6 +921,11 @@ watch(
</template>
</PlSlideModal>

<PlAlert v-if="emptySamplesMessage" type="warn" :style="{ width: '100%' }">
<template #title>No clonotypes imported</template>
{{ emptySamplesMessage }}
</PlAlert>

<PlAgDataTableV2
v-model="app.model.data.tableState"
:settings="tableSettings"
Expand Down
8 changes: 5 additions & 3 deletions workflow/src/import-custom.tpl.tengo
Original file line number Diff line number Diff line change
Expand Up @@ -169,16 +169,18 @@ self.body(func(inputs) {
for chain in chains {
prefixes := chainToLocusMap[chain]

// Locus matching mirrors import-common: case and position insensitive
chainDf := undefined
if chain == "IGLight" {
chainColumnUpper := pt.col(chainColumn).strToUpper()
chainDf = df.filter(
pt.col(chainColumn).strSlice(0, 3).eq("IGK").or(pt.col(chainColumn).strSlice(0, 3).eq("IGL"))
chainColumnUpper.strContains("IGK", { literal: true }).or(chainColumnUpper.strContains("IGL", { literal: true }))
)
} else {
filterExpr := undefined
for i := 0; i < len(prefixes); i++ {
prefix := prefixes[i]
expr := pt.col(chainColumn).strSlice(0, len(prefix)).eq(prefix)
expr := pt.col(chainColumn).strToUpper().strContains(prefix, { literal: true })
if i == 0 {
filterExpr = expr
} else {
Expand Down Expand Up @@ -275,4 +277,4 @@ self.body(func(inputs) {
tsv: tsv,
stats: stats
}
})
})
6 changes: 4 additions & 2 deletions workflow/src/import-immunoSeq.tpl.tengo
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,15 @@ self.body(func(inputs) {
for chain in chains {
prefix := chainToLocusMap[chain]

// Locus matching mirrors import-common: case and position insensitive
chainDf := undefined
if chain == "IGLight" {
vGeneUpper := pt.col("v-gene").strToUpper()
chainDf = df.filter(
pt.col("v-gene").strSlice(0, 3).eq("IGK").or(pt.col("v-gene").strSlice(0, 3).eq("IGL"))
vGeneUpper.strContains("IGK", { literal: true }).or(vGeneUpper.strContains("IGL", { literal: true }))
)
} else {
chainDf = df.filter(pt.col("v-gene").strSlice(0, len(prefix)).eq(prefix))
chainDf = df.filter(pt.col("v-gene").strToUpper().strContains(prefix, { literal: true }))
}

// Calculate "is-productive" column based on CDR3 AA sequence presence.
Expand Down
9 changes: 7 additions & 2 deletions workflow/src/import-qiagen.tpl.tengo
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,21 @@ self.body(func(inputs) {
for chain in chains {
prefix := chainToLocusMap[chain]

// Chain values are matched case-insensitively.
// The trailing 'C' has already been stripped from the chain column above, so
// this still assumes the source value carried that suffix.
chainUpper := pt.col("chain").strToUpper()

chainDf := undefined
if chain == "IGLight" {
// For IG light chains, check for both IGK and IGL (after removing 'C' suffix)
chainDf = df.filter(
pt.col("chain").eq("IGK").or(pt.col("chain").eq("IGL"))
chainUpper.eq("IGK").or(chainUpper.eq("IGL"))
)
} else {
// Use exact chain column value for filtering (after removing 'C' suffix)
qiagenChainValue := chainToLocusMap[chain]
chainDf = df.filter(pt.col("chain").eq(qiagenChainValue))
chainDf = df.filter(chainUpper.eq(qiagenChainValue))
}

// Calculate "is-productive" column based on CDR3 AA sequence presence.
Expand Down
Loading