diff --git a/.github/workflows/scientific-tests.yml b/.github/workflows/scientific-tests.yml index 5459941c..c1e9a6a4 100644 --- a/.github/workflows/scientific-tests.yml +++ b/.github/workflows/scientific-tests.yml @@ -39,6 +39,7 @@ jobs: - name: Test browser message handler and source selection run: | node --check shinyRam/www/custom.js + node --check shinyRam/www/experimental-search.js node --check shinyRam/www/compare.js node --check shinyRam/www/prediction.js node tests/compare-ui.test.cjs diff --git a/README.md b/README.md index 56e3ba5e..e048ae1a 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 multiple AF2/ColabFold or ESMFold seeds as a prediction ensemble, and search experimental PDB 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. | @@ -106,6 +106,7 @@ The default RamplotR teal contour palette provides consistent, recognisable publ - [Geometry, official wwPDB evidence, cryo-EM overlays, ensembles and batch mode](docs/structural-verification.md) - [Rama8000 standard validation and direct wwPDB comparison](docs/rama8000-validation.md) - [Prediction ensemble analysis](docs/prediction-ensembles.md) +- [Experimental counterpart discovery for predicted models](docs/experimental-counterparts.md) - [Independent wwPDB angle-validation protocol](docs/wwpdb-validation.md) · [Results](docs/validation-results.md) - [Performance and large-structure benchmarks](docs/benchmark-results.md) · [Scaling results](docs/scaling-results.md) - [Shinylive browser deployment and one.com caching](docs/shinylive-deployment.md) diff --git a/docs/experimental-counterparts.md b/docs/experimental-counterparts.md new file mode 100644 index 00000000..9341ce97 --- /dev/null +++ b/docs/experimental-counterparts.md @@ -0,0 +1,85 @@ +# Experimental counterpart discovery + +RamplotR can search for experimentally determined PDB structures related to a +loaded predicted protein model and open a selected hit directly in the linked +Compare workflow. + +## Workflow + +1. Load an AlphaFold DB, AlphaFold/ColabFold, ESMFold or other declared + prediction. +2. Expand **Experimental counterparts** below the prediction-confidence panel. +3. Choose the prediction chain and a minimum sequence-identity threshold. +4. Select **Find experimental structures**. +5. Review the returned PDB polymer entities, experimental method and resolution. +6. Open the entry in PDBe for archive context, or choose **Compare in RamplotR** + to load the experimental structure as the second model. + +When a result is opened in RamplotR, the query chain is selected as the primary +comparison chain and the matching PDB chain returned by the archive metadata is +preferred for the experimental structure. The existing Conformational Change +Explorer then provides wrapped delta-phi/delta-psi values, combined local +backbone displacement, Rama8000 categories and linked 3D residue inspection. + +## Search implementation + +The browser sends the selected protein sequence to the public RCSB PDB Search +API sequence service: + +- target: `pdb_protein_sequence` +- return type: `polymer_entity` +- content type: experimental structures only +- default minimum sequence identity: 90% +- maximum displayed hits: 12 + +Polymer-entity and entry metadata are then obtained from the RCSB Data API. +The network requests are made client-side in `experimental-search.js`, so the +same workflow can be used by the normal Shiny application and the static +Shinylive/webR deployment without adding a server-side HTTP dependency. + +Result cards link to the corresponding current PDBe entry page for archive, +annotation and validation context. + +## Interpretation + +A sequence-similar PDB structure is a **candidate experimental counterpart**, +not proof that it represents the same biochemical state. + +Differences can arise from: + +- bound ligands or cofactors; +- mutations, constructs or truncations; +- oligomeric state; +- crystallisation or cryo-EM conditions; +- alternate functional states; +- experimental uncertainty; +- genuinely different local conformations. + +For that reason RamplotR does not assign a single "agreement score" between a +prediction and an experimental hit. The search only identifies candidates. +Interpretation happens in the existing residue-level comparison workflow. + +Lowering the identity threshold can be useful for homologous structures, but +the biological relevance of the comparison becomes increasingly dependent on +domain architecture and functional context. + +## Privacy and browser execution + +The selected amino-acid sequence is sent to the public RCSB PDB sequence search +service when the user explicitly runs the search. Coordinate files uploaded to +RamplotR are not automatically uploaded to a prediction service by this +feature. Loading a selected PDB hit uses RamplotR's normal public-accession +retrieval path. + +## Testing + +The browser integration test mocks the RCSB sequence and metadata responses so +continuous integration is deterministic and does not depend on temporary API +availability. It verifies: + +- the request targets protein sequence search; +- the chosen identity threshold is transmitted; +- experimental-only search content is requested; +- candidate metadata and PDBe links render; +- **Compare in RamplotR** loads the candidate and selects the matching chain; +- the resulting aligned residues appear in the Conformational Change Explorer. diff --git a/shinyRam/R/inspection.R b/shinyRam/R/inspection.R index 5e990a78..632deeb2 100644 --- a/shinyRam/R/inspection.R +++ b/shinyRam/R/inspection.R @@ -31,6 +31,25 @@ ram_amino_acid_letters <- c( PRO="P", SER="S", THR="T", TRP="W", TYR="Y", VAL="V" ) +ram_chain_query_sequence <- function(data, chain) { + residues <- ram_sequence_data(data) + if (!nrow(residues)) return(list(sequence="", residues=residues, + known_fraction=NA_real_)) + target <- as.character(chain) + rows <- residues$chain == target + residues <- residues[rows, , drop=FALSE] + if (!nrow(residues)) return(list(sequence="", residues=residues, + known_fraction=NA_real_)) + letters <- toupper(as.character(residues$letter)) + letters[!grepl("^[ACDEFGHIKLMNPQRSTVWY]$", letters)] <- "X" + known <- letters != "X" + list( + sequence=paste0(letters,collapse=""), + residues=residues, + known_fraction=mean(known) + ) +} + ram_sequence_data <- function(data) { if (!nrow(data)) return(data.frame( chain=character(), resi=integer(), insertion_code=character(), diff --git a/shinyRam/app.R b/shinyRam/app.R index 1462813a..6c44bc5e 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -445,6 +445,7 @@ ui <- fluidPage( ) ), uiOutput("predictionPanel"), + uiOutput("experimentalCounterpartPanel"), uiOutput("geometryPanel") ), tabPanel( @@ -593,6 +594,7 @@ ui <- fluidPage( ), tags$script(src = "plotly-loader.js"), tags$script(src = "custom.js"), + tags$script(src = "experimental-search.js"), tags$script(src = "compare.js"), tags$script(src = "prediction.js"), tags$script(src = "density.js") @@ -606,6 +608,9 @@ server <- function(input, output, session) { external_validation <- reactiveVal(NULL) ensemble_results <- reactiveVal(NULL) prediction_ensemble_results <- reactiveVal(NULL) + experimental_search_results <- reactiveVal(NULL) + experimental_search_status <- reactiveVal(NULL) + experimental_search_request <- reactiveVal(0L) selected_residue <- reactiveVal(NULL) selected_comparison <- reactiveVal(NULL) viewer_ready <- reactiveVal(FALSE) @@ -833,49 +838,81 @@ server <- function(input, output, session) { }, 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 + # settings does not repeatedly refetch the secondary structure. The helper is + # shared by the manual Compare tab and prediction-to-experiment discovery. + load_comparison_structure <- function(path = NULL, original_name = NULL, + pdb_id = NULL, + preferred_chain_a = NULL, + preferred_chain_b = NULL) { + is_upload <- !is.null(path) + source_id <- if (is_upload) path else toupper(trimws(pdb_id)) + source_name <- if (is_upload) original_name else source_id data <- tryCatch( ram_load_structure( - path = if (is_upload) secondary else NULL, - original_name = if (is_upload) source_name else NULL, - pdb_id = if (is_upload) NULL else secondary + path = path, + original_name = original_name, + pdb_id = if (is_upload) NULL else source_id ), error = function(e) { showNotification(conditionMessage(e), type="error", duration=12) NULL } ) - if (is.null(data)) return() + if (is.null(data)) return(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(FALSE) + comparison_loaded(list( + pdb=data, torsions=torsions, name=tools::file_path_sans_ext(basename(source_name)), - source_id=secondary, + source_id=source_id, viewer_format=if (is_upload) ram_detect_format(source_name) else NULL, - nmodels=ram_model_count(data))) + nmodels=ram_model_count(data), + preferred_chain_a=preferred_chain_a, + preferred_chain_b=preferred_chain_b + )) + 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() + } + if (is_upload) { + load_comparison_structure( + path=input$compareFile$datapath, + original_name=input$compareFile$name + ) + } else { + pdb_id <- toupper(trimws(input$comparePDB)) + if (!nzchar(pdb_id)) { + showNotification("Enter a PDB accession.", type="error") + return() + } + load_comparison_structure(pdb_id=pdb_id) + } }, ignoreInit=TRUE) + output$compareChainControls <- renderUI({ first <- req(loaded()) second <- req(comparison_loaded()) + chains_b <- unique(second$torsions$chain) + preferred_a <- second$preferred_chain_a + preferred_b <- second$preferred_chain_b + selected_a <- if (!is.null(preferred_a) && preferred_a %in% first$chains) + preferred_a else first$chains[[1L]] + selected_b <- if (!is.null(preferred_b) && preferred_b %in% chains_b) + preferred_b else chains_b[[1L]] tags$div(class="ram-compare-chains", selectInput("compareChainA", paste("Chain in", first$name), - choices=first$chains, selected=first$chains[[1L]]), + choices=first$chains, selected=selected_a), selectInput("compareChainB", paste("Chain in", second$name), - choices=unique(second$torsions$chain), - selected=unique(second$torsions$chain)[[1L]]), + choices=chains_b, selected=selected_b), if (second$nmodels > 1L) selectInput("compareModel", "Second structure model", choices=as.character(seq_len(second$nmodels)), selected="1") @@ -1370,6 +1407,196 @@ server <- function(input, output, session) { ) ) }) + output$experimentalCounterpartPanel <- renderUI({ + structure <- req(loaded()) + prediction <- structure$prediction + if (is.null(prediction) || current_model() != 1L) return(NULL) + tags$details(id="ram-experimental-counterparts", + class="ram-confidence-panel ram-counterpart-panel", + tags$summary( + tags$span(class="ram-confidence-title", + "Experimental counterparts"), + tags$span(class="ram-confidence-subtitle", + "Find related PDB structures and compare local backbone conformations") + ), + tags$div(class="ram-confidence-body", + tags$p(class="ram-confidence-explainer", + "Search experimental PDB polymer entities by sequence similarity, then open a candidate directly in RamplotR's linked comparison view. A sequence match is not evidence that two structures represent the same functional or biochemical state."), + tags$div(class="ram-counterpart-controls", + selectInput("experimentalSearchChain","Prediction chain", + choices=structure$chains,selected=structure$chains[[1L]], + selectize=FALSE), + selectInput("experimentalIdentity","Minimum sequence identity", + choices=c("100%"="1","95%"="0.95","90%"="0.90", + "70%"="0.70","50%"="0.50"), + selected="0.90",selectize=FALSE), + actionButton("findExperimentalStructures", + "Find experimental structures",class="btn-primary btn-sm") + ), + uiOutput("experimentalCounterpartStatus"), + uiOutput("experimentalCounterpartResults"), + tags$p(class="ram-field-hint", + "Search uses the public RCSB PDB sequence service and requests experimental entries only. Entry links open the corresponding PDBe page.") + ) + ) + }) + + output$experimentalCounterpartStatus <- renderUI({ + status <- experimental_search_status() + if (is.null(status)) return(NULL) + state <- if (is.null(status$state)) "" else as.character(status$state) + message <- if (is.null(status$message)) "" else as.character(status$message) + cls <- if (identical(state,"error")) "ram-confidence-warning" + else if (identical(state,"searching")) "ram-counterpart-searching" + else "ram-field-hint" + tags$p(class=cls,message) + }) + + output$experimentalCounterpartResults <- renderUI({ + payload <- experimental_search_results() + if (is.null(payload)) return(NULL) + results <- payload$results + if (is.null(results) || !length(results)) + return(tags$div(class="ram-counterpart-empty", + tags$strong("No experimental matches at this threshold."), + tags$p("Try a lower sequence-identity threshold if a more distant structural homologue would still be informative."))) + total <- suppressWarnings(as.integer(payload$total_count)) + card <- function(item) { + get <- function(name, default="") { + value <- item[[name]] + if (is.null(value) || !length(value) || is.na(value[[1L]])) + default else as.character(value[[1L]]) + } + pdb_id <- toupper(get("pdb_id")) + entity <- get("entity_id") + chain <- get("chain") + title <- get("title") + description <- get("description","Protein polymer entity") + method <- get("method","Experimental structure") + resolution <- suppressWarnings(as.numeric(get("resolution",NA_character_))) + resolution_label <- if (is.finite(resolution)) + sprintf("%.2f Å",resolution) else "Resolution n/a" + chains <- item[["chains"]] + chain_label <- if (!is.null(chains) && length(chains)) + paste(as.character(unlist(chains)),collapse=", ") else + if (nzchar(chain)) chain else "n/a" + pdbe <- paste0("https://www.ebi.ac.uk/pdbe/entry/pdb/", + tolower(pdb_id)) + tags$article(class="ram-counterpart-card", + tags$div(class="ram-counterpart-card-main", + tags$div(class="ram-counterpart-id", + tags$strong(pdb_id), + tags$span(paste("Entity",entity)), + tags$span(paste("Chain",chain_label)) + ), + tags$div(class="ram-counterpart-copy", + tags$strong(description), + if (nzchar(title) && !identical(title,description)) + tags$p(title), + tags$div(class="ram-counterpart-meta", + tags$span(method),tags$span(resolution_label)) + ) + ), + tags$div(class="ram-counterpart-actions", + tags$a("PDBe entry",href=pdbe,target="_blank", + rel="noopener noreferrer",class="btn btn-default btn-sm"), + tags$button(type="button", + class="btn btn-primary btn-sm ram-experimental-compare", + "data-pdb"=pdb_id,"data-entity"=entity, + "data-chain"=chain,"data-title"=title, + "Compare in RamplotR") + ) + ) + } + tags$div(class="ram-counterpart-results", + tags$div(class="ram-counterpart-result-head", + tags$strong(sprintf("%d candidate%s shown", + length(results),if(length(results)==1L) "" else "s")), + if (is.finite(total) && total > length(results)) + tags$span(sprintf("%d total hits met the sequence threshold",total)) + ), + lapply(results,card) + ) + }) + + observeEvent(loaded(), { + experimental_search_results(NULL) + experimental_search_status(NULL) + experimental_search_request(experimental_search_request()+1L) + }, ignoreInit=TRUE) + + observeEvent(input$findExperimentalStructures, { + structure <- req(loaded()) + if (is.null(structure$prediction) || current_model() != 1L) return() + chain <- req(input$experimentalSearchChain) + query <- ram_chain_query_sequence(classified(),chain) + if (nchar(query$sequence) < 20L) { + experimental_search_status(list(state="error", + message="The selected chain is too short for a useful sequence search.")) + return() + } + if (!is.finite(query$known_fraction) || query$known_fraction < 0.60) { + experimental_search_status(list(state="error", + message="Too much of this chain is unresolved or non-standard for a reliable protein-sequence search.")) + return() + } + identity <- suppressWarnings(as.numeric(input$experimentalIdentity)) + if (!is.finite(identity)) identity <- 0.90 + request_id <- experimental_search_request()+1L + experimental_search_request(request_id) + experimental_search_results(NULL) + experimental_search_status(list(state="searching", + message=sprintf("Searching experimental structures related to chain %s…",chain))) + session$sendCustomMessage("ram-experimental-search",list( + request_id=as.character(request_id), + sequence=query$sequence, + identity_cutoff=identity, + rows=12L + )) + },ignoreInit=TRUE) + + observeEvent(input$ramExperimentalSearchStatus, { + value <- input$ramExperimentalSearchStatus + if (!is.list(value) || is.null(value$request_id)) return() + if (!identical(as.character(value$request_id), + as.character(isolate(experimental_search_request())))) return() + experimental_search_status(value) + },ignoreInit=TRUE) + + observeEvent(input$ramExperimentalSearchResults, { + value <- input$ramExperimentalSearchResults + if (!is.list(value) || is.null(value$request_id)) return() + if (!identical(as.character(value$request_id), + as.character(isolate(experimental_search_request())))) return() + experimental_search_results(value) + },ignoreInit=TRUE) + + observeEvent(input$ramExperimentalComparePick, { + item <- input$ramExperimentalComparePick + if (!is.list(item) || is.null(item$pdb_id)) return() + pdb_id <- toupper(trimws(as.character(item$pdb_id))) + chain_b <- if (is.null(item$chain)) "" else as.character(item$chain) + chain_a <- isolate(input$experimentalSearchChain) + if (!grepl("^[A-Za-z0-9]{4}$",pdb_id)) { + showNotification("Invalid PDB identifier returned by the search.", + type="error") + return() + } + updateRadioButtons(session,"compareInputSource",selected="pdb") + updateTextInput(session,"comparePDB",value=pdb_id) + ok <- load_comparison_structure( + pdb_id=pdb_id, + preferred_chain_a=chain_a, + preferred_chain_b=chain_b + ) + if (isTRUE(ok)) { + updateTabsetPanel(session,"analysisTabs",selected="compare") + showNotification( + paste("Loaded",pdb_id,"for experimental comparison."), + type="message",duration=5) + } + },ignoreInit=TRUE) + output$predictionReviewMetrics <- renderUI({ if (is.null(req(loaded())$prediction) || current_model() != 1L) return(NULL) diff --git a/shinyRam/www/experimental-search.js b/shinyRam/www/experimental-search.js new file mode 100644 index 00000000..59aec218 --- /dev/null +++ b/shinyRam/www/experimental-search.js @@ -0,0 +1,224 @@ +(function () { + "use strict"; + + const SEARCH_URL = "https://search.rcsb.org/rcsbsearch/v2/query"; + const DATA_BASE = "https://data.rcsb.org/rest/v1/core"; + + async function fetchJson(url, options, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs || 20000); + try { + const response = await fetch(url, Object.assign({}, options || {}, { + signal: controller.signal + })); + if (response.status === 204) return null; + if (!response.ok) { + throw new Error("HTTP " + response.status + " from " + url); + } + return await response.json(); + } finally { + clearTimeout(timer); + } + } + + function compactText(value, fallback) { + const text = String(value == null ? "" : value) + .replace(/\s+/g, " ").trim(); + return text || fallback || ""; + } + + function parseEntityIdentifier(identifier) { + const value = compactText(identifier, ""); + const match = value.match(/^([A-Za-z0-9]{4})_(\d+)$/); + return match ? { + pdb_id: match[1].toUpperCase(), + entity_id: match[2] + } : null; + } + + function chainIds(entity) { + const ids = entity && entity.rcsb_polymer_entity_container_identifiers; + let chains = ids && Array.isArray(ids.auth_asym_ids) + ? ids.auth_asym_ids.filter(Boolean).map(String) : []; + if (!chains.length && entity && entity.entity_poly) { + chains = compactText(entity.entity_poly.pdbx_strand_id, "") + .split(",").map(x => x.trim()).filter(Boolean); + } + return [...new Set(chains)]; + } + + function entityDescription(entity) { + return compactText( + entity && entity.rcsb_polymer_entity && + entity.rcsb_polymer_entity.pdbx_description, + "Protein polymer entity" + ); + } + + function entryMethod(entry) { + const values = entry && Array.isArray(entry.exptl) + ? entry.exptl.map(item => compactText(item && item.method, "")) + .filter(Boolean) : []; + return [...new Set(values)].join(", ") || "Experimental structure"; + } + + function entryResolution(entry) { + const values = entry && entry.rcsb_entry_info && + Array.isArray(entry.rcsb_entry_info.resolution_combined) + ? entry.rcsb_entry_info.resolution_combined.map(Number) + .filter(Number.isFinite) : []; + return values.length ? Math.min(...values) : null; + } + + function entryTitle(entry) { + return compactText(entry && entry.struct && entry.struct.title, ""); + } + + function releaseDate(entry) { + return compactText( + entry && entry.rcsb_accession_info && + entry.rcsb_accession_info.initial_release_date, "" + ); + } + + async function enrichHit(hit, entryCache) { + const parsed = parseEntityIdentifier(hit && hit.identifier); + if (!parsed) return null; + const entityUrl = DATA_BASE + "/polymer_entity/" + + encodeURIComponent(parsed.pdb_id) + "/" + + encodeURIComponent(parsed.entity_id); + const entryKey = parsed.pdb_id; + if (!entryCache.has(entryKey)) { + entryCache.set(entryKey, fetchJson( + DATA_BASE + "/entry/" + encodeURIComponent(entryKey), + { method: "GET" }, 15000 + ).catch(() => null)); + } + const [entity, entry] = await Promise.all([ + fetchJson(entityUrl, { method: "GET" }, 15000).catch(() => null), + entryCache.get(entryKey) + ]); + if (!entity) return null; + const chains = chainIds(entity); + return { + pdb_id: parsed.pdb_id, + entity_id: parsed.entity_id, + chain: chains.length ? chains[0] : "", + chains, + description: entityDescription(entity), + title: entryTitle(entry), + method: entryMethod(entry), + resolution: entryResolution(entry), + release_date: releaseDate(entry), + search_score: Number.isFinite(Number(hit && hit.score)) + ? Number(hit.score) : null + }; + } + + async function searchExperimental(payload) { + if (!window.Shiny || !window.Shiny.setInputValue) return; + const requestId = compactText(payload && payload.request_id, + String(Date.now())); + const sequence = compactText(payload && payload.sequence, "") + .replace(/\s+/g, "").toUpperCase(); + const identity = Number(payload && payload.identity_cutoff); + const rows = Math.max(1, Math.min(25, + Number(payload && payload.rows) || 12)); + + window.Shiny.setInputValue("ramExperimentalSearchStatus", { + request_id: requestId, + state: "searching", + message: "Searching experimental PDB polymer entities..." + }, { priority: "event" }); + + if (sequence.length < 20) { + window.Shiny.setInputValue("ramExperimentalSearchStatus", { + request_id: requestId, + state: "error", + message: "The selected chain is too short for a useful sequence search." + }, { priority: "event" }); + return; + } + + const body = { + query: { + type: "terminal", + service: "sequence", + parameters: { + target: "pdb_protein_sequence", + value: sequence, + evalue_cutoff: 0.1, + identity_cutoff: Number.isFinite(identity) + ? Math.max(0, Math.min(1, identity)) : 0.9 + } + }, + return_type: "polymer_entity", + request_options: { + paginate: { start: 0, rows }, + results_content_type: ["experimental"], + scoring_strategy: "sequence" + } + }; + + try { + const response = await fetchJson(SEARCH_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }, 30000); + const hits = response && Array.isArray(response.result_set) + ? response.result_set : []; + const entryCache = new Map(); + const enriched = (await Promise.all( + hits.slice(0, rows).map(hit => enrichHit(hit, entryCache)) + )).filter(Boolean); + + window.Shiny.setInputValue("ramExperimentalSearchResults", { + request_id: requestId, + total_count: response && Number.isFinite(Number(response.total_count)) + ? Number(response.total_count) : hits.length, + results: enriched + }, { priority: "event" }); + window.Shiny.setInputValue("ramExperimentalSearchStatus", { + request_id: requestId, + state: "done", + message: enriched.length + ? "Experimental counterparts found." + : "No experimental counterpart met the search threshold." + }, { priority: "event" }); + } catch (error) { + const message = error && error.name === "AbortError" + ? "The experimental-structure search timed out." + : "Experimental-structure search failed: " + + compactText(error && error.message, "unknown error"); + window.Shiny.setInputValue("ramExperimentalSearchStatus", { + request_id: requestId, + state: "error", + message + }, { priority: "event" }); + } + } + + function install() { + if (!window.Shiny || !window.Shiny.addCustomMessageHandler) { + setTimeout(install, 50); + return; + } + window.Shiny.addCustomMessageHandler( + "ram-experimental-search", searchExperimental + ); + } + install(); + + document.addEventListener("click", function (event) { + const button = event.target && event.target.closest && + event.target.closest(".ram-experimental-compare"); + if (!button || !window.Shiny || !window.Shiny.setInputValue) return; + window.Shiny.setInputValue("ramExperimentalComparePick", { + pdb_id: compactText(button.dataset.pdb, "").toUpperCase(), + entity_id: compactText(button.dataset.entity, ""), + chain: compactText(button.dataset.chain, ""), + title: compactText(button.dataset.title, "") + }, { priority: "event" }); + }); +})(); \ No newline at end of file diff --git a/shinyRam/www/styles.css b/shinyRam/www/styles.css index 96b9fcf3..1113faa1 100644 --- a/shinyRam/www/styles.css +++ b/shinyRam/www/styles.css @@ -1244,3 +1244,52 @@ 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; } + +/* Predicted-to-experimental counterpart discovery. Search metadata is kept + compact so the result remains a gateway into the linked Compare workspace. */ +.ram-counterpart-panel .ram-confidence-body { gap:10px; } +.ram-counterpart-controls { + display:grid; grid-template-columns:minmax(130px,.8fr) minmax(165px,.9fr) auto; + gap:9px 12px; align-items:end; margin:8px 0 4px; +} +.ram-counterpart-controls .form-group { margin-bottom:0; } +.ram-counterpart-searching { + margin:6px 0 0; color:#2e6f76; font-size:11px; font-weight:700; +} +.ram-counterpart-empty { + border:1px dashed #c8dcd8; border-radius:8px; padding:11px 12px; + background:#fbfdfc; color:#527077; +} +.ram-counterpart-empty p { margin:4px 0 0; font-size:10px; } +.ram-counterpart-results { display:grid; gap:8px; margin-top:6px; } +.ram-counterpart-result-head { + display:flex; gap:8px; align-items:baseline; flex-wrap:wrap; + font-size:10px; color:#607a80; +} +.ram-counterpart-result-head strong { color:#245c63; font-size:11px; } +.ram-counterpart-card { + display:flex; justify-content:space-between; gap:12px; align-items:center; + border:1px solid #d8e7e4; border-radius:9px; padding:10px 11px; + background:#fff; +} +.ram-counterpart-card-main { + min-width:0; display:grid; grid-template-columns:auto minmax(0,1fr); + gap:10px; align-items:start; +} +.ram-counterpart-id { display:flex; flex-direction:column; gap:1px; + min-width:66px; font-size:9px; color:#6a7f84; } +.ram-counterpart-id strong { font-size:15px; color:#154f57; letter-spacing:.03em; } +.ram-counterpart-copy { min-width:0; font-size:10px; color:#3d5d63; } +.ram-counterpart-copy > strong { display:block; color:#234d54; font-size:11px; } +.ram-counterpart-copy p { margin:3px 0 0; max-width:720px; } +.ram-counterpart-meta { display:flex; flex-wrap:wrap; gap:5px; margin-top:5px; } +.ram-counterpart-meta span { padding:2px 6px; border-radius:999px; + background:#eff6f4; border:1px solid #ddebe8; font-size:9px; color:#42666d; } +.ram-counterpart-actions { display:flex; gap:6px; flex:0 0 auto; } +@media(max-width:760px) { + .ram-counterpart-controls { grid-template-columns:1fr; } + .ram-counterpart-card { align-items:flex-start; flex-direction:column; } + .ram-counterpart-card-main { grid-template-columns:1fr; } + .ram-counterpart-id { flex-direction:row; gap:7px; align-items:baseline; } + .ram-counterpart-actions { width:100%; flex-wrap:wrap; } +} diff --git a/tests/inspection.R b/tests/inspection.R index c8cdbf34..d5c8b863 100644 --- a/tests/inspection.R +++ b/tests/inspection.R @@ -94,4 +94,22 @@ assert(identical(m$atom$x,c(11,12)) && assert(ram_model_count(m)==1L, "Selected model must not retain ensemble flag") assert(inherits(try(ram_model_at(pdb,3L),silent=TRUE),"try-error"), "Invalid model must fail") +seq_fixture <- data.frame( + chain=c("A","A","A","B"), + resi=c(1L,2L,3L,1L), + insertion_code=c("","","",""), + resn=c("ALA","GLY","MSE","VAL"), + phi=c(-60,-70,-80,-90), + psi=c(-40,140,150,160), + region=c("Favoured","Allowed","Allowed","Favoured"), + stringsAsFactors=FALSE +) +seq_a <- ram_chain_query_sequence(seq_fixture,"A") +assert(identical(seq_a$sequence,"AGX"), + "Counterpart search must preserve unknown residues as X.") +assert(isTRUE(all.equal(seq_a$known_fraction,2/3)), + "Known-residue fraction changed for counterpart sequence search.") +assert(identical(ram_chain_query_sequence(seq_fixture,"B")$sequence,"V"), + "Chain-specific counterpart sequence extraction changed.") + message("Inspection, alignment and structural model unit tests passed") diff --git a/tests/prediction-browser.cjs b/tests/prediction-browser.cjs index 56d98669..b61d6823 100644 --- a/tests/prediction-browser.cjs +++ b/tests/prediction-browser.cjs @@ -46,6 +46,43 @@ const puppeteer = require("puppeteer-core"); try { const page = await browser.newPage(); page.on("pageerror",error=>console.error("Browser error:",error.message)); + const counterpartSearchBodies = []; + await page.setRequestInterception(true); + page.on("request",request => { + const url=request.url(); + const cors={ + "access-control-allow-origin":"*", + "access-control-allow-methods":"GET, POST, OPTIONS", + "access-control-allow-headers":"content-type", + "content-type":"application/json" + }; + if(url==="https://search.rcsb.org/rcsbsearch/v2/query") { + if(request.method()==="OPTIONS") { + request.respond({status:204,headers:cors,body:""}); + return; + } + counterpartSearchBodies.push(JSON.parse(request.postData()||"{}")); + request.respond({status:200,headers:cors,body:JSON.stringify({ + total_count:1, + result_set:[{identifier:"1CRN_1",score:1}] + })}); + } else if(url==="https://data.rcsb.org/rest/v1/core/polymer_entity/1CRN/1") { + request.respond({status:200,headers:cors,body:JSON.stringify({ + rcsb_polymer_entity_container_identifiers:{auth_asym_ids:["A"]}, + rcsb_polymer_entity:{pdbx_description:"Crambin"}, + entity_poly:{pdbx_strand_id:"A"} + })}); + } else if(url==="https://data.rcsb.org/rest/v1/core/entry/1CRN") { + request.respond({status:200,headers:cors,body:JSON.stringify({ + struct:{title:"Water structure of crambin"}, + exptl:[{method:"X-RAY DIFFRACTION"}], + rcsb_entry_info:{resolution_combined:[1.5]}, + rcsb_accession_info:{initial_release_date:"1981-04-30"} + })}); + } else { + request.continue(); + } + }); await page.setViewport({width:1366,height:900,deviceScaleFactor:1}); await page.goto("http://127.0.0.1:8765",{ waitUntil:"networkidle2",timeout:60000}); @@ -101,6 +138,49 @@ const puppeteer = require("puppeteer-core"); "ESMFold without PAE must not invent an error map"); await page.screenshot({path:path.join(output,"prediction-esmfold.png"), fullPage:true}); + + // A predicted model can discover experimental PDB counterparts without + // leaving the RamplotR workflow. API calls are mocked here so CI tests the + // client-side request contract and one-click comparison deterministically. + await page.click("#ram-experimental-counterparts > summary"); + await page.waitForSelector("#findExperimentalStructures",{timeout:10000}); + await page.select("#experimentalIdentity","0.90"); + await page.click("#findExperimentalStructures"); + await page.waitForFunction(() => + document.querySelectorAll(".ram-counterpart-card").length===1 && + document.querySelector(".ram-counterpart-card").textContent.includes("1CRN") && + document.querySelector(".ram-counterpart-card").textContent.includes("X-RAY DIFFRACTION") && + document.querySelector(".ram-counterpart-card").textContent.includes("1.50 Å"), + {timeout:20000}); + assert.equal(counterpartSearchBodies.length,1, + "Experimental counterpart search should issue one RCSB sequence request."); + const counterpartRequest=counterpartSearchBodies[0]; + assert.equal(counterpartRequest.query.service,"sequence"); + assert.equal(counterpartRequest.query.parameters.target,"pdb_protein_sequence"); + assert.equal(counterpartRequest.query.parameters.identity_cutoff,0.9); + assert.deepEqual(counterpartRequest.request_options.results_content_type, + ["experimental"]); + const pdbeHref=await page.$eval(".ram-counterpart-card a", + node=>node.href); + assert.ok(pdbeHref.includes("ebi.ac.uk/pdbe/entry/pdb/1crn"), + "Experimental candidates should link to the current PDBe entry page."); + await page.click(".ram-experimental-compare"); + await page.waitForFunction(() => { + const active=document.querySelector(".ram-main .nav-tabs li.active a"); + const chain=document.getElementById("compareChainB"); + const rows=document.querySelectorAll("#comparison tbody tr"); + return active && active.dataset.value==="compare" && + chain && chain.value==="A" && rows.length>0; + },{timeout:45000}); + await page.waitForFunction(() => + document.querySelectorAll(".ram-change-cell").length>20, + {timeout:20000}); + await page.screenshot({ + path:path.join(output,"prediction-experimental-counterpart.png"), + fullPage:true + }); + await page.click('.nav-tabs a[data-value="plot"]'); + await page.click("#ram-sequence-panel > summary"); await page.waitForFunction(() => { const buttons = Array.from(document.querySelectorAll("#sequenceView .ram-seq-res")); diff --git a/tests/ui-contract.R b/tests/ui-contract.R index fa70e2e9..78e16160 100644 --- a/tests/ui-contract.R +++ b/tests/ui-contract.R @@ -25,6 +25,7 @@ stopifnot( grepl('src = "plotly-loader.js"', ui, fixed = TRUE), !grepl('tags$script(src = "https://cdn.plot.ly', ui, fixed = TRUE), grepl('src = "custom.js"', ui, fixed = TRUE), + grepl('src = "experimental-search.js"', ui, fixed = TRUE), grepl('class = "ram-workspace"', ui, fixed = TRUE), grepl('class = "ram-charts"', ui, fixed = TRUE), grepl("tabsetPanel(", ui, fixed = TRUE),