diff --git a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx index 6739cf1b5..22e7eef6e 100644 --- a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx +++ b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx @@ -178,6 +178,14 @@ function PredefinedSignature({ + setPageState({ ...pageState, minJaccard: v })} + /> ); } diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index 9d69bde1e..c5035dba1 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -16,7 +16,7 @@ import { getBackendServiceForClientside } from '../../../backendApi/backendServi import { getCollection } from '../../../covspectrum/getCollection'; import { detailedMutationsToQuery } from '../../../covspectrum/variantConversionUtil'; import { getCladeLineages } from '../../../lapis/getCladeLineages'; -import { getMutations, getMutationsForVariant } from '../../../lapis/getMutations'; +import { getJaccardForMutations, getMutations, getMutationsForVariant } from '../../../lapis/getMutations'; import { parseQuery } from '../../../lapis/parseQuery'; import { validateGenomeOnly } from '../../../util/siloExpressionUtils'; @@ -77,7 +77,7 @@ async function fetchVariantModeData( case 'computed': return fetchVariantComputedModeData(config, analysis); case 'predefined': - return fetchVariantPredefinedModeData(analysis); + return fetchVariantPredefinedModeData(config, analysis); } } @@ -113,7 +113,13 @@ async function fetchVariantComputedModeData( }; } -async function fetchVariantPredefinedModeData(analysis: WasapVariantFilter): Promise { +async function fetchVariantPredefinedModeData( + config: WasapPageConfig, + analysis: WasapVariantFilter, +): Promise { + if (!config.variantAnalysisModeEnabled) { + throw Error("Cannot fetch data, 'variant' mode is not enabled."); + } if (analysis.collectionId === undefined) { throw new Error('No collection selected for predefined variant mode.'); } @@ -140,7 +146,32 @@ async function fetchVariantPredefinedModeData(analysis: WasapVariantFilter): Pro ? (variant.filterObject.nucleotideMutations ?? []) : (variant.filterObject.aminoAcidMutations ?? []); - return { type: 'mutations', displayMutations: mutations }; + const jaccardByMutation = await getJaccardForMutations( + config.clinicalLapis.lapisBaseUrl, + analysis.sequenceType, + { [config.clinicalLapis.lineageField]: collection.name }, + getLapisFilterForTimeFrame(analysis.timeFrame, config.clinicalLapis.dateField), + ); + + if (jaccardByMutation.size === 0) { + return { type: 'mutations', displayMutations: mutations }; + } + + return { + type: 'mutations', + displayMutations: mutations.filter((m) => (jaccardByMutation.get(m) ?? 0) >= analysis.minJaccard), + customColumns: [ + { + header: 'Jaccard index', + values: Object.fromEntries( + mutations + .filter((m) => jaccardByMutation.has(m) && (jaccardByMutation.get(m) ?? 0) >= analysis.minJaccard) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .map((m) => [m, jaccardByMutation.get(m)!.toPrecision(2)]), + ), + }, + ], + }; } function fetchResistanceModeData( diff --git a/website/src/lapis/getMutations.spec.ts b/website/src/lapis/getMutations.spec.ts index 394b30edd..26e64b369 100644 --- a/website/src/lapis/getMutations.spec.ts +++ b/website/src/lapis/getMutations.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { getMutations, getMutationsForVariant } from './getMutations.ts'; +import { getJaccardForMutations, getMutations, getMutationsForVariant } from './getMutations.ts'; import { DUMMY_LAPIS_URL } from '../../routeMocker.ts'; import { astroApiRouteMocker, lapisRouteMocker } from '../../vitest.setup.ts'; @@ -85,6 +85,110 @@ describe('getMutations', () => { }); }); +describe('getJaccardForMutations', () => { + beforeEach(() => { + astroApiRouteMocker.mockLog(); + }); + + test('should return Jaccard indices for mutations observed in lineage', async () => { + // Variant A.1 has 20 sequences in clinical data. + // A1C: in 10 A.1 sequences, 40 total -> Jaccard = 10/(20+40-10) = 10/50 = 0.2 + // A2C: in 20 A.1 sequences, 20 total -> Jaccard = 20/(20+20-20) = 20/20 = 1.0 + lapisRouteMocker.mockPostAggregated({ lineage: 'A.1' }, { data: [{ count: 20 }] }); + lapisRouteMocker.mockPostNucleotideMutationsMulti([ + { + body: { minProportion: 0, lineage: 'A.1' }, + response: { + data: [ + { mutation: 'A1C', count: 10 }, + { mutation: 'A2C', count: 20 }, + ], + }, + }, + { + body: { minProportion: 0 }, + response: { + data: [ + { mutation: 'A1C', count: 40 }, + { mutation: 'A2C', count: 20 }, + ], + }, + }, + ]); + + const result = await getJaccardForMutations(DUMMY_LAPIS_URL, 'nucleotide', { lineage: 'A.1' }, undefined); + + expect(result.get('A1C')).toBeCloseTo(0.2); + expect(result.get('A2C')).toBe(1); + }); + + test('should return empty map when no clinical sequences match the lineage', async () => { + lapisRouteMocker.mockPostAggregated({ lineage: 'A.99' }, { data: [{ count: 0 }] }); + lapisRouteMocker.mockPostNucleotideMutationsMulti([ + { + body: { minProportion: 0, lineage: 'A.99' }, + response: { data: [] }, + }, + { + body: { minProportion: 0 }, + response: { data: [{ mutation: 'A1C', count: 40 }] }, + }, + ]); + + const result = await getJaccardForMutations(DUMMY_LAPIS_URL, 'nucleotide', { lineage: 'A.99' }, undefined); + + expect(result.size).toBe(0); + }); + + test('should not include mutations absent from the lineage', async () => { + // A1C is in the clinical database but not in lineage A.1 sequences. + lapisRouteMocker.mockPostAggregated({ lineage: 'A.1' }, { data: [{ count: 30 }] }); + lapisRouteMocker.mockPostNucleotideMutationsMulti([ + { + body: { minProportion: 0, lineage: 'A.1' }, + response: { data: [{ mutation: 'A2C', count: 30 }] }, + }, + { + body: { minProportion: 0 }, + response: { + data: [ + { mutation: 'A1C', count: 50 }, + { mutation: 'A2C', count: 30 }, + ], + }, + }, + ]); + + const result = await getJaccardForMutations(DUMMY_LAPIS_URL, 'nucleotide', { lineage: 'A.1' }, undefined); + + expect(result.has('A1C')).toBe(false); + expect(result.get('A2C')).toBe(1); + }); + + test('should apply date filter correctly', async () => { + lapisRouteMocker.mockPostAggregated({ lineage: 'A.1', dateFrom: '2025-01-01' }, { data: [{ count: 10 }] }); + lapisRouteMocker.mockPostNucleotideMutationsMulti([ + { + body: { minProportion: 0, lineage: 'A.1', dateFrom: '2025-01-01' }, + response: { data: [{ mutation: 'A1C', count: 10 }] }, + }, + { + body: { minProportion: 0, dateFrom: '2025-01-01' }, + response: { data: [{ mutation: 'A1C', count: 10 }] }, + }, + ]); + + const result = await getJaccardForMutations( + DUMMY_LAPIS_URL, + 'nucleotide', + { lineage: 'A.1' }, + { dateFrom: '2025-01-01' }, + ); + + expect(result.get('A1C')).toBe(1); + }); +}); + describe('getMutationsForVariant', () => { beforeEach(() => { astroApiRouteMocker.mockLog(); diff --git a/website/src/lapis/getMutations.ts b/website/src/lapis/getMutations.ts index 27b7ffe2a..343cf2c4d 100644 --- a/website/src/lapis/getMutations.ts +++ b/website/src/lapis/getMutations.ts @@ -41,6 +41,39 @@ export async function getMutations( ); } +/** + * Returns a map from mutation code to Jaccard index for all mutations observed in clinical + * sequences belonging to the given lineage. Mutations not observed in the lineage are absent + * from the map. Returns an empty map when no clinical sequences match the lineage filter. + * + * Use this to annotate a pre-defined list of mutations with clinical Jaccard scores without + * applying any proportion/count/threshold filtering. + */ +export async function getJaccardForMutations( + lapisUrl: string, + mutationType: SequenceType, + lineageFilter: LapisFilter, + dateFilter: LapisFilter | undefined, +): Promise> { + return Promise.all([ + getMutationsInternal(lapisUrl, mutationType, { ...lineageFilter, ...dateFilter }, 0), + getMutationsInternal(lapisUrl, mutationType, dateFilter, 0).then((r) => + Object.fromEntries(r.map((item) => [item.mutation, item.count])), + ), + getTotalCount(lapisUrl, { ...lineageFilter, ...dateFilter }), + ]).then(([intersectionCounts, totalCounts, variantCount]) => { + if (variantCount === 0) { + return new Map(); + } + return new Map( + intersectionCounts.map(({ mutation, count }) => [ + mutation, + count / (variantCount + (totalCounts[mutation] ?? count) - count), + ]), + ); + }); +} + /** * Returns the list of mutations that are defining this variant, based on the given parameters. * The result also includes the Jaccard index for every mutation.