diff --git a/.github/workflows/scientific-tests.yml b/.github/workflows/scientific-tests.yml index 5459941c..83d8afe5 100644 --- a/.github/workflows/scientific-tests.yml +++ b/.github/workflows/scientific-tests.yml @@ -59,8 +59,10 @@ jobs: Rscript tests/ensemble.R Rscript tests/batch.R shell: bash - - name: Verify AlphaFold and ESMFold confidence formats - run: Rscript tests/predictions.R + - name: Verify AlphaFold, ESMFold and experimental counterpart formats + run: | + Rscript tests/predictions.R + Rscript tests/counterparts.R shell: bash - name: Verify residue review, sequence, pairwise alignment and model selection run: Rscript tests/inspection.R diff --git a/README.md b/README.md index 56e3ba5e..96ad4b25 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ RamplotR is an open-source R Shiny application that brings backbone geometry, a | --- | --- | | **Explore a structure** | Interactive φ/ψ plots with several reference-density datasets, native RamplotR density regions and a parallel six-class Rama8000 standard validation that matches the current cctbx/Phenix categories. | | **Inspect residues in context** | Synchronized Ramachandran plot, searchable residue table, all-chain sequence navigator and NGL 3D viewer. The issue queue distinguishes Rama8000 outliers from native RamplotR `Not allowed` regions and missing angles. | -| **Work with predicted models** | Import AlphaFold DB models or upload AlphaFold 2/3, ColabFold and ESMFold structures. Examine pLDDT/PAE and analyse multiple AF2/ColabFold or ESMFold seeds as a prediction ensemble with residue-level φ/ψ, Rama8000 and confidence agreement. | +| **Work with predicted models** | Import AlphaFold DB models or upload AlphaFold 2/3, ColabFold and ESMFold structures. Examine pLDDT/PAE, analyse prediction ensembles, and discover experimentally determined PDBe counterparts for direct residue-level comparison. | | **Examine structural geometry** | Explore peptide ω, side-chain χ1 and descriptive Cβ measurements. Optionally attach the matching deposited structure's official wwPDB validation report for independent rotamer, clash and geometry annotations. | | **Inspect experimental evidence** | Overlay a local CCP4/MRC cryo-EM map in the 3D viewer as a qualitative aid, without uploading the map to a separate service. | | **Compare models** | Sequence-align chains from two structures; use the Conformational Change Explorer to navigate residue-level wrapped φ/ψ displacement, then inspect paired residues in linked 2D/3D views alongside native and Rama8000 category changes. | diff --git a/docs/experimental-counterparts.md b/docs/experimental-counterparts.md new file mode 100644 index 00000000..757d67e2 --- /dev/null +++ b/docs/experimental-counterparts.md @@ -0,0 +1,68 @@ +# Experimental counterparts for predicted structures + +RamplotR can discover experimentally determined PDB structures for a predicted +protein and load a selected structure directly into the pairwise comparison +workflow. + +## Data source + +Counterpart discovery uses the 3D-Beacons Hub API with a UniProt accession. +RamplotR only presents records whose provider is **PDBe** and whose model +category is explicitly **EXPERIMENTALLY DETERMINED**. + +This distinction is deliberate: template-based models, AlphaFold models, +conformational ensembles and records from other providers are not relabelled as +experimental evidence. + +## Workflow + +For an AlphaFold DB model, RamplotR already knows the UniProt accession and +performs the lookup automatically after the model is loaded. + +For an uploaded AlphaFold 2 / ColabFold, ESMFold or other predicted structure, +open the prediction-confidence panel, enter the matching UniProt accession and +choose **Find experimental structures**. + +Returned structures are ranked by: + +1. mapped UniProt sequence coverage, highest first; +2. experimental resolution, lowest first when available. + +Resolution can legitimately be unavailable for some experimental methods, such +as NMR; these structures remain in the result set. + +Select one hit and choose **Compare selected structure**. RamplotR loads the PDB +entry into the existing Compare tab, where chain selection, sequence alignment, +wrapped delta-phi/delta-psi, the Conformational Change Explorer and linked +paired 3D inspection remain available. + +## Interpretation + +A structure mapped to the same UniProt accession is a useful experimental +counterpart, but it is not automatically an equivalent biological state. +Deposited structures may differ in: + +- construct boundaries; +- mutations or engineered residues; +- ligand or cofactor state; +- oligomeric state; +- experimental conditions; +- unresolved segments; +- post-translational modifications; +- conformational state. + +RamplotR therefore uses the lookup as a discovery/navigation step. Scientific +interpretation should still inspect sequence alignment, coverage, structure +metadata and the local residue context. + +## Privacy and networking + +Local uploaded coordinates are **not** sent to 3D-Beacons. The lookup sends only +the UniProt accession supplied by the user. Choosing a counterpart then loads +that public PDB entry through RamplotR's existing PDB input workflow. + +## Reproducibility + +The parsing and filtering logic is covered by `tests/counterparts.R` using a +mock 3D-Beacons response. Browser workflows do not depend on the live service, +so external downtime does not make RamplotR's core CI flaky. diff --git a/docs/prediction-confidence.md b/docs/prediction-confidence.md index 5f4129c1..38cd8cb8 100644 --- a/docs/prediction-confidence.md +++ b/docs/prediction-confidence.md @@ -58,6 +58,22 @@ Confidence is attached to a specific prediction model, not automatically transferred to unrelated models in an NMR ensemble. For additional models choose the model and supply compatible confidence data separately. +## Experimental counterpart discovery + +For a loaded prediction, the confidence panel also offers an **Experimental +counterparts** workflow. AlphaFold DB inputs retain their UniProt accession and +query 3D-Beacons automatically. For uploaded prediction models, provide the +matching UniProt accession manually. + +Only experimentally determined **PDBe** records are shown. Selecting one loads +that PDB entry directly into RamplotR's Compare tab for sequence-aligned +residue-level phi/psi and 3D inspection. A shared UniProt accession is useful +evidence that the structures represent the same protein, but it does not imply +the same construct, ligand state, oligomeric state or conformation. + +See [Experimental counterparts for predicted structures](experimental-counterparts.md) +for details and limitations. + ## Interpretation and provenance pLDDT is a prediction of local correctness, while PAE measures expected diff --git a/shinyRam/R/counterparts.R b/shinyRam/R/counterparts.R new file mode 100644 index 00000000..a34e3e9f --- /dev/null +++ b/shinyRam/R/counterparts.R @@ -0,0 +1,126 @@ +# Experimental counterpart discovery for predicted structures. +# +# 3D-Beacons provides a UniProt-centred index of experimental and predicted +# structure models. RamplotR only treats PDBe records explicitly labelled +# EXPERIMENTALLY DETERMINED as experimental counterparts. + +ram_uniprot_accession <- function(accession) { + value <- toupper(trimws(as.character(accession))) + if(length(value)!=1L || is.na(value) || + !grepl("^[A-Z0-9]{6,12}(?:-[0-9]+)?$",value)) + stop("Enter a valid UniProt accession.") + value +} + +ram_3dbeacons_summary_url <- function(accession) { + accession <- ram_uniprot_accession(accession) + paste0( + "https://www.ebi.ac.uk/pdbe/pdbe-kb/3dbeacons/api/uniprot/summary/", + utils::URLencode(accession,reserved=TRUE),".json" + ) +} + +ram_3dbeacons_fetch <- function(url) { + if(!requireNamespace("jsonlite",quietly=TRUE)) + stop("Install jsonlite to query experimental counterparts.") + jsonlite::fromJSON(url,simplifyVector=FALSE) +} + +ram_counterpart_scalar <- function(value, default=NA_character_) { + if(is.null(value)) return(default) + flat <- unlist(value,use.names=FALSE) + if(!length(flat) || is.na(flat[[1L]])) return(default) + as.character(flat[[1L]]) +} + +ram_counterpart_numeric <- function(value) { + out <- suppressWarnings(as.numeric(ram_counterpart_scalar(value,NA_character_))) + if(length(out)!=1L || !is.finite(out)) NA_real_ else out +} + +ram_counterpart_pdb_id <- function(identifier) { + value <- toupper(ram_counterpart_scalar(identifier,"")) + hit <- regexpr("^[0-9][A-Z0-9]{3}",value,perl=TRUE) + if(hit[[1L]]<0L) return(NA_character_) + regmatches(value,hit) +} + +ram_counterpart_chains <- function(entities) { + if(is.null(entities) || !is.list(entities)) return("") + chains <- unique(unlist(lapply(entities,function(entity) { + type <- toupper(ram_counterpart_scalar(entity$entity_type,"")) + if(!identical(type,"POLYMER")) return(character()) + as.character(unlist(entity$chain_ids,use.names=FALSE)) + }),use.names=FALSE)) + chains <- chains[!is.na(chains) & nzchar(chains)] + paste(chains,collapse=", ") +} + +ram_parse_experimental_counterparts <- function(response, accession, + max_results=50L) { + accession <- ram_uniprot_accession(accession) + if(!is.list(response)) stop("Invalid 3D-Beacons response.") + structures <- response$structures + if(is.null(structures) || !length(structures)) + return(data.frame( + accession=character(),pdb_id=character(),model_identifier=character(), + chains=character(),coverage=numeric(),resolution=numeric(), + uniprot_start=integer(),uniprot_end=integer(), + experimental_method=character(),model_url=character(), + stringsAsFactors=FALSE + )) + rows <- lapply(structures,function(item) { + summary <- if(is.list(item)) item$summary else NULL + if(is.null(summary) || !is.list(summary)) return(NULL) + provider <- toupper(ram_counterpart_scalar(summary$provider,"")) + category <- toupper(gsub("[-_]"," ", + ram_counterpart_scalar(summary$model_category,""))) + if(provider!="PDBE" || + !category %in% c("EXPERIMENTALLY DETERMINED","EXPERIMENTAL")) + return(NULL) + identifier <- ram_counterpart_scalar(summary$model_identifier,"") + pdb_id <- ram_counterpart_pdb_id(identifier) + if(is.na(pdb_id)) return(NULL) + data.frame( + accession=accession, + pdb_id=pdb_id, + model_identifier=identifier, + chains=ram_counterpart_chains(summary$entities), + coverage=ram_counterpart_numeric(summary$coverage), + resolution=ram_counterpart_numeric(summary$resolution), + uniprot_start=suppressWarnings(as.integer( + ram_counterpart_numeric(summary$uniprot_start))), + uniprot_end=suppressWarnings(as.integer( + ram_counterpart_numeric(summary$uniprot_end))), + experimental_method=ram_counterpart_scalar( + summary$experimental_method,""), + model_url=ram_counterpart_scalar(summary$model_url,""), + stringsAsFactors=FALSE + ) + }) + rows <- rows[!vapply(rows,is.null,logical(1))] + if(!length(rows)) return(ram_parse_experimental_counterparts( + list(structures=list()),accession,max_results)) + result <- do.call(rbind,rows) + + # One UniProt mapping may expose multiple records for the same PDB/chain. + # Keep the best-covered / highest-resolution representative of duplicates. + coverage_rank <- replace(result$coverage,!is.finite(result$coverage),-Inf) + resolution_rank <- replace(result$resolution,!is.finite(result$resolution),Inf) + result <- result[order(-coverage_rank,resolution_rank,result$pdb_id, + result$model_identifier),,drop=FALSE] + key <- paste(result$pdb_id,result$chains,sep="\r") + result <- result[!duplicated(key),,drop=FALSE] + + max_results <- suppressWarnings(as.integer(max_results)) + if(length(max_results)!=1L || is.na(max_results) || max_results<1L) + stop("max_results must be a positive integer.") + utils::head(result,max_results) +} + +ram_lookup_experimental_counterparts <- function( + accession,max_results=50L,fetch=ram_3dbeacons_fetch) { + accession <- ram_uniprot_accession(accession) + response <- fetch(ram_3dbeacons_summary_url(accession)) + ram_parse_experimental_counterparts(response,accession,max_results) +} diff --git a/shinyRam/app.R b/shinyRam/app.R index 1462813a..2f79abe3 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -31,6 +31,7 @@ source(file.path("R", "io.R"), local = TRUE) source(file.path("R", "inspection.R"), local = TRUE) source(file.path("R", "reports.R"), local = TRUE) source(file.path("R", "predictions.R"), local = TRUE) +source(file.path("R", "counterparts.R"), local = TRUE) source(file.path("R", "geometry.R"), local = TRUE) source(file.path("R", "experimental.R"), local = TRUE) source(file.path("R", "ensemble.R"), local = TRUE) @@ -606,6 +607,7 @@ server <- function(input, output, session) { external_validation <- reactiveVal(NULL) ensemble_results <- reactiveVal(NULL) prediction_ensemble_results <- reactiveVal(NULL) + experimental_counterparts <- reactiveVal(NULL) selected_residue <- reactiveVal(NULL) selected_comparison <- reactiveVal(NULL) viewer_ready <- reactiveVal(FALSE) @@ -827,23 +829,17 @@ server <- function(input, output, session) { loaded(list(key = key, name = name, torsions = torsions, chains = chains, pdb = pdb, nmodels = ram_model_count(pdb), source_id = source_id, viewer_format = viewer_format, prediction = prediction, - declared_source = declared_source, input_source = source_type)) + declared_source = declared_source, input_source = source_type, + uniprot_accession = if (is_afdb) source_label else NA_character_)) incProgress(0.25, detail = "Preparing interactive views") }) }, ignoreInit = TRUE) # Comparison loading is a separate, deliberate action, so changing plot # settings does not repeatedly refetch the secondary structure. - observeEvent(input$compareSubmit, { - is_upload <- identical(input$compareInputSource, "upload") - if (is_upload && (is.null(input$compareFile) || - is.null(input$compareFile$datapath))) { - showNotification("Choose a second PDB or mmCIF file.", type="error") - return() - } - secondary <- if (is_upload) input$compareFile$datapath else - toupper(trimws(input$comparePDB)) - source_name <- if (is_upload) input$compareFile$name else secondary + load_comparison_structure <- function(secondary, source_name=secondary, + is_upload=FALSE, + preferred_chain=NULL) { data <- tryCatch( ram_load_structure( path = if (is_upload) secondary else NULL, @@ -854,28 +850,51 @@ server <- function(input, output, session) { NULL } ) - if (is.null(data)) return() - torsions <- tryCatch(ram_extract_torsions(ram_model_at(data, 1L)), - error = function(e) { - showNotification(conditionMessage(e), type="error",duration=12) + if (is.null(data)) return(invisible(FALSE)) + torsions <- tryCatch(ram_extract_torsions(ram_model_at(data,1L)), + error=function(e) { + showNotification(conditionMessage(e),type="error",duration=12) NULL }) - if (is.null(torsions)) return() - comparison_loaded(list(pdb=data, torsions=torsions, + if(is.null(torsions)) return(invisible(FALSE)) + comparison_loaded(list( + pdb=data,torsions=torsions, name=tools::file_path_sans_ext(basename(source_name)), source_id=secondary, - viewer_format=if (is_upload) ram_detect_format(source_name) else NULL, - nmodels=ram_model_count(data))) - }, ignoreInit=TRUE) + viewer_format=if(is_upload) ram_detect_format(source_name) else NULL, + nmodels=ram_model_count(data), + preferred_chain=if(length(preferred_chain)==1L && + !is.na(preferred_chain) && nzchar(preferred_chain)) + as.character(preferred_chain) else NULL + )) + invisible(TRUE) + } + + observeEvent(input$compareSubmit, { + is_upload <- identical(input$compareInputSource,"upload") + if(is_upload && (is.null(input$compareFile) || + is.null(input$compareFile$datapath))) { + showNotification("Choose a second PDB or mmCIF file.",type="error") + return() + } + secondary <- if(is_upload) input$compareFile$datapath else + toupper(trimws(input$comparePDB)) + source_name <- if(is_upload) input$compareFile$name else secondary + load_comparison_structure(secondary,source_name,is_upload) + },ignoreInit=TRUE) + output$compareChainControls <- renderUI({ first <- req(loaded()) second <- req(comparison_loaded()) + second_chains <- unique(second$torsions$chain) + selected_second <- if(!is.null(second$preferred_chain) && + second$preferred_chain %in% second_chains) + second$preferred_chain else second_chains[[1L]] tags$div(class="ram-compare-chains", selectInput("compareChainA", paste("Chain in", first$name), choices=first$chains, selected=first$chains[[1L]]), selectInput("compareChainB", paste("Chain in", second$name), - choices=unique(second$torsions$chain), - selected=unique(second$torsions$chain)[[1L]]), + choices=second_chains, selected=selected_second), if (second$nmodels > 1L) selectInput("compareModel", "Second structure model", choices=as.character(seq_len(second$nmodels)), selected="1") @@ -1366,10 +1385,166 @@ server <- function(input, output, session) { "No matching PAE matrix was provided for this model."), if (length(prediction$notes)) tags$p( class = "ram-confidence-warning", - paste(prediction$notes, collapse = " ")) + paste(prediction$notes, collapse = " ")), + tags$section(class="ram-counterpart-panel", + tags$div(class="ram-confidence-map-title", + tags$strong("Experimental counterparts"), + tags$span("Find deposited PDB structures mapped to the same UniProt protein.")), + tags$p(class="ram-confidence-explainer", + "Hits come from the 3D-Beacons network and are restricted to experimentally determined PDBe records. Select a hit to open it directly in RamplotR's Compare workflow."), + tags$div(class="ram-counterpart-controls", + textInput("counterpartAccession","UniProt accession", + value=if(!is.null(structure$uniprot_accession) && + !is.na(structure$uniprot_accession)) + structure$uniprot_accession else "", + placeholder="e.g. P69905"), + actionButton("lookupCounterparts","Find experimental structures", + class="btn-default btn-sm") + ), + uiOutput("counterpartSummary"), + DT::DTOutput("counterpartTable"), + actionButton("useCounterpart","Compare selected structure", + class="btn-primary btn-sm") + ) ) ) }) + lookup_experimental_counterparts <- function(accession, quiet=FALSE) { + structure <- req(loaded()) + normalized <- tryCatch(ram_uniprot_accession(accession), + error=function(e) { + if(!quiet) showNotification(conditionMessage(e),type="warning",duration=10) + NULL + }) + if(is.null(normalized)) return(invisible(FALSE)) + result <- tryCatch( + withProgress(message="Finding experimental structures",value=0.2,{ + incProgress(0.35,detail="Querying 3D-Beacons") + ram_lookup_experimental_counterparts(normalized,max_results=50L) + }), + error=function(e) e + ) + if(inherits(result,"error")) { + experimental_counterparts(list( + key=structure$key,accession=normalized,data=NULL, + error=conditionMessage(result) + )) + if(!quiet) + showNotification(paste("Experimental counterpart lookup:", + conditionMessage(result)),type="warning",duration=14) + return(invisible(FALSE)) + } + experimental_counterparts(list( + key=structure$key,accession=normalized,data=result,error=NULL + )) + invisible(TRUE) + } + + observeEvent(loaded(), { + experimental_counterparts(NULL) + structure <- loaded() + if(is.null(structure) || is.null(structure$prediction)) return() + accession <- structure$uniprot_accession + if(!is.null(accession) && length(accession)==1L && + !is.na(accession) && nzchar(accession)) + lookup_experimental_counterparts(accession,quiet=TRUE) + },ignoreInit=TRUE) + + observeEvent(input$lookupCounterparts, { + lookup_experimental_counterparts(input$counterpartAccession,quiet=FALSE) + },ignoreInit=TRUE) + + counterpart_matches <- reactive({ + value <- experimental_counterparts() + structure <- req(loaded()) + if(is.null(value) || !identical(value$key,structure$key)) return(NULL) + value + }) + + output$counterpartSummary <- renderUI({ + value <- counterpart_matches() + if(is.null(value)) + return(tags$p(class="ram-field-hint", + "AlphaFold DB models are looked up automatically. For other predictions, enter the matching UniProt accession.")) + if(!is.null(value$error)) + return(tags$p(class="ram-confidence-warning", + paste("Lookup unavailable:",value$error))) + data <- value$data + if(is.null(data) || !nrow(data)) + return(tags$p(class="ram-field-hint", + paste("No experimentally determined PDBe structures were returned for", + value$accession,"."))) + best <- data[1L,,drop=FALSE] + best_detail <- paste0( + if(is.finite(best$coverage[[1L]])) + sprintf("%.0f%% coverage",100*best$coverage[[1L]]) else "coverage unavailable", + if(is.finite(best$resolution[[1L]])) + sprintf(" · %.2f Å",best$resolution[[1L]]) else "" + ) + tags$div(class="ram-official-summary", + tags$strong(sprintf("%d experimental structure%s for %s", + nrow(data),if(nrow(data)==1L) "" else "s",value$accession)), + tags$p(sprintf("Best-ranked hit: %s%s · %s", + best$pdb_id[[1L]], + if(nzchar(best$chains[[1L]])) paste0(" · chain ",best$chains[[1L]]) else "", + best_detail)), + tags$p(class="ram-field-hint", + "Ranked by UniProt coverage first, then experimental resolution. Missing resolution is retained for methods such as NMR.") + ) + }) + + output$counterpartTable <- DT::renderDT({ + value <- counterpart_matches() + if(is.null(value) || !is.null(value$error) || + is.null(value$data) || !nrow(value$data)) + return(DT::datatable(data.frame(),rownames=FALSE, + options=list(dom="t"),class="compact")) + data <- value$data + shown <- data.frame( + PDB=data$pdb_id, + Chains=data$chains, + Coverage=ifelse(is.finite(data$coverage), + sprintf("%.0f%%",100*data$coverage),""), + Resolution=ifelse(is.finite(data$resolution), + sprintf("%.2f Å",data$resolution),""), + Method=data$experimental_method, + UniProt_range=ifelse(!is.na(data$uniprot_start) & !is.na(data$uniprot_end), + paste0(data$uniprot_start,"–",data$uniprot_end),""), + stringsAsFactors=FALSE + ) + DT::datatable(shown,rownames=FALSE,selection="single", + options=list(pageLength=8,dom="tip",scrollX=TRUE,autoWidth=FALSE), + class="compact stripe hover") + },server=FALSE) + + observeEvent(input$useCounterpart, { + value <- counterpart_matches() + if(is.null(value) || is.null(value$data) || !nrow(value$data)) { + showNotification("Find an experimental structure first.",type="warning") + return() + } + selected <- input$counterpartTable_rows_selected + if(length(selected)!=1L || is.na(selected) || + selected<1L || selected>nrow(value$data)) { + showNotification("Select one experimental structure from the table.", + type="warning") + return() + } + row <- value$data[selected,,drop=FALSE] + pdb_id <- row$pdb_id[[1L]] + mapped_chain <- if(nzchar(row$chains[[1L]])) + trimws(strsplit(row$chains[[1L]],",",fixed=TRUE)[[1L]][[1L]]) + else NULL + updateRadioButtons(session,"compareInputSource",selected="pdb") + updateTextInput(session,"comparePDB",value=pdb_id) + if(load_comparison_structure(pdb_id,pdb_id,FALSE,mapped_chain)) { + updateTabsetPanel(session,"analysisTabs",selected="compare") + showNotification( + paste("Loaded",pdb_id,"as the experimental comparison structure."), + type="message",duration=8) + } + },ignoreInit=TRUE) + output$predictionReviewMetrics <- renderUI({ if (is.null(req(loaded())$prediction) || current_model() != 1L) return(NULL) diff --git a/shinyRam/www/styles.css b/shinyRam/www/styles.css index 96b9fcf3..9506e639 100644 --- a/shinyRam/www/styles.css +++ b/shinyRam/www/styles.css @@ -1244,3 +1244,15 @@ body > .container-fluid { max-width: none; padding: 0; } border-radius:999px; padding:4px 8px; font-size:9px; font-weight:700; cursor:pointer; } .ram-change-top-item:hover,.ram-change-top-item:focus-visible { background:#eaf5f2; border-color:#7eb7ae; } + +.ram-counterpart-panel { margin-top:14px; padding-top:12px; + border-top:1px solid #dbe8e5; } +.ram-counterpart-controls { display:grid; + grid-template-columns:minmax(190px,260px) auto; gap:8px 10px; + align-items:end; margin:8px 0 9px; } +.ram-counterpart-controls .form-group { margin-bottom:0; } +.ram-counterpart-panel .dataTables_wrapper { margin:8px 0; } +.ram-counterpart-panel .btn-primary { margin-top:3px; } +@media(max-width:680px) { + .ram-counterpart-controls { grid-template-columns:1fr; } +} diff --git a/tests/counterparts.R b/tests/counterparts.R new file mode 100644 index 00000000..9b073921 --- /dev/null +++ b/tests/counterparts.R @@ -0,0 +1,65 @@ +source(file.path("shinyRam","R","counterparts.R")) +assert <- function(x,msg) if(!isTRUE(x)) stop(msg,call.=FALSE) + +assert(identical(ram_uniprot_accession(" p69905 "),"P69905"), + "UniProt accessions must be normalized.") +assert(grepl("/uniprot/summary/P69905.json$", + ram_3dbeacons_summary_url("P69905")), + "3D-Beacons summary endpoint changed unexpectedly.") +assert(inherits(try(ram_uniprot_accession("not an id"),silent=TRUE),"try-error"), + "Invalid UniProt accessions must be rejected.") + +mock <- list( + uniprot_entry=list(ac="P69905",sequence_length=142), + structures=list( + list(summary=list( + model_identifier="1abc.1.A",model_category="EXPERIMENTALLY DETERMINED", + provider="PDBe",coverage=0.91,resolution=1.8, + uniprot_start=2,uniprot_end=131,experimental_method="X-RAY DIFFRACTION", + model_url="https://example.org/1abc.cif", + entities=list(list(entity_type="POLYMER",chain_ids=list("A"))))), + list(summary=list( + model_identifier="2xyz.1.B",model_category="EXPERIMENTALLY-DETERMINED", + provider="pdbe",coverage=0.72,resolution=NA, + uniprot_start=10,uniprot_end=110, + entities=list(list(entity_type="POLYMER",chain_ids=list("B","C"))))), + # Duplicate PDB/chain with worse coverage should be removed. + list(summary=list( + model_identifier="1abc.2.A",model_category="EXPERIMENTALLY DETERMINED", + provider="PDBe",coverage=0.50,resolution=2.6, + entities=list(list(entity_type="POLYMER",chain_ids=list("A"))))), + # Predicted/non-PDBe records must never be presented as experimental. + list(summary=list( + model_identifier="AF-P69905-F1",model_category="AB-INITIO", + provider="AlphaFold DB",coverage=1)), + list(summary=list( + model_identifier="3def.1.A",model_category="EXPERIMENTALLY DETERMINED", + provider="OtherProvider",coverage=1)) + ) +) + +parsed <- ram_parse_experimental_counterparts(mock,"p69905") +assert(nrow(parsed)==2L, + "Only unique experimentally determined PDBe records should remain.") +assert(identical(parsed$pdb_id,c("1ABC","2XYZ")), + "Counterparts should rank by coverage and parse PDB IDs.") +assert(parsed$coverage[[1L]]==0.91 && parsed$resolution[[1L]]==1.8, + "Counterpart coverage/resolution parsing changed.") +assert(identical(parsed$chains,c("A","B, C")), + "Polymer chain labels were not preserved.") +assert(parsed$uniprot_start[[1L]]==2L && parsed$uniprot_end[[1L]]==131L, + "UniProt coverage range changed.") + +seen <- NULL +result <- ram_lookup_experimental_counterparts( + "P69905",max_results=1L, + fetch=function(url) { seen <<- url; mock }) +assert(nrow(result)==1L && result$pdb_id[[1L]]=="1ABC" && + grepl("P69905.json",seen,fixed=TRUE), + "Lookup must use the normalized accession and result limit.") + +empty <- ram_parse_experimental_counterparts(list(structures=list()),"P69905") +assert(nrow(empty)==0L && "pdb_id" %in% names(empty), + "No-hit responses must return a typed empty table.") + +message("Experimental counterpart lookup tests passed.") diff --git a/tests/prediction-browser.cjs b/tests/prediction-browser.cjs index 56d98669..6006e6af 100644 --- a/tests/prediction-browser.cjs +++ b/tests/prediction-browser.cjs @@ -97,6 +97,33 @@ const puppeteer = require("puppeteer-core"); await page.screenshot({path:path.join(output,"prediction-debug.png"),fullPage:true}); throw error; } + await page.waitForSelector("#ram-confidence-panel > summary",{timeout:12000}); + const confidencePanelOpen=await page.$eval("#ram-confidence-panel", + panel=>panel.open); + if(!confidencePanelOpen) + await page.click("#ram-confidence-panel > summary"); + await page.waitForSelector("#counterpartAccession", + {visible:true,timeout:12000}); + const counterpartUi=await page.evaluate(() => ({ + accession:document.getElementById("counterpartAccession")?.value || "", + lookup:Boolean(document.getElementById("lookupCounterparts")), + use:Boolean(document.getElementById("useCounterpart")) + })); + assert.equal(counterpartUi.accession,"", + "Uploaded predictions should not invent a UniProt accession."); + assert.ok(counterpartUi.lookup && counterpartUi.use, + "Prediction confidence should expose experimental counterpart controls."); + await page.$eval("#counterpartAccession",input=>{ + input.value="not an accession"; + input.dispatchEvent(new Event("input",{bubbles:true})); + input.dispatchEvent(new Event("change",{bubbles:true})); + }); + await page.click("#lookupCounterparts"); + await page.waitForFunction(() => + [...document.querySelectorAll(".shiny-notification")] + .some(node=>node.textContent.includes("valid UniProt accession")), + {timeout:12000}); + assert.equal(await page.$("#ram-pae-plot"),null, "ESMFold without PAE must not invent an error map"); await page.screenshot({path:path.join(output,"prediction-esmfold.png"),