';
+ html += '
';
html += '
Variant Details
';
html += '- Coordinates
- ';
@@ -608,6 +608,65 @@
}
}
html += '
';
+ } else if (rowData.variantType === 'ShortTandemRepeat' && variantData.info) {
+ var strRepeats = variantData.info.sample_repeats;
+ var strDetails = variantData.info.sample_repeat_details;
+
+ if (strRepeats && strRepeats.length > 0) {
+ html += '- Repeat Counts
- ';
+ strRepeats.forEach(function(count, i) {
+ html += 'Allele ' + (i + 1) + ': ' + escapeHtml(String(count)) + '
';
+ });
+ html += ' ';
+ }
+
+ if (strDetails && Object.keys(strDetails).length > 0) {
+ var maxRepeats = strRepeats && strRepeats.length > 0 ? Math.max.apply(null, strRepeats) : null;
+ html += '- Disease Classification
- ';
+ html += '
';
+ html += '| Disease | MOI | Normal | Intermediate | Pathogenic | Classification | ';
+ html += '
';
+
+ Object.keys(strDetails).forEach(function(disease) {
+ var d = strDetails[disease];
+ var norm = d.norm ? d.norm[0] + '–' + d.norm[1] : '—';
+ var inter = d.inter ? d.inter[0] + '–' + d.inter[1] : '—';
+ var pathThresh = d.pathogenic && d.pathogenic !== '.' ? '≥' + d.pathogenic : '—';
+
+ var badge = 'UNCLASSIFIED';
+ if (maxRepeats !== null) {
+ var pathNum = d.pathogenic && d.pathogenic !== '.' ? parseInt(d.pathogenic, 10) : null;
+ if (pathNum !== null && maxRepeats >= pathNum) {
+ badge = 'PATHOGENIC';
+ } else if (d.inter && maxRepeats >= d.inter[0] && maxRepeats <= d.inter[1]) {
+ badge = 'INTERMEDIATE';
+ } else if (d.norm && maxRepeats >= d.norm[0] && maxRepeats <= d.norm[1]) {
+ badge = 'NORMAL';
+ }
+ }
+
+ html += '';
+ html += '| ' + escapeHtml(disease) + ' | ';
+ html += '' + escapeHtml(d.moi || '—') + ' | ';
+ html += '' + escapeHtml(norm) + ' | ';
+ html += '' + escapeHtml(inter) + ' | ';
+ html += '' + escapeHtml(pathThresh) + ' | ';
+ html += '' + badge + ' | ';
+ html += '
';
+ });
+
+ html += '
';
+ }
+
+ if (rowData.genes) {
+ var strGenes = rowData.genes.split(',');
+ var strFirst = strGenes[0] ? strGenes[0].split(':') : ['', ''];
+ if (strFirst[1]) {
+ html += '- External Links
- ';
+ html += 'OMIM';
+ html += '
';
+ }
+ }
}
html += '
';
diff --git a/src/talos/unified_panelapp_parser.py b/src/talos/unified_panelapp_parser.py
index cfbd8c87..cad57359 100644
--- a/src/talos/unified_panelapp_parser.py
+++ b/src/talos/unified_panelapp_parser.py
@@ -430,6 +430,10 @@ def main(panel_data: str, output_file: str, pedigree_path: str, hpo_file: str |
panelapp_data.genes = {key: value for key, value in panelapp_data.genes.items() if key not in genes_to_remove}
+ # add in the STR content
+ panelapp_data.str_genes = cached_panelapp.str_genes
+ panelapp_data.str_symbols = cached_panelapp.str_symbols
+
# validate and write using pydantic
valid_cohort_details = PanelApp.model_validate(panelapp_data)
with open(output_file, 'w', encoding='utf-8') as handle:
diff --git a/src/talos/utils.py b/src/talos/utils.py
index 99f12f85..f353946e 100644
--- a/src/talos/utils.py
+++ b/src/talos/utils.py
@@ -12,6 +12,7 @@
import zoneinfo
from collections import defaultdict
from datetime import datetime
+from functools import cache
from itertools import chain, combinations, combinations_with_replacement, islice
from random import choices
from typing import TYPE_CHECKING, Any
@@ -21,13 +22,16 @@
from loguru import logger
from mendelbrot.bcftools_interpreter import TYPES_RE, classify_change
from mendelbrot.pedigree_parser import PedigreeParser
+from numpy import isnan
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential_jitter
from talos.config import config_retrieve
from talos.models import (
VARIANT_MODELS,
Coordinates,
+ PanelApp,
ResultData,
+ ShortTandemRepeat,
SmallVariant,
StructuralVariant,
lift_up_model_version,
@@ -75,6 +79,8 @@
# not in use yet, if we want to reduce the number of TranscriptConsequence entries we store, this is a good start
BORING_CONSEQUENCES = ['downstream_gene_variant', 'intron_variant', 'upstream_gene_variant']
+STR_RANGE = re.compile(r'min(?P
[0-9]+)max(?P[0-9]+)$')
+
def parse_mane_json_to_dict(mane_json: str) -> dict:
"""
@@ -134,6 +140,14 @@ def generator_chunks(generator, size):
yield list(chain([first], islice(iterator, size - 1)))
+@cache
+def get_categories_translated(conf_string: str = 'support_categories') -> set[str]:
+ """One central method to parse categories from config."""
+ support_categories = {word.lower() for word in config_retrieve(['ValidateMOI', conf_string], [])}
+ support_categories.update({translate_category(x) for x in support_categories})
+ return support_categories
+
+
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential_jitter(initial=1, max=5, exp_base=2),
@@ -399,6 +413,51 @@ def organise_svdb_doi(info_dict: dict[str, Any]):
info_dict['svdb_doi'] = doi_urls
+def parse_str_disease_details(detail_string: str) -> dict[str, dict[str, str | tuple[int, int] | None]]:
+ """
+ Parse the pipe-delimited disease details for this sample & locus.
+ Each block is in the format "Disease__MOI__NormalRange__IntermediateRange__PathogenicThreshold".
+ Ranges can be missing: "."
+ Other values can be missing: "NA"
+
+ Args:
+ detail_string: the single pipe-delimited String
+
+ Returns:
+ dict of each disease name and its related thresholds and ranges
+ """
+
+ results: dict[str, dict] = {}
+
+ # missing value = no details, return an empty dict
+ if detail_string == '.':
+ return results
+
+ # parse each disease's data separately
+ for disease_block in detail_string.split('|'):
+ # split up the formatted string
+ gene, moi, norm, inter, pathogenic = disease_block.split('__')
+
+ # try and detect the normal and intermediate ranges
+ normal_range: None | tuple[int, int] = None
+ if matchy := re.match(STR_RANGE, norm):
+ normal_range = (int(matchy.group('min')), int(matchy.group('max')))
+
+ inter_range: None | tuple[int, int] = None
+ if matchy := re.match(STR_RANGE, inter):
+ inter_range = (int(matchy.group('min')), int(matchy.group('max')))
+
+ # build the
+ results[gene] = {
+ 'moi': moi,
+ 'norm': normal_range,
+ 'inter': inter_range,
+ 'pathogenic': pathogenic,
+ }
+
+ return results
+
+
def organise_de_novo(info_dict: dict[str, Any], alt_depths: dict[str, int], ab_ratios: dict[str, float]) -> None:
"""
apply some late checking on de novo attributes
@@ -424,10 +483,65 @@ def organise_de_novo(info_dict: dict[str, Any], alt_depths: dict[str, int], ab_r
# if we detected any failing samples against these rules, fish them out
if to_pop:
- logger.info(f'Removing de novo status for {len(to_pop)} samples: {", ".join(to_pop)}')
+ logger.debug(f'Removing de novo status for {len(to_pop)} samples: {", ".join(to_pop)}')
info_dict['categorysampledenovo'] = [sam for sam in info_dict['categorysampledenovo'] if sam not in to_pop]
+def create_str_variant(
+ var: 'cyvcf2.Variant',
+ samples: list[str],
+) -> ShortTandemRepeat | None:
+ """Takes a variant row from an STR 'joint-call', and parses into a Variant object."""
+
+ ignored_categories = get_categories_translated('ignore_categories')
+ if 'categorybooleanstr' in ignored_categories:
+ return None
+
+ coordinates = Coordinates(chrom=var.CHROM.replace('chr', ''), pos=var.POS, ref='STR', alt=var.ID)
+
+ # parse the info dict, and shove in a recognisable category label
+ boolean_categories = ['categorybooleanstr']
+ info: dict[str, Any] = {x.lower(): y for x, y in var.INFO} | {'categorybooleanstr': 1}
+
+ if info.get('locus') in config_retrieve(['ValidateMOI', 'noisy_strs'], []):
+ return None
+
+ # shuffle the gene ID (ENSG) to match the location Hail classifiers put it
+ info['gene_id'] = info.pop('gene')
+
+ het_samples, hom_samples = get_non_ref_samples(variant=var, samples=samples)
+
+ # no samples with an STR call, reject the whole variant row
+ if not (het_samples or hom_samples):
+ return None
+
+ # get repeat counts for each sample with a variant call
+ vcf_repcn = dict(zip(samples, var.format('REPCN'), strict=True))
+
+ disease_details = dict(zip(samples, var.format('DISEASE_DETAILS'), strict=True))
+
+ # dictionary to record repeat counts for called variants - tuple of length 1 | 2
+ repeat_counts: dict[str, tuple[int, int] | tuple[int]] = {}
+ sample_disease_details: dict = {}
+ for sample_id in het_samples | hom_samples:
+ # strip out numpy.nan(float64) values, cast the real values as floats
+ repeat_counts[sample_id] = tuple(int(value) for value in vcf_repcn[sample_id] if ~isnan(value)) # type: ignore[assignment]
+ sample_disease_details[sample_id] = parse_str_disease_details(disease_details[sample_id])
+
+ return ShortTandemRepeat(
+ coordinates=coordinates,
+ locus=var.ID,
+ info=info,
+ het_samples=het_samples,
+ hom_samples=hom_samples,
+ boolean_categories=boolean_categories,
+ ignored_categories=ignored_categories,
+ support_categories=get_categories_translated(),
+ sample_repeats=repeat_counts,
+ sample_repeat_details=sample_disease_details,
+ )
+
+
def create_small_variant(
var: 'cyvcf2.Variant',
samples: list[str],
@@ -454,10 +568,8 @@ def create_small_variant(
),
)
- # optionally - ignore some categories from this analysis
- # keep it super flexible, lower case and a translated version
- ignored_categories = {word.lower() for word in config_retrieve(['ValidateMOI', 'ignore_categories'], [])}
- ignored_categories.update({translate_category(x) for x in ignored_categories})
+ # optionally - ignore some categories from this analysis. Super flexible, lower case and a translated version.
+ ignored_categories = get_categories_translated('ignore_categories')
# set the class attributes - skipping over categories we've chosen to ignore
boolean_categories = [
@@ -471,10 +583,6 @@ def create_small_variant(
if key.startswith('categorysample') and key.replace('categorysample', '') not in ignored_categories
]
- # the categories to be treated as support-only for this runtime - make it a set
- support_categories = {word.lower() for word in config_retrieve(['ValidateMOI', 'support_categories'], [])}
- support_categories.update({translate_category(x) for x in support_categories})
-
# overwrite with true booleans
for cat in boolean_categories:
info[cat] = info.get(cat, 0) == 1
@@ -522,7 +630,7 @@ def create_small_variant(
boolean_categories=boolean_categories,
sample_categories=sample_categories,
ignored_categories=ignored_categories,
- support_categories=support_categories,
+ support_categories=get_categories_translated(),
phased=phased,
alt_depths=alt_depths,
depths=depths,
@@ -595,8 +703,8 @@ def canonical_contigs_from_vcf(reader) -> set[str]:
def gather_gene_dict_from_contig(
contig: str,
- variant_source: 'cyvcf2.VCFReader',
- sv_source: 'cyvcf2.VCFReader | None' = None,
+ variant_sources: dict[str, 'cyvcf2.VCFReader'],
+ panelapp: PanelApp,
) -> GeneDict:
"""
takes a cyvcf2.VCFReader instance, and a specified chromosome
@@ -606,8 +714,8 @@ def gather_gene_dict_from_contig(
Args:
contig (): contig name from VCF header
- variant_source (): the VCF reader instance
- sv_source (): an optional list of SV VCFs
+ variant_sources (): dict mapping each variant type to a VCF reader instance
+ known types: small, sv, mito (chrM only), str
Returns:
A lookup in the form
@@ -631,36 +739,55 @@ def gather_gene_dict_from_contig(
contig_dict = defaultdict(list)
# iterate over all variants on this contig and store by unique key
- # if contig has no variants, prints an error and returns []
- for variant in variant_source(contig):
- if (small_variant := create_small_variant(var=variant, samples=variant_source.samples)) is None:
+ small_samples = variant_sources['small'].samples
+ for variant in variant_sources['small'](contig):
+ if (small_variant := create_small_variant(var=variant, samples=small_samples)) is None:
continue
if small_variant.coordinates.string_format in blacklist:
logger.info(f'Skipping blacklisted variant: {small_variant.coordinates.string_format}')
continue
-
- # update the variant count
contig_variants += 1
-
- # update the gene index dictionary
contig_dict[small_variant.info['gene_id']].append(small_variant)
# parse the SV VCF if provided, but not a necessary part of processing
- if sv_source:
+ if variant_sources.get('sv'):
+ sv_samples = variant_sources['sv'].samples
structural_variants = 0
- for variant in sv_source(contig):
- # create an abstract SV variant
- structural_variant = create_structural_variant(var=variant, samples=sv_source.samples)
-
- # update the variant count
+ for variant in variant_sources['sv'](contig):
+ structural_variant = create_structural_variant(var=variant, samples=sv_samples)
structural_variants += 1
-
- # update the gene index dictionary
contig_dict[structural_variant.info['gene_id']].append(structural_variant)
-
logger.info(f'Contig {contig} contained {structural_variants} SVs')
+ # parse STR VCF if provided
+ if variant_sources.get('str'):
+ # limit STRs to PanelApp green-evidence accepted repeat disorder genes
+ repeat_disorder_genes = panelapp.str_genes
+ str_samples = variant_sources['str'].samples
+ str_variants = 0
+ for variant in variant_sources['str'](contig):
+ if str_var := create_str_variant(var=variant, samples=str_samples):
+ # skip any which aren't accepted in PanelApp's repeat disorders panel
+ if str_var.info['gene_id'] not in repeat_disorder_genes:
+ continue
+
+ str_variants += 1
+ contig_dict[str_var.info['gene_id']].append(str_var)
+
+ logger.info(f'Contig {contig} contained {str_variants} STRs')
+
+ # special segment for mito data - if Mito is provided and chrM is being searched, provide variants
+ if contig == 'chrM' and variant_sources.get('mito'):
+ mito_samples = variant_sources['mito'].samples
+ mito_variants = 0
+ for variant in variant_sources['mito'](contig):
+ if mito_var := create_small_variant(var=variant, samples=mito_samples):
+ mito_variants += 1
+ contig_dict[mito_var.info['gene_id']].append(mito_var)
+
+ logger.info(f'Mito VCF contained {mito_variants} variants')
+
logger.info(f'Contig {contig} contained {contig_variants} variants, in {len(contig_dict)} genes')
return contig_dict
diff --git a/src/talos/validate_moi.py b/src/talos/validate_moi.py
index 87a68792..c62a7ef0 100644
--- a/src/talos/validate_moi.py
+++ b/src/talos/validate_moi.py
@@ -279,8 +279,7 @@ def count_families(pedigree: PedigreeParser) -> dict:
def prepare_results_shell(
results_meta: ResultMeta,
- small_samples: set[str],
- sv_samples: set[str],
+ source_samples: dict[str, set[str]],
pedigree: PedigreeParser,
panelapp: PanelApp,
) -> ResultData:
@@ -289,8 +288,7 @@ def prepare_results_shell(
Args:
results_meta (): metadata for the results
- small_samples (): samples in the Small VCF
- sv_samples (): samples in the SV VCFs
+ source_samples (): samples indexed by variant type
pedigree (): the Pedigree object, already reduced to samples in the callset
panelapp (): dictionary of gene data
@@ -328,8 +326,8 @@ def prepare_results_shell(
if panel_id in panelapp.metadata
},
solved=bool(participant.sample_id in solved_cases or participant.family_id in solved_cases),
- present_in_small=participant.sample_id in small_samples,
- present_in_sv=participant.sample_id in sv_samples,
+ present_in_small=participant.sample_id in source_samples['small'],
+ present_in_sv=participant.sample_id in source_samples.get('sv', set()),
),
)
@@ -339,8 +337,9 @@ def prepare_results_shell(
def cli_main():
parser = ArgumentParser(description='Startup commands for the MOI testing phase of Talos')
parser.add_argument('--labelled_vcf', help='Category-labelled VCF')
- parser.add_argument('--labelled_sv', help='Category-labelled SV VCF', default=None)
- parser.add_argument('--labelled_mito', help='Category-labelled Mito VCF', default=None)
+ parser.add_argument('--labelled_sv', help='Optional, Category-labelled SV VCF', default=None)
+ parser.add_argument('--labelled_mito', help='Optional, Category-labelled Mito VCF', default=None)
+ parser.add_argument('--str', help='Optional, VCF containing STR data', default=None)
parser.add_argument('--output', help='Prefix to write JSON results to', required=True)
parser.add_argument('--panelapp', help='QueryPanelApp JSON', required=True)
parser.add_argument('--pedigree', help='Path to PED file', required=True)
@@ -354,6 +353,7 @@ def cli_main():
pedigree=args.pedigree,
labelled_sv=args.labelled_sv,
labelled_mito=args.labelled_mito,
+ str_vcf=args.str,
previous=args.previous,
)
@@ -365,6 +365,7 @@ def main(
pedigree: str,
labelled_sv: str | None = None,
labelled_mito: str | None = None,
+ str_vcf: str | None = None,
previous: str | None = None,
):
"""
@@ -378,6 +379,7 @@ def main(
labelled_vcf (str): VCF output from Hail Labelling stage
labelled_sv (str | None): optional second VCF (SV)
labelled_mito (str | None): optional Mitochondrial VCF
+ str_vcf (str | None): optional STR 'joint-call' VCF
output (str): location to write output file
panelapp_path (str): location of PanelApp data JSON
pedigree (str): location of PED file
@@ -398,6 +400,7 @@ def main(
# initialise the optional MOI-stage exclusion logger; no-op when disabled in config
exclusion_logger = get_exclusion_logger()
+ # shove everything in a try-except to make sure the global logger is closed down
try:
panelapp: PanelApp = read_json_from_path(
panelapp_path,
@@ -413,26 +416,31 @@ def main(
result_list: list[ReportVariant] = []
- # collect all sample IDs from each VCF type
- small_vcf_samples: set[str] = set()
- sv_vcf_samples: set[str] = set()
-
- # open the small variant VCF using a cyvcf2 reader
+ # open the small variant VCF using a cyvcf2 reader - mandatory data source
vcf_opened = VCFReader(labelled_vcf)
- small_vcf_samples.update(set(vcf_opened.samples))
+ source_samples: dict[str, set[str]] = {'small': set(vcf_opened.samples)}
+ source_vcfs: dict[str, VCFReader] = {'small': vcf_opened}
# optional SV behaviour
- sv_opened = None
if labelled_sv:
sv_opened = VCFReader(labelled_sv)
- sv_vcf_samples = set(sv_opened.samples)
-
- all_samples = small_vcf_samples.union(sv_vcf_samples)
+ source_samples['sv'] = set(sv_opened.samples)
+ source_vcfs['sv'] = sv_opened
- mito_opened = None
+ # optional mito behaviour
if labelled_mito:
mito_opened = VCFReader(labelled_mito)
- all_samples |= set(mito_opened.samples)
+ source_vcfs['mito'] = mito_opened
+ source_samples['mito'] = set(mito_opened.samples)
+
+ # optional str behaviour
+ if str_vcf:
+ str_opened = VCFReader(str_vcf)
+ source_vcfs['str'] = str_opened
+ source_samples['str'] = set(str_opened.samples)
+
+ # collect all unique samples across the various sources
+ all_samples = set.union(*source_samples.values())
# parse the pedigree from the file
ped = PedigreeParser(pedigree)
@@ -454,21 +462,10 @@ def main(
# assemble {gene: [var1, var2, ..]}
contig_dict = gather_gene_dict_from_contig(
contig=contig,
- variant_source=vcf_opened,
- sv_source=sv_opened,
- )
-
- result_list.extend(
- apply_moi_to_variants(
- variant_dict=contig_dict,
- moi_lookup=moi_lookup,
- panelapp_data=panelapp.genes,
- pedigree=ped,
- ),
+ variant_sources=source_vcfs,
+ panelapp=panelapp,
)
- if mito_opened:
- contig_dict = gather_gene_dict_from_contig('chrM', variant_source=mito_opened)
result_list.extend(
apply_moi_to_variants(
variant_dict=contig_dict,
@@ -488,8 +485,7 @@ def main(
# create a shell to store results in, adds participant metadata
results_model = prepare_results_shell(
results_meta=results_meta,
- small_samples=small_vcf_samples,
- sv_samples=sv_vcf_samples,
+ source_samples=source_samples,
pedigree=ped,
panelapp=panelapp,
)
diff --git a/src/talos/version.py b/src/talos/version.py
index d1bb7002..77f1fe77 100644
--- a/src/talos/version.py
+++ b/src/talos/version.py
@@ -1 +1 @@
-__version__ = '11.0.3'
+__version__ = '11.1.0'
diff --git a/test/test_unified_panelapp_parser.py b/test/test_unified_panelapp_parser.py
index a6c7cd61..d2a3e4de 100644
--- a/test/test_unified_panelapp_parser.py
+++ b/test/test_unified_panelapp_parser.py
@@ -101,7 +101,6 @@ def test_match_participants_to_panels():
'genes': {},
'participants': {
'sam1': ParticipantHPOPanels(
- external_id='',
family_id='',
hpo_terms=[HpoTerm(id='HP:1', label='1'), HpoTerm(id='HP:2', label='2')],
panels={99, 1, 3, 5},
@@ -109,7 +108,6 @@ def test_match_participants_to_panels():
matched_phenotypes=set(),
),
'sam2': ParticipantHPOPanels(
- external_id='',
family_id='',
hpo_terms=[HpoTerm(id='HP:1', label='1')],
panels={99, 1, 3},
@@ -118,6 +116,8 @@ def test_match_participants_to_panels():
),
},
'version': CURRENT_VERSION,
+ 'str_genes': set(),
+ 'str_symbols': set(),
}
diff --git a/test/test_utils.py b/test/test_utils.py
index 74ab45a4..3caccb53 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -26,6 +26,11 @@
FIVE_EXPECTED = 5
+class FakePanelApp:
+ def __init__(self):
+ self.str_genes: set[str] = set()
+
+
def test_coord_sorting():
"""
check that coord sorting methods work
@@ -132,8 +137,7 @@ def test_gene_dict(two_trio_variants_vcf):
gene = ENSG00000075043
"""
reader = VCFReader(two_trio_variants_vcf)
- contig = 'chr20'
- var_dict = gather_gene_dict_from_contig(contig=contig, variant_source=reader)
+ var_dict = gather_gene_dict_from_contig(contig='chr20', variant_sources={'small': reader}, panelapp=FakePanelApp())
assert len(var_dict) == 1
assert 'ENSG00000075043' in var_dict
assert len(var_dict['ENSG00000075043']) == TWO_EXPECTED
@@ -163,7 +167,7 @@ def test_phased_dict(phased_vcf_path):
gene = ENSG00000075043
"""
reader = VCFReader(phased_vcf_path)
- var_dict = gather_gene_dict_from_contig(contig='chr20', variant_source=reader)
+ var_dict = gather_gene_dict_from_contig(contig='chr20', variant_sources={'small': reader}, panelapp=FakePanelApp())
assert len(var_dict) == ONE_EXPECTED
assert 'ENSG00000075043' in var_dict
assert len(var_dict['ENSG00000075043']) == TWO_EXPECTED
diff --git a/test/test_validate_methods.py b/test/test_validate_methods.py
index c13cb224..d4887ebc 100644
--- a/test/test_validate_methods.py
+++ b/test/test_validate_methods.py
@@ -49,10 +49,10 @@ def test_results_shell(pedigree_path: str):
'mother_2': {'external_id': 'mother_2', 'hpo_terms': []},
},
)
+ source_samples = {'small': {'male'}, 'sv': {'female'}}
shell = prepare_results_shell(
results_meta=ResultMeta(),
- small_samples={'male'},
- sv_samples={'female'},
+ source_samples=source_samples,
pedigree=PedigreeParser(pedigree_path=pedigree_path),
panelapp=panelapp,
)
diff --git a/uv.lock b/uv.lock
index 81e1331e..19c3270f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2320,6 +2320,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" },
]
+[[package]]
+name = "pysam"
+version = "0.24.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/9b/e856ad525ed0847810cca936ee551b8e0a709525fea34e0e0e723173f62d/pysam-0.24.0.tar.gz", hash = "sha256:db0f86c15532ef5dad263748324f45d9a639668e3497d8cabce54ef47a1a78d9", size = 5201690, upload-time = "2026-04-27T12:26:50.879Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/25/ed/5b642a89de9a4cf5ce21f70b4c2c2cd10720ef12cb6ef60b005ecf9dcb08/pysam-0.24.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d06a94e2886cb8affba77bc4fe337513f29387113d708d14609765aa8bb143d3", size = 6217780, upload-time = "2026-04-27T12:25:00.598Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/39/2c9aea082383c8d41d0d8b8f9907dc6273f8cee47a8c83d0c8bad02488b4/pysam-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d3260795e066f659ea94e6b7cac07727ef2877b874f69029b6a2e48551d72bd", size = 5946970, upload-time = "2026-04-27T12:25:02.783Z" },
+ { url = "https://files.pythonhosted.org/packages/12/0b/d9c1155257f2c2d0d8a33d15011964bf0c9ad927f85fa57f00965b0c1cfc/pysam-0.24.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b299f6128601356e25ca94040eef9dadb1e196aa2f36736c93948aaa821c4bc", size = 22114024, upload-time = "2026-04-27T12:25:04.779Z" },
+ { url = "https://files.pythonhosted.org/packages/21/48/92dddc8df65b576c9d30752650c89301b5222d4ac10187724796cedfd723/pysam-0.24.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a40440b16bbf67b696914a10acc241a0cd0fa02dcb14aee8e614eb2c5d8b37", size = 22675883, upload-time = "2026-04-27T12:25:07.582Z" },
+ { url = "https://files.pythonhosted.org/packages/07/ff/eee9caac42c255150cc7eae19afa71bd2bb8347fd9895196f7daae2ccd62/pysam-0.24.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8e4f5f1ebf3bdcad4c3b8f3a656ec2afdc44f0efc96ce8523858556e95d8ca9a", size = 22056750, upload-time = "2026-04-27T12:25:10.127Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/7f/da483a98b11690f3c4b24f4aa12b7e0d0d1e19da8b8ab45785c129240eae/pysam-0.24.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fcce1edc48a9d8610958296c0aaf3e28723d143113bcd9b7c13b5cd821300d2a", size = 22364034, upload-time = "2026-04-27T12:25:12.709Z" },
+ { url = "https://files.pythonhosted.org/packages/68/b3/1d586b4db6ff7220ac1df4b9588fae7c2622680ca51d53aa7cf32ee73604/pysam-0.24.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:48524b2132158ffeca7ca21c52490e92dca6687446faad95522db2354e376641", size = 8711987, upload-time = "2026-04-27T12:25:15.415Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/29/5fe6f53416cfc17cbe1440f5f55b655e5dcb64b67e18cae9b0a38366db0b/pysam-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:58e347406cec254bd68ac0f50da1e772273c3c891f65eab4b54641f2a89e8644", size = 8441919, upload-time = "2026-04-27T12:25:17.984Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/75/69379edfcd26c88f749e29b5d3aee9044fbd201c1a678371e7ccd9010937/pysam-0.24.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed1f202fe27f2bd677b3d210616890a8cbf043043ea89404ada3c121a5e9a20e", size = 25122944, upload-time = "2026-04-27T12:25:20.982Z" },
+ { url = "https://files.pythonhosted.org/packages/13/6d/2e2a80cda66b152ec143ceb1784e56d8abc6abeeeb65f8ef6aa52c731038/pysam-0.24.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8de0b19109da0c4aa8e44c6fef2fae97f0e616f3cdf5be0f1b74f7946b8de262", size = 25697041, upload-time = "2026-04-27T12:25:24.566Z" },
+ { url = "https://files.pythonhosted.org/packages/91/70/852f6917e9d7592482db2c43ccd91fa57158cf20ba07c31e9a477af84b9d/pysam-0.24.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d528fc09a44c85b7d4018ba8be543e1cc2e7a4d06eeb779a28ac34947e182f5a", size = 25069351, upload-time = "2026-04-27T12:25:27.617Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6b/0596765a59253958488fc7a8816df347d815ac3850a7a9cab785c1b1e184/pysam-0.24.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e9f20388c80dd0e4c09dfd7fe38adc9fdf771ec665e071e1b7350dc8da2d692", size = 25378507, upload-time = "2026-04-27T12:25:30.504Z" },
+]
+
[[package]]
name = "pyspark"
version = "3.5.3"
@@ -2762,6 +2782,9 @@ docs = [
{ name = "mkdocs-include-markdown-plugin" },
{ name = "mkdocs-material" },
]
+stripy = [
+ { name = "pysam" },
+]
test = [
{ name = "bump-my-version" },
{ name = "pre-commit" },
@@ -2792,6 +2815,7 @@ requires-dist = [
{ name = "pendulum", specifier = ">=3.1.0" },
{ name = "pre-commit", marker = "extra == 'test'" },
{ name = "pydantic", specifier = ">=2.5.2" },
+ { name = "pysam", marker = "extra == 'stripy'" },
{ name = "pytest", marker = "extra == 'test'" },
{ name = "pytest-httpx", marker = "extra == 'test'" },
{ name = "pytest-xdist", marker = "extra == 'test'", specifier = ">=3.6.0" },
@@ -2801,7 +2825,7 @@ requires-dist = [
{ name = "tenacity", specifier = ">=9.0.0" },
{ name = "toml", specifier = "==0.10.2" },
]
-provides-extras = ["cpg", "test", "docs"]
+provides-extras = ["cpg", "test", "docs", "stripy"]
[[package]]
name = "tenacity"