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
1 change: 1 addition & 0 deletions rust/bioscript-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ include!("package.rs");
include!("report_review.rs");
include!("report_execution.rs");
include!("report_observations.rs");
include!("report_observation_json.rs");
include!("report_findings.rs");
include!("report_matching.rs");
include!("report_output.rs");
Expand Down
60 changes: 54 additions & 6 deletions rust/bioscript-cli/src/report_findings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,22 +273,70 @@ mod report_observations_tests {
);
}

#[test]
fn deletion_copy_number_calls_are_normalized_from_insertion_deletion_tokens() {
assert_eq!(
normalize_app_genotype(
"DI",
"TTATAA",
"<DEL:6>",
Some(bioscript_core::VariantKind::Deletion),
"22",
None
),
("0/1".to_owned(), "het".to_owned())
);
}

#[test]
fn cram_long_deletion_copy_number_calls_are_displayed_as_insertion_deletion_tokens() {
let mut row = BTreeMap::new();
row.insert("backend".to_owned(), "cram".to_owned());
let manifest = bioscript_schema::VariantManifest {
path: std::path::PathBuf::new(),
name: "apol1_g2".to_owned(),
tags: Vec::new(),
spec: bioscript_core::VariantSpec {
reference: Some("TTATAA".to_owned()),
alternate: Some("<DEL:6>".to_owned()),
kind: Some(bioscript_core::VariantKind::Deletion),
..bioscript_core::VariantSpec::default()
},
};

assert_eq!(
deletion_copy_number_display(&row, &manifest, Some(53), Some(0)).as_deref(),
Some("II")
);
assert_eq!(
normalize_app_genotype(
"II",
"TTATAA",
"<DEL:6>",
Some(bioscript_core::VariantKind::Deletion),
"22",
None
),
("0/0".to_owned(), "hom_ref".to_owned())
);
}

#[test]
fn single_allele_sex_chromosome_calls_are_treated_as_hemizygous() {
assert_eq!(
normalize_app_genotype("G", "C", "G", "X", None),
normalize_app_genotype("G", "C", "G", None, "X", None),
("1".to_owned(), "hem_alt".to_owned())
);
assert_eq!(
normalize_app_genotype("C", "C", "G", "chrX", None),
normalize_app_genotype("C", "C", "G", None, "chrX", None),
("0".to_owned(), "hem_ref".to_owned())
);
assert_eq!(
normalize_app_genotype("G", "C", "G", "1", None),
normalize_app_genotype("G", "C", "G", None, "1", None),
("G".to_owned(), "unknown".to_owned())
);
assert_eq!(
normalize_app_genotype("GG", "C", "G", "X", None),
normalize_app_genotype("GG", "C", "G", None, "X", None),
("1/1".to_owned(), "hom_alt".to_owned())
);
}
Expand All @@ -302,11 +350,11 @@ mod report_observations_tests {
evidence: vec!["called_y_snps=1200".to_owned()],
};
assert_eq!(
normalize_app_genotype("GG", "C", "G", "X", Some(&inferred_sex)),
normalize_app_genotype("GG", "C", "G", None, "X", Some(&inferred_sex)),
("1".to_owned(), "hem_alt".to_owned())
);
assert_eq!(
normalize_app_genotype("CC", "C", "G", "chrX", Some(&inferred_sex)),
normalize_app_genotype("CC", "C", "G", None, "chrX", Some(&inferred_sex)),
("0".to_owned(), "hem_ref".to_owned())
);
}
Expand Down
54 changes: 52 additions & 2 deletions rust/bioscript-cli/src/report_html_analysis.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
fn render_analysis_tables(
out: &mut String,
analyses: &[serde_json::Value],
observations: &[serde_json::Value],
show_participant_id: bool,
) {
if analyses.is_empty() {
Expand All @@ -10,6 +11,7 @@ fn render_analysis_tables(
for (index, analysis) in analyses.iter().enumerate() {
let table_id = format!("analysis-table-{index}");
let title = analysis_title(analysis);
let weak_indel_dependency = analysis_depends_on_weak_observation(analysis, observations);
let row_count = analysis
.get("rows")
.and_then(serde_json::Value::as_array)
Expand Down Expand Up @@ -37,6 +39,7 @@ fn render_analysis_tables(
out.push_str("<h4>Results</h4>");
render_analysis_key_values(out, analysis, &rows[0], &headers);
render_analysis_notes(out, &notes);
render_weak_indel_analysis_note(out, weak_indel_dependency);
out.push_str("</div></details>");
continue;
}
Expand All @@ -57,6 +60,7 @@ fn render_analysis_tables(
}
render_table_end(out);
render_analysis_notes(out, &notes);
render_weak_indel_analysis_note(out, weak_indel_dependency);
out.push_str("</div></details>");
}
}
Expand Down Expand Up @@ -182,7 +186,7 @@ fn render_analysis_value(key: &str, value: &str) -> String {
html_escape(value)
);
}
if key.ends_with("_status") || key.ends_with("_outcome") {
if is_analysis_badge_key(key) {
format!(
"<span class=\"analysis-badge {}\">{}</span>",
analysis_badge_class(value),
Expand All @@ -193,6 +197,10 @@ fn render_analysis_value(key: &str, value: &str) -> String {
}
}

fn is_analysis_badge_key(key: &str) -> bool {
key.ends_with("_status") || key.ends_with("_outcome")
}

fn analysis_badge_class(value: &str) -> &'static str {
match value {
"normal" | "reference" => "analysis-badge-normal",
Expand All @@ -214,6 +222,49 @@ fn render_analysis_notes(out: &mut String, notes: &[String]) {
out.push_str("</div>");
}

fn render_weak_indel_analysis_note(out: &mut String, weak_indel_dependency: bool) {
if !weak_indel_dependency {
return;
}
out.push_str("<div class=\"analysis-notes\"><h4>Notes</h4><p>* Result depends on a weak indel match from a consumer genotype file.</p></div>");
}

fn analysis_depends_on_weak_observation(
analysis: &serde_json::Value,
observations: &[serde_json::Value],
) -> bool {
let weak_paths = observations
.iter()
.filter(|observation| analysis_observation_is_weak_indel_match(observation))
.filter_map(|observation| {
observation
.get("variant_path")
.and_then(serde_json::Value::as_str)
})
.collect::<Vec<_>>();
if weak_paths.is_empty() {
return false;
}
analysis
.get("derived_from")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.any(|derived| {
weak_paths
.iter()
.any(|path| path.ends_with(derived) || derived.ends_with(path))
})
}

fn analysis_observation_is_weak_indel_match(observation: &serde_json::Value) -> bool {
observation
.get("match_quality")
.and_then(serde_json::Value::as_str)
== Some("weak")
}

fn render_analysis_logic(out: &mut String, analysis: &serde_json::Value) {
let Some(logic) = analysis.get("logic") else {
return;
Expand Down Expand Up @@ -249,4 +300,3 @@ fn render_analysis_logic(out: &mut String, analysis: &serde_json::Value) {
}
out.push_str("</div>");
}

4 changes: 4 additions & 0 deletions rust/bioscript-cli/src/report_html_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ fn is_debug_column(header: &str) -> bool {
| "genotype_quality"
| "evidence_type"
| "evidence_raw"
| "match_quality"
| "match_notes"
| "assay_id"
| "assay_version"
| "variant_key"
Expand Down Expand Up @@ -69,6 +71,8 @@ fn table_header_label(header: &str) -> String {
"evidence_type" => "Evidence Type".to_owned(),
"source" => "Source".to_owned(),
"evidence_raw" => "Evidence Raw".to_owned(),
"match_quality" => "Match Quality".to_owned(),
"match_notes" => "Match Notes".to_owned(),
"facets" => "Facets".to_owned(),
"assay_id" => "Assay ID".to_owned(),
"assay_version" => "Assay Version".to_owned(),
Expand Down
24 changes: 23 additions & 1 deletion rust/bioscript-cli/src/report_html_observations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ fn render_observation_table(
"allele_balance",
"evidence_type",
"evidence_raw",
"match_quality",
"match_notes",
"facets",
"assay_id",
"assay_version",
Expand All @@ -41,7 +43,12 @@ fn render_observation_table(
let show_facets = observations
.iter()
.any(|observation| !json_field_as_tsv(observation.get("facets")).is_empty());
let show_match_quality = observations.iter().any(|observation| {
!json_field_as_tsv(observation.get("match_quality")).is_empty()
|| !json_field_as_tsv(observation.get("match_notes")).is_empty()
});
let show_imputed_reference_note = observations.iter().any(observation_is_imputed_vcf_reference);
let show_weak_indel_note = observations.iter().any(observation_is_weak_indel_match);
let headers = all_headers
.iter()
.copied()
Expand All @@ -50,6 +57,9 @@ fn render_observation_table(
show_counts || !matches!(*header, "ref_count" | "alt_count" | "depth" | "allele_balance")
})
.filter(|header| show_genotype_quality || *header != "genotype_quality")
.filter(|header| {
show_match_quality || !matches!(*header, "match_quality" | "match_notes")
})
.filter(|header| show_facets || *header != "facets")
.collect::<Vec<_>>();
render_table_start(out, "observations-table", &headers);
Expand All @@ -70,6 +80,9 @@ fn render_observation_table(
if show_imputed_reference_note {
out.push_str("<p class=\"muted observation-note\">* In variant-only VCF inputs, absent queried variant rows are shown as imputed reference genotypes. This is usually appropriate for variant-only VCFs, but it may be wrong if the VCF omits loci for another reason.</p>");
}
if show_weak_indel_note {
out.push_str("<p class=\"muted observation-note\">* Indel calls from consumer genotype files are weak matches: the file reports an insertion/deletion token at the marker, but does not provide sequence-resolved evidence for the exact deletion allele.</p>");
}
}

fn observation_filter_group(observation: &serde_json::Value) -> &'static str {
Expand Down Expand Up @@ -131,7 +144,9 @@ fn render_observation_cell(out: &mut String, observation: &serde_json::Value, he
let cell_class = table_column_class(header);
if header == "outcome" {
let mut value = json_field_as_tsv(observation.get(header));
if value == "reference" && observation_is_imputed_vcf_reference(observation) {
if (value == "reference" && observation_is_imputed_vcf_reference(observation))
|| (value == "variant" && observation_is_weak_indel_match(observation))
{
value.push('*');
}
let _ = write!(out, "<td class=\"{}\">{}</td>", cell_class, html_escape(&value));
Expand Down Expand Up @@ -205,6 +220,13 @@ fn observation_is_imputed_vcf_reference(observation: &serde_json::Value) -> bool
})
}

fn observation_is_weak_indel_match(observation: &serde_json::Value) -> bool {
observation
.get("match_quality")
.and_then(serde_json::Value::as_str)
== Some("weak")
}

fn observation_ref_alt(observation: &serde_json::Value) -> String {
let ref_allele = observation
.get("ref")
Expand Down
Loading
Loading