Skip to content

Commit 8c2d6a5

Browse files
feat(prepro): add custom lineage functions (#6174)
resolves # thanks to claude for writing the tests! ### Screenshot ### PR Checklist - [ ] All necessary documentation has been adapted. - [ ] The implemented feature is covered by appropriate, automated tests. - [ ] Any manual testing that has been done is documented (i.e. what exactly was tested?) 🚀 Preview: https://add-custom-lineage-functi.loculus.org --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
1 parent 06429f4 commit 8c2d6a5

5 files changed

Lines changed: 457 additions & 25 deletions

File tree

ingest/Snakefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ if CALCULATE_GROUPS_OVERRIDE_JSON:
415415
if [[ "{params.use_mirror}" == "True" ]]; then
416416
curl {params.mirror_url} -o {params.compressed_package_tzst} && \
417417
mkdir -p {params.package} && \
418-
tar xvf {params.compressed_package_tzst} -C {params.package}
418+
tar xf {params.compressed_package_tzst} -C {params.package}
419419
else
420420
datasets download genome taxon {params.taxon_id} \
421421
--assembly-source {wildcards.source} \

kubernetes/loculus/values.yaml

Lines changed: 64 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,21 +1406,6 @@ defaultOrganismConfig: &defaultOrganismConfig
14061406
label: Length
14071407
preprocessing:
14081408
inputs: {input: nextclade.coverage}
1409-
- name: variant
1410-
isSequenceFilter: true
1411-
perSegment: true
1412-
header: "Alignment and QC metrics"
1413-
displayName: "Variant"
1414-
type: boolean
1415-
noInput: true
1416-
autocomplete: true
1417-
initiallyVisible: false
1418-
includeInDownloadsByDefault: false
1419-
preprocessing:
1420-
function: is_above_threshold
1421-
args:
1422-
threshold: 50
1423-
inputs: {input: "nextclade.privateNucMutations.totalPrivateSubstitutions"}
14241409
website: &website
14251410
tableColumns:
14261411
- sampleCollectionDate
@@ -1614,6 +1599,21 @@ defaultOrganisms:
16141599
includeInDownloadsByDefault: false
16151600
preprocessing:
16161601
inputs: {input: "nextclade.cladeFounderInfo.aaMutations.*.privateSubstitutions"}
1602+
- name: variant
1603+
isSequenceFilter: true
1604+
perSegment: true
1605+
header: "Alignment and QC metrics"
1606+
displayName: "Variant"
1607+
type: boolean
1608+
noInput: true
1609+
autocomplete: true
1610+
initiallyVisible: false
1611+
includeInDownloadsByDefault: false
1612+
preprocessing:
1613+
function: is_variant
1614+
args:
1615+
mu: 0.002
1616+
inputs: {numMutations: "nextclade.privateNucMutations.totalPrivateSubstitutions", length: processed.length}
16171617
website:
16181618
<<: *website
16191619
tableColumns:
@@ -2089,26 +2089,67 @@ defaultOrganisms:
20892089
header: "Host"
20902090
ingest: ncbiHostName
20912091
initiallyVisible: true
2092-
- name: variant
2092+
- name: variant_L
20932093
isSequenceFilter: true
2094-
perSegment: true
20952094
header: "Clade & Lineage"
20962095
oneHeader: true
2097-
displayName: "Variant"
2096+
displayName: "Variant L"
2097+
relatesToSegment: L
20982098
type: boolean
20992099
noInput: true
21002100
autocomplete: true
21012101
initiallyVisible: false
21022102
includeInDownloadsByDefault: false
21032103
customDisplay:
21042104
type: variantReference
2105-
displayGroup: reference
2106-
label: Closest reference
2105+
displayGroup: reference_L
2106+
label: Closest reference L
2107+
preprocessing:
2108+
function: is_variant
2109+
args:
2110+
mu: 0.004
2111+
inputs: {numMutations: "nextclade.totalSubstitutions", length: processed.length_L}
2112+
- name: variant_M
2113+
isSequenceFilter: true
2114+
header: "Clade & Lineage"
2115+
oneHeader: true
2116+
displayName: "Variant M"
2117+
relatesToSegment: M
2118+
type: boolean
2119+
noInput: true
2120+
autocomplete: true
2121+
initiallyVisible: false
2122+
includeInDownloadsByDefault: false
2123+
customDisplay:
2124+
type: variantReference
2125+
displayGroup: reference_M
2126+
label: Closest reference M
2127+
preprocessing:
2128+
function: is_variant
2129+
args:
2130+
mu: 0.008
2131+
inputs: {numMutations: "nextclade.totalSubstitutions", length: processed.length_M}
2132+
- name: variant_S
2133+
isSequenceFilter: true
2134+
header: "Clade & Lineage"
2135+
oneHeader: true
2136+
displayName: "Variant S"
2137+
relatesToSegment: S
2138+
type: boolean
2139+
noInput: true
2140+
autocomplete: true
2141+
initiallyVisible: false
2142+
includeInDownloadsByDefault: false
2143+
customDisplay:
2144+
type: variantReference
2145+
displayGroup: reference_S
2146+
label: Closest reference S
21072147
preprocessing:
2108-
function: is_above_threshold
2148+
function: is_variant
21092149
args:
2110-
threshold: 1000
2111-
inputs: {input: "nextclade.totalSubstitutions"} #custom nextclade dataset does not have private mutations, so using total substitutions as a proxy for distance from reference
2150+
mu: 0.004
2151+
# custom nextclade dataset does not have private mutations, so using total substitutions as a proxy for distance from reference
2152+
inputs: {numMutations: "nextclade.totalSubstitutions", length: processed.length_S}
21122153
- name: reference
21132154
oneHeader: true
21142155
header: "Clade & Lineage"

preprocessing/nextclade/src/loculus_preprocessing/processing_functions.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,154 @@ def is_above_threshold(
12501250
)
12511251
return ProcessingResult(datum=(input > threshold), warnings=[], errors=[])
12521252

1253+
@staticmethod
1254+
def is_variant(
1255+
input_data: InputMetadata, output_field: str, input_fields: list[str], args: FunctionArgs
1256+
) -> ProcessingResult:
1257+
"""Flag if number of mutations is above mutation rate (specified in args) times length"""
1258+
if "mu" not in args:
1259+
return ProcessingResult(
1260+
datum=None,
1261+
warnings=[],
1262+
errors=[
1263+
ProcessingAnnotation.from_fields(
1264+
input_fields,
1265+
[output_field],
1266+
AnnotationSourceType.METADATA,
1267+
message=(
1268+
f"Field {output_field} is missing mu argument."
1269+
" Please report this error to the administrator."
1270+
),
1271+
)
1272+
],
1273+
)
1274+
length_datum = input_data.get("length")
1275+
num_mutations_datum = input_data.get("numMutations")
1276+
if length_datum is None or num_mutations_datum is None:
1277+
return ProcessingResult(datum=None, warnings=[], errors=[])
1278+
try:
1279+
mu = float(args["mu"]) # type: ignore
1280+
length = float(length_datum)
1281+
threshold = mu * length
1282+
is_above_threshold_result = ProcessingFunctions.is_above_threshold(
1283+
input_data={"input": num_mutations_datum},
1284+
output_field=output_field,
1285+
input_fields=input_fields,
1286+
args={"threshold": threshold},
1287+
)
1288+
except (ValueError, TypeError):
1289+
return ProcessingResult(
1290+
datum=None,
1291+
warnings=[],
1292+
errors=[
1293+
ProcessingAnnotation.from_fields(
1294+
input_fields,
1295+
[output_field],
1296+
AnnotationSourceType.METADATA,
1297+
message=(
1298+
f"Field {output_field} has non-numeric length or numMutations value."
1299+
),
1300+
)
1301+
],
1302+
)
1303+
return ProcessingResult(
1304+
datum=is_above_threshold_result.datum,
1305+
warnings=is_above_threshold_result.warnings,
1306+
errors=is_above_threshold_result.errors,
1307+
)
1308+
1309+
@staticmethod
1310+
def assign_custom_lineage( # noqa: C901
1311+
input_data: InputMetadata, output_field: str, input_fields: list[str], args: FunctionArgs
1312+
) -> ProcessingResult:
1313+
"""
1314+
Assign flu lineage based on seg4 and seg6.
1315+
Add reassortant flag if different lineages are detected for internal segments,
1316+
add and variant flag if any segment is a variant.
1317+
It expects the following input_data fields to be present:
1318+
- subtype_seg4: the subtype assigned to seg4
1319+
- subtype_seg6: the subtype assigned to seg6
1320+
- reference_seg1,...,reference_seg8: the reference sequence assigned to each segment
1321+
- variant_seg1,...,variant_seg8: boolean flag indicating whether a segment is a variant
1322+
It expects the following args to be present:
1323+
- pattern: regex pattern to extract lineage from reference
1324+
e.g. ^(?P<segment>[^-]+)-(?P<lineage>[^-]+)$
1325+
- uppercase: boolean flag indicating whether to uppercase the extracted lineage
1326+
- capture_group: the name of the capture group in the regex pattern to extract
1327+
e.g. "lineage"
1328+
"""
1329+
logger.debug(
1330+
f"Starting custom lineage assignment with input_data: {input_data} and args: {args}"
1331+
)
1332+
if not input_data:
1333+
return ProcessingResult(datum=None, warnings=[], errors=[])
1334+
ha_subtype = input_data.get("subtype_seg4")
1335+
na_subtype = input_data.get("subtype_seg6")
1336+
references: dict[str, str | None] = {}
1337+
extracted_lineages: dict[str, str | None] = {}
1338+
variant: dict[str, bool | None] = {}
1339+
for i in range(1, 9):
1340+
segment = f"seg{i}"
1341+
reference_field = f"reference_seg{i}"
1342+
variant_field = f"variant_seg{i}"
1343+
if reference_field in input_data:
1344+
references[segment] = input_data.get(reference_field)
1345+
variant[segment] = (
1346+
bool(input_data.get(variant_field)) if variant_field in input_data else None
1347+
)
1348+
try:
1349+
for i in range(1, 9):
1350+
segment = f"seg{i}"
1351+
extracted_lineages[segment] = ProcessingFunctions.call_function( # type: ignore
1352+
"extract_regex",
1353+
{
1354+
"pattern": args["pattern"],
1355+
"uppercase": args["uppercase"],
1356+
"capture_group": args["capture_group"],
1357+
},
1358+
{"regex_field": references.get(segment, "")},
1359+
"output_field",
1360+
["segment_name"],
1361+
).datum
1362+
logger.debug(f"Extracted lineages: {extracted_lineages} from references: {references}")
1363+
if not ha_subtype or not na_subtype:
1364+
return ProcessingResult(datum=None, warnings=[], errors=[])
1365+
lineage = f"{ha_subtype}{na_subtype}"
1366+
if (
1367+
extracted_lineages.get("seg4") == "H1N1PDM"
1368+
and extracted_lineages.get("seg6") == "H1N1PDM"
1369+
):
1370+
lineage = "H1N1pdm"
1371+
logger.debug(
1372+
f"Determined preliminary lineage {lineage} based on segments seg4 and seg6"
1373+
)
1374+
if lineage in {"H1N1", "H3N2", "H2N2", "H1N1pdm"}:
1375+
logger.debug(
1376+
f"Lineage {lineage} is a human lineage, checking for reassortment and variants"
1377+
)
1378+
# only assign human lineages
1379+
if len({v for v in extracted_lineages.values() if v is not None}) > 1:
1380+
lineage += " reassortant"
1381+
if any(v for v in variant.values() if v):
1382+
lineage += " (variant)"
1383+
return ProcessingResult(datum=lineage, warnings=[], errors=[])
1384+
except (ValueError, TypeError):
1385+
return ProcessingResult(
1386+
datum=None,
1387+
warnings=[],
1388+
errors=[
1389+
ProcessingAnnotation.from_fields(
1390+
input_fields,
1391+
[output_field],
1392+
AnnotationSourceType.METADATA,
1393+
message=(
1394+
f"Internal error processing custom lineage for field {output_field}."
1395+
),
1396+
)
1397+
],
1398+
)
1399+
return ProcessingResult(datum=None, warnings=[], errors=[])
1400+
12531401
@staticmethod
12541402
def build_display_name( # noqa: C901
12551403
input_data: InputMetadata,

0 commit comments

Comments
 (0)