diff --git a/docs/inspection-user-guide.md b/docs/inspection-user-guide.md index 9e381a0f..55933319 100644 --- a/docs/inspection-user-guide.md +++ b/docs/inspection-user-guide.md @@ -10,6 +10,30 @@ The inspector beneath the structure input is shared across tabs. Selecting a res On a loaded screen with no selected residue, the inspector is reduced to a single review action. After selecting a residue, navigation controls become available. Representation buttons and the ligand, DNA, RNA, spin and rock switches remain visible beneath the viewer, so users can discover them without opening a settings menu. +## Why inspect this residue? + +Selecting a residue opens a compact evidence explanation in the shared +inspector. RamplotR keeps the underlying signals separate instead of combining +them into an opaque quality score. + +Depending on the data available for that residue, the explanation can surface: + +- Rama8000 Allowed or Outlier status; +- the native RamplotR `Not allowed` region or proximity to a native contour; +- low or very low pLDDT; +- the combination of very high pLDDT with a Rama8000 outlier; +- cis or twisted peptide geometry; +- an attached official wwPDB Ramachandran or rotamer outlier; +- official local clashes or covalent-geometry outliers. + +Each item identifies its source and explains why it may be worth examining. +If none of the available evidence is unusual, the panel says so explicitly +rather than inventing a warning. + +This is an **inspection aid**, not a residue-quality score. A Rama8000 +classification, prediction confidence, local geometry and an official wwPDB +annotation remain scientifically distinct observations. + ## Compact multi-chain sequence map The overview directly under the Ramachandran plot shows every selected diff --git a/shinyRam/R/inspection.R b/shinyRam/R/inspection.R index 632deeb2..4f1480aa 100644 --- a/shinyRam/R/inspection.R +++ b/shinyRam/R/inspection.R @@ -1,6 +1,121 @@ # Pure residue inspection and pairwise comparison helpers. # Nothing in this file changes torsion extraction or scientific classification. +ram_residue_evidence <- function(row, boundary_margin = 2) { + if (!is.data.frame(row) || nrow(row) != 1L) + stop("Residue evidence expects exactly one residue row.") + evidence <- list() + add <- function(level, title, detail, source) { + evidence[[length(evidence)+1L]] <<- data.frame( + level=level,title=title,detail=detail,source=source, + stringsAsFactors=FALSE + ) + } + value <- function(name, default=NA) { + if (!name %in% names(row) || !length(row[[name]])) return(default) + row[[name]][[1L]] + } + phi <- suppressWarnings(as.numeric(value("phi",NA_real_))) + psi <- suppressWarnings(as.numeric(value("psi",NA_real_))) + native <- as.character(value("region",NA_character_)) + standard <- as.character(value("rama8000_region",NA_character_)) + plddt <- suppressWarnings(as.numeric(value("plddt",NA_real_))) + + if (!is.finite(phi) || !is.finite(psi)) { + add("warning","Backbone angles unavailable", + "Phi/psi cannot be evaluated for this residue, commonly because it is terminal or required backbone atoms are missing.", + "Coordinates") + } else { + if (!is.na(standard) && standard == "Outlier") { + add("high","Rama8000 backbone outlier", + "The current six-class standard validation places this residue outside the allowed Rama8000 region.", + "Rama8000") + } else if (!is.na(standard) && standard == "Allowed") { + add("info","Rama8000 allowed region", + "The residue is outside the favored Rama8000 region but remains within the allowed region.", + "Rama8000") + } + if (!is.na(native) && native == "Not allowed") { + add("warning","Unusual RamplotR density position", + "The selected RamplotR reference distribution labels this position Not allowed. This is separate from Rama8000 outlier status.", + "RamplotR density") + } + density <- suppressWarnings(as.numeric(value("density",NA_real_))) + if (is.finite(density) && + any(abs(density-c(85,98,99.95)) <= boundary_margin)) { + add("info","Near a RamplotR contour boundary", + sprintf("Density percentile %.1f lies within %.1f points of a display-classification contour.", + density,boundary_margin), + "RamplotR density") + } + } + + if (is.finite(plddt)) { + if (plddt < 50) { + add("warning","Very low prediction confidence", + sprintf("pLDDT %.1f indicates very low local model confidence; local geometry should be interpreted cautiously.",plddt), + "Prediction confidence") + } else if (plddt < 70) { + add("info","Low prediction confidence", + sprintf("pLDDT %.1f indicates low local model confidence.",plddt), + "Prediction confidence") + } + if (plddt >= 90 && !is.na(standard) && standard == "Outlier") { + add("high","High-confidence prediction with unusual backbone geometry", + sprintf("pLDDT %.1f is very high while Rama8000 classifies the backbone as an outlier; inspect the local structural context.",plddt), + "Combined evidence") + } + } + + omega_status <- as.character(value("omega_status",NA_character_)) + omega <- suppressWarnings(as.numeric(value("omega",NA_real_))) + if (!is.na(omega_status) && omega_status == "Twisted") { + add("high","Twisted peptide bond", + if (is.finite(omega)) sprintf("Peptide omega is %.1f degrees.",omega) + else "The preceding peptide bond is classified as twisted.", + "Local geometry") + } else if (!is.na(omega_status) && omega_status == "Cis") { + add("info","Cis peptide bond", + if (is.finite(omega)) sprintf("Peptide omega is %.1f degrees.",omega) + else "The preceding peptide bond is cis.", + "Local geometry") + } + + official_rama <- tolower(as.character(value("wwpdb_rama",NA_character_))) + if (!is.na(official_rama) && official_rama == "outlier") + add("high","Official wwPDB Ramachandran outlier", + "The attached official validation report identifies this residue as a Ramachandran outlier.", + "wwPDB") + rotamer <- tolower(as.character(value("wwpdb_rotamer",NA_character_))) + if (!is.na(rotamer) && rotamer %in% c("outlier","outliers")) + add("warning","Official wwPDB rotamer outlier", + "The attached official validation report identifies the side-chain rotamer as an outlier.", + "wwPDB") + clashes <- suppressWarnings(as.numeric(value("wwpdb_clashes",NA_real_))) + if (is.finite(clashes) && clashes > 0) + add("warning","Official wwPDB local clash", + sprintf("The attached report records %d local clash%s for this residue.", + as.integer(clashes),if (clashes==1) "" else "es"), + "wwPDB") + bond <- suppressWarnings(as.numeric(value("wwpdb_bond_outliers",NA_real_))) + angle <- suppressWarnings(as.numeric(value("wwpdb_angle_outliers",NA_real_))) + if ((is.finite(bond) && bond > 0) || (is.finite(angle) && angle > 0)) + add("warning","Official covalent-geometry outlier", + sprintf("The attached report records %d bond-length and %d bond-angle outlier%s.", + ifelse(is.finite(bond),as.integer(bond),0L), + ifelse(is.finite(angle),as.integer(angle),0L), + ifelse((ifelse(is.finite(bond),bond,0)+ifelse(is.finite(angle),angle,0))==1, + "","s")), + "wwPDB") + + if (!length(evidence)) + return(data.frame(level=character(),title=character(), + detail=character(),source=character(),stringsAsFactors=FALSE)) + out <- do.call(rbind,evidence) + priority <- match(out$level,c("high","warning","info")) + out[order(priority,out$source,out$title),,drop=FALSE] +} + ram_review_queue <- function(data, boundary_margin = 2) { if (!nrow(data)) return(data) missing <- is.na(data$phi) | is.na(data$psi) | is.na(data$region) diff --git a/shinyRam/app.R b/shinyRam/app.R index 6c44bc5e..655ca617 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -2758,6 +2758,16 @@ server <- function(input, output, session) { if (is.null(row)) return(tags$span(class = "ram-inspector-empty", "Select a residue in the plot, table, sequence or 3D model. Review controls navigate to the next issue.")) angle <- function(value) if (is.finite(value)) sprintf("%.1f°", value) else "Unavailable" + evidence <- ram_residue_evidence(row) + evidence_item <- function(item) { + level <- as.character(item$level[[1L]]) + tags$div(class=paste("ram-evidence-item",paste0("ram-evidence-",level)), + tags$div(class="ram-evidence-item-head", + tags$strong(as.character(item$title[[1L]])), + tags$span(as.character(item$source[[1L]]))), + tags$p(as.character(item$detail[[1L]])) + ) + } tags$div(class = "ram-inspector-data", tags$div(tags$strong(sprintf("%s %d%s · %s", if (nzchar(row$chain[[1L]])) paste("Chain", row$chain[[1L]]) else "Chain", @@ -2797,9 +2807,25 @@ server <- function(input, output, session) { row$confidence_category[[1L]])), if ("plddt" %in% names(row) && is.finite(row$plddt[[1L]]) && row$plddt[[1L]] >= 90 && - identical(as.character(row$region[[1L]]), "Not allowed")) + identical(as.character(row$rama8000_region[[1L]]), "Outlier")) tags$span(class = "ram-inspector-warning", - "High model confidence, unusual backbone geometry; inspect locally.") + "High model confidence with a Rama8000 outlier; inspect locally.") + ), + tags$details(class="ram-evidence-panel", + open=if(nrow(evidence)>0L) "open" else NULL, + tags$summary( + if(nrow(evidence)) + sprintf("Why inspect this residue? · %d signal%s", + nrow(evidence),if(nrow(evidence)==1L) "" else "s") + else "Why inspect this residue? · no obvious issue" + ), + if(nrow(evidence)) + tags$div(class="ram-evidence-list", + lapply(seq_len(nrow(evidence)),function(i) + evidence_item(evidence[i,,drop=FALSE]))) + else + tags$p(class="ram-evidence-none", + "No unusual signal is present in the currently available backbone, prediction-confidence or attached official-validation evidence.") ) ) }) diff --git a/shinyRam/www/styles.css b/shinyRam/www/styles.css index 1113faa1..3b2b580b 100644 --- a/shinyRam/www/styles.css +++ b/shinyRam/www/styles.css @@ -1293,3 +1293,31 @@ body > .container-fluid { max-width: none; padding: 0; } .ram-counterpart-id { flex-direction:row; gap:7px; align-items:baseline; } .ram-counterpart-actions { width:100%; flex-wrap:wrap; } } + +/* Evidence explanation for the selected residue. Signals stay separate rather + than being collapsed into a composite quality score. */ +.ram-evidence-panel { + margin-top:8px; border-top:1px solid rgba(42,91,96,.14); padding-top:6px; +} +.ram-evidence-panel > summary { + cursor:pointer; color:#245a61; font-size:10px; font-weight:750; + list-style-position:outside; +} +.ram-evidence-list { display:grid; gap:5px; margin-top:6px; } +.ram-evidence-item { + border:1px solid #dce8e6; border-left-width:3px; + border-radius:6px; padding:6px 8px; background:#fbfdfc; +} +.ram-evidence-item-head { + display:flex; align-items:baseline; justify-content:space-between; + gap:8px; flex-wrap:wrap; +} +.ram-evidence-item-head strong { color:#294f55; font-size:10px; } +.ram-evidence-item-head span { + color:#75898d; font-size:8px; text-transform:uppercase; letter-spacing:.04em; +} +.ram-evidence-item p { margin:2px 0 0; color:#5b7379; font-size:9px; line-height:1.4; } +.ram-evidence-high { border-left-color:#b7473d; background:#fff8f6; } +.ram-evidence-warning { border-left-color:#c88a31; background:#fffaf0; } +.ram-evidence-info { border-left-color:#4f8f88; } +.ram-evidence-none { margin:6px 0 0; color:#687f84; font-size:9px; } diff --git a/tests/inspection.R b/tests/inspection.R index d5c8b863..3070d5f7 100644 --- a/tests/inspection.R +++ b/tests/inspection.R @@ -112,4 +112,44 @@ assert(isTRUE(all.equal(seq_a$known_fraction,2/3)), assert(identical(ram_chain_query_sequence(seq_fixture,"B")$sequence,"V"), "Chain-specific counterpart sequence extraction changed.") +evidence_row <- data.frame( + chain="A",resi=10L,insertion_code="",resn="ALA", + phi=-120,psi=95,region="Not allowed",density=98.4, + rama8000_region="Outlier",rama8000_group="general", + rama8000_score=0.0001,plddt=96,confidence_category="Very high", + omega=72,omega_status="Twisted", + wwpdb_rama="outlier",wwpdb_rotamer="outlier", + wwpdb_clashes=2,wwpdb_bond_outliers=1,wwpdb_angle_outliers=0, + stringsAsFactors=FALSE +) +ev <- ram_residue_evidence(evidence_row) +assert(all(c( + "Rama8000 backbone outlier", + "Unusual RamplotR density position", + "High-confidence prediction with unusual backbone geometry", + "Twisted peptide bond", + "Official wwPDB Ramachandran outlier", + "Official wwPDB rotamer outlier", + "Official wwPDB local clash", + "Official covalent-geometry outlier" +) %in% ev$title), "Residue evidence should preserve independent warning sources.") +assert(ev$title[[1L]] %in% c( + "High-confidence prediction with unusual backbone geometry", + "Official wwPDB Ramachandran outlier","Twisted peptide bond"), + "High-severity residue evidence should sort before informational notes.") +quiet_row <- evidence_row +quiet_row$region <- "Favoured"; quiet_row$density <- 40 +quiet_row$rama8000_region <- "Favored"; quiet_row$plddt <- 95 +quiet_row$omega <- 179; quiet_row$omega_status <- "Trans" +quiet_row$wwpdb_rama <- "favored"; quiet_row$wwpdb_rotamer <- "favored" +quiet_row$wwpdb_clashes <- 0; quiet_row$wwpdb_bond_outliers <- 0 +quiet_row$wwpdb_angle_outliers <- 0 +assert(nrow(ram_residue_evidence(quiet_row))==0L, + "Unremarkable residues should not receive artificial warnings.") +missing_row <- quiet_row +missing_row$phi <- NA_real_ +missing_ev <- ram_residue_evidence(missing_row) +assert("Backbone angles unavailable" %in% missing_ev$title, + "Missing torsions should be explained explicitly.") + message("Inspection, alignment and structural model unit tests passed") diff --git a/tests/ui-browser.cjs b/tests/ui-browser.cjs index 75722a83..fb86f708 100644 --- a/tests/ui-browser.cjs +++ b/tests/ui-browser.cjs @@ -360,6 +360,13 @@ const assert = require("node:assert/strict"); !document.querySelector(".ram-global-inspector").classList.contains("is-empty") && getComputedStyle(document.getElementById("prevReview")).display !== "none", {timeout:10000}); + const evidencePanel = await page.$eval("#selectedResidueInfo .ram-evidence-panel", + node => ({ + summary: node.querySelector("summary")?.textContent?.trim() || "", + items: node.querySelectorAll(".ram-evidence-item").length + })); + assert.ok(evidencePanel.summary.startsWith("Why inspect this residue?"), + "Selected residues should explain why they merit inspection without a composite score."); await page.screenshot({ path: "benchmarks/output/ui-preview/desktop-residue-zoom.png", fullPage: true