From 8e500187bddf7282862a7ba1b47d9df44c526a35 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:00:38 +0200 Subject: [PATCH 01/50] docs: scope Phase C geometry, wwPDB evidence, ensembles and batch reporting --- docs/phase-c-worklog.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/phase-c-worklog.md diff --git a/docs/phase-c-worklog.md b/docs/phase-c-worklog.md new file mode 100644 index 0000000..6447e07 --- /dev/null +++ b/docs/phase-c-worklog.md @@ -0,0 +1,15 @@ +# Phase C — structural verification and research workflows + +Base: `dad437d132c58452caa46950d644172e19b2eaa6` (Phase A and Phase B merged into `main`). Preserve scientific reference grids and `v0.1.0-legacy`. + +## Work packages + +- [ ] **Extended geometry**: compute interpretable omega cis/trans/twisted peptide flags and independent side-chain chi1 and Cβ geometry. Use explicit methodology and missing-value semantics; for authoritative rotamer/clash results, import wwPDB validation results rather than claim an ad hoc calculation is MolProbity-equivalent. +- [ ] **Experimental evidence**: allow optional local official wwPDB validation XML and show its residue-specific rotamer/clash/outlier annotations; link to source and show differences against the native RamplotR region. Optional NGL density-map view for local cryo-EM maps with user-controlled contours, not an unvalidated map/model fit score. +- [ ] **Ensembles**: compare coherent NMR/prediction models residue by residue using circular angle statistics, coverage and class-change summaries. Identify missing/misaligned atom records, not index-only matching. +- [ ] **Batch interface**: a scriptable offline R command, safe output directories, per-residue CSV/JSON and standalone HTML/SVG exports; consistent options and provenance. Structure and reference files remain local. +- [ ] Tests on synthetic fixtures and genuine multimeric/NMR samples, CI and docs. Keep advanced controls optional; no extra permanent tabs without need. + +## Methodological limits + +Independent wwPDB classifications and MolProbity rotamers/clashscores have different algorithms/reference populations and must not be relabelled as new native RamplotR reference classifications. Experimental B factors are not pLDDT. Map visuals are qualitative unless a validated map-fit engine is integrated. Aggregate ensemble measurements require matching residue IDs and explicitly report missing data. AlphaFold/ESMFold predictions remain supported, with their provenance retained. From df5e0f9aa4e7ccdc59a39326c33574b1a956376c Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:01:40 +0200 Subject: [PATCH 02/50] feat(geometry): add peptide omega and descriptive side-chain chi1 diagnostics --- shinyRam/R/geometry.R | 106 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 shinyRam/R/geometry.R diff --git a/shinyRam/R/geometry.R b/shinyRam/R/geometry.R new file mode 100644 index 0000000..51a9ba6 --- /dev/null +++ b/shinyRam/R/geometry.R @@ -0,0 +1,106 @@ +# Additional *diagnostic* geometry for experimental and predicted proteins. +# Reference-grade clashes and rotamer outliers come from an independent +# validation report, NOT these descriptive geometry measurements. + +ram_geom_omega_status <- function(degrees, tolerance = 30) { + out <- rep("Missing", length(degrees)) + valid <- is.finite(degrees) + out[valid] <- "Twisted" + out[valid & abs(degrees) <= tolerance] <- "Cis" + out[valid & abs(abs(degrees) - 180) <= tolerance] <- "Trans" + out +} + +ram_geom_chi1_atom <- c( + ARG="CG", ASN="CG", ASP="CG", CYS="SG", GLN="CG", GLU="CG", + HIS="CG", ILE="CG1", LEU="CG", LYS="CG", MET="CG", PHE="CG", + PRO="CG", SER="OG", THR="OG1", TRP="CG", TYR="CG", VAL="CG1" +) + +ram_extra_geometry <- function(pdb, torsions, peptide_min = 1.0, + peptide_max = 1.9) { + required <- c("chain","resno","resid","elety","x","y","z") + atoms <- pdb$atom + if (!is.data.frame(atoms) || !all(required %in% names(atoms))) + stop("A Bio3D structure with named atom coordinates is required.") + n <- nrow(torsions) + result <- data.frame( + omega=rep(NA_real_,n), peptide_bond_length=rep(NA_real_,n), + omega_status=rep("Missing",n), chi1=rep(NA_real_,n), + chi1_available=rep(FALSE,n), stringsAsFactors=FALSE) + if (!n) return(result) + if (!"insert" %in% names(atoms)) atoms$insert <- "" + if (!"alt" %in% names(atoms)) atoms$alt <- "" + atoms$insert[is.na(atoms$insert)] <- "" + atoms$alt[is.na(atoms$alt)] <- "" + atom_keys <- paste(atoms$chain, atoms$resno, atoms$insert, sep="\r") + residue_keys <- paste(torsions$chain, torsions$resi, + torsions$insertion_code, sep="\r") + # The original atom table may contain repeated atom names for alternate + # conformers. Unlabelled atoms take priority, then alternate A. + valid <- atoms$alt %in% c("", "A") & is.finite(atoms$x) & + is.finite(atoms$y) & is.finite(atoms$z) + atoms <- atoms[valid, , drop=FALSE] + atom_keys <- atom_keys[valid] + atom_keys_full <- paste(atom_keys, atoms$elety, sep="\r") + ord <- order(atoms$alt != "", seq_len(nrow(atoms))) + first <- ord[!duplicated(atom_keys_full[ord])] + atoms <- atoms[first, , drop=FALSE] + atom_keys_full <- atom_keys_full[first] + get_atom <- function(residue, atom) { + keys <- paste(residue_keys, atom, sep="\r") + idx <- match(keys, atom_keys_full) + coordinates <- matrix(NA_real_, nrow=n, ncol=3L) + ok <- which(!is.na(idx)) + if (length(ok)) + coordinates[ok, ] <- as.matrix(atoms[idx[ok],c("x","y","z"),drop=FALSE]) + coordinates + } + ca <- get_atom(seq_len(n), "CA") + c_atom <- get_atom(seq_len(n), "C") + n_atom <- get_atom(seq_len(n), "N") + cb <- get_atom(seq_len(n), "CB") + chi_target <- unname(ram_geom_chi1_atom[as.character(torsions$resn)]) + target <- matrix(NA_real_, nrow=n, ncol=3L) + for (atom_name in unique(chi_target[!is.na(chi_target)])) { + rows <- which(chi_target == atom_name & !is.na(chi_target)) + target[rows, ] <- get_atom(seq_len(n),atom_name)[rows,,drop=FALSE] + } + chi_rows <- which(complete.cases(cbind(n_atom,ca,cb,target))) + if (length(chi_rows)) { + result$chi1[chi_rows] <- ram_dihedral_batch( + n_atom[chi_rows,,drop=FALSE],ca[chi_rows,,drop=FALSE], + cb[chi_rows,,drop=FALSE],target[chi_rows,,drop=FALSE]) + result$chi1_available[chi_rows] <- is.finite(result$chi1[chi_rows]) + } + if (n>1L && "bonded_to_next" %in% names(torsions)) { + rows <- which(torsions$bonded_to_next[seq_len(n-1L)] %in% TRUE) + if (length(rows)) { + j <- rows+1L + distance <- sqrt(rowSums((c_atom[rows,,drop=FALSE]- + n_atom[j,,drop=FALSE])^2)) + valid_pair <- is.finite(distance) & distance>=peptide_min & + distance<=peptide_max & + complete.cases(cbind(ca[rows,,drop=FALSE], + c_atom[rows,,drop=FALSE], n_atom[j,,drop=FALSE], + ca[j,,drop=FALSE])) + rows <- rows[valid_pair]; j <- j[valid_pair] + if(length(rows)) { + result$peptide_bond_length[rows] <- + sqrt(rowSums((c_atom[rows,,drop=FALSE]- + n_atom[j,,drop=FALSE])^2)) + result$omega[rows] <- ram_dihedral_batch( + ca[rows,,drop=FALSE],c_atom[rows,,drop=FALSE], + n_atom[j,,drop=FALSE],ca[j,,drop=FALSE]) + } + } + } + result$omega_status <- ram_geom_omega_status(result$omega) + result +} + +ram_join_geometry <- function(classified, geometry) { + if (nrow(classified)!=nrow(geometry)) + stop("Extra-geometry rows must match the extracted backbone rows.") + cbind(classified,geometry) +} From f24e5385d7712f4f970ea42184ab4af61b2a1e36 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:02:04 +0200 Subject: [PATCH 03/50] test(geometry): verify omega, chi1, termini and missing-atom handling --- tests/geometry.R | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/geometry.R diff --git a/tests/geometry.R b/tests/geometry.R new file mode 100644 index 0000000..7834c36 --- /dev/null +++ b/tests/geometry.R @@ -0,0 +1,48 @@ +# Reproducible Phase C native diagnostic geometry. +source("shinyRam/R/backbone.R") +source("shinyRam/R/geometry.R") +assert <- function(x, message) if(!isTRUE(x)) stop(message,call.=FALSE) +stopifnot(identical(ram_geom_omega_status(c(NA,-179,0,180,74)), + c("Missing","Trans","Cis","Trans","Twisted"))) +# Synthetic first residue has a complete chi1, peptide omega to the second. +residue <- function(chain, no, name, insert, atoms, coords) { + data.frame(chain=chain,resno=no,resid=name,insert=insert, + elety=atoms,alt="",x=coords[,1],y=coords[,2],z=coords[,3], + stringsAsFactors=FALSE) +} +first <- residue("A",1,"SER","",c("N","CA","C","CB","OG"), + rbind(c(0,0,0),c(1,0,0),c(1,1,0),c(1,-1,0),c(2,-1,1))) +second <- residue("A",2,"PRO","",c("N","CA","C","CB","CG"), + rbind(c(1,2.4,0),c(2,2.4,1),c(3,2.4,1),c(2,1.4,1),c(3,1.4,2))) +pdb <- list(atom=rbind(first,second)) +torsion <- ram_extract_torsions(pdb) +assert(nrow(torsion)==2L && torsion$bonded_to_next[[1]], + "Peptide bond in synthetic geometry was lost.") +geom <- ram_extra_geometry(pdb,torsion) +assert(nrow(geom)==2L && geom$chi1_available[[1]] && + geom$chi1_available[[2]] && is.finite(geom$omega[[1]]) && + is.finite(geom$peptide_bond_length[[1]]), + "Complete peptide/side chain must yield finite diagnostic angles.") +assert(is.na(geom$omega[[2]]) && geom$omega_status[[2]]=="Missing", + "Termini must not be assigned peptide omega.") +# Break between chains even if atom coordinates remain chemically close. +other <- torsion +other$bonded_to_next[] <- FALSE +disconnected <- ram_extra_geometry(pdb,other) +assert(all(is.na(disconnected$omega)), + "Peptide geometry must not cross an unconnected residue boundary.") +# Missing sidechain atoms produce unknown chi1, not a fake rotamer outlier. +deleted <- pdb +deleted$atom <- deleted$atom[deleted$atom$elety!="OG",,drop=FALSE] +missing <- ram_extra_geometry(deleted,torsion) +assert(is.na(missing$chi1[[1]]) && !missing$chi1_available[[1]], + "Missing side-chain atoms must remain explicitly unclassified.") +# Insertion-code and chain identifiers are preserved by the backbone join. +joined <- ram_join_geometry(torsion,geom) +assert(nrow(joined)==nrow(torsion) && + identical(joined$chain,torsion$chain), + "Geometry joins must preserve backbone row order.") +assert(inherits(try(ram_join_geometry(torsion,geom[1,,drop=FALSE]), + silent=TRUE),"try-error"), + "Misaligned geometry tables must be rejected.") +message("Descriptive peptide/side-chain geometry checks passed.") From b70a7316eec6e6e2eed085381e9c265023ea93eb Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:02:37 +0200 Subject: [PATCH 04/50] feat(evidence): import official wwPDB per-residue rotamers, clashes, bond and map-fit annotations --- shinyRam/R/experimental.R | 93 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 shinyRam/R/experimental.R diff --git a/shinyRam/R/experimental.R b/shinyRam/R/experimental.R new file mode 100644 index 0000000..995bba4 --- /dev/null +++ b/shinyRam/R/experimental.R @@ -0,0 +1,93 @@ +# Import external wwPDB/MolProbity-backed diagnostics without changing +# RamplotR's original reference grids or assigning our own rotamer labels. +# xml2 is optional and used only when a user supplies an official report. + +ram_external_validation_read <- function(path, max_bytes=32000000L, + max_residues=100000L) { + if (!requireNamespace("xml2",quietly=TRUE)) + stop("Install xml2 to read official wwPDB validation XML.") + if (!file.exists(path) || !is.finite(file.info(path)$size) || + file.info(path)$size <= 0 || file.info(path)$size > max_bytes) + stop("Provide a nonempty wwPDB validation XML (or XML.gz), 32 MB maximum.") + raw <- readBin(path,what="raw",n=file.info(path)$size) + if (grepl("\\.gz$",path,ignore.case=TRUE)) + raw <- memDecompress(raw,type="gzip") + if (length(raw) > max_bytes) + stop("The uncompressed wwPDB report exceeds the 32 MB limit.") + doc <- xml2::read_xml(raw) + nodes <- xml2::xml_find_all(doc,".//*[local-name()='ModelledSubgroup']") + if (!length(nodes) || length(nodes) > max_residues) + stop("No supported residue records found, or report exceeds the size limit.") + attr <- function(name) { + z <- trimws(xml2::xml_attr(nodes,name)) + z[z %in% c("",".","?")] <- NA_character_ + z + } + count <- function(tag) vapply(nodes,function(node) + length(xml2::xml_find_all(node, + sprintf("./*[local-name()='%s']",tag))),integer(1)) + rota <- tolower(attr("rota")) + rama <- tolower(attr("rama")) + rama[rama=="favoured"] <- "favored" + # These labels originate from the official wwPDB report, not RamplotR. + records <- data.frame( + model=attr("model"),chain=attr("chain"), + resi=suppressWarnings(as.integer(attr("resnum"))), + insertion_code=attr("icode"),resn=toupper(attr("resname")), + altcode=attr("altcode"),wwpdb_rama=rama,wwpdb_rotamer=rota, + wwpdb_clashes=count("clash"),wwpdb_symmetry_clashes=count("symm-clash"), + wwpdb_bond_outliers=count("bond-outlier"), + wwpdb_angle_outliers=count("angle-outlier"), + wwpdb_rscc=suppressWarnings(as.numeric(attr("rscc"))), + wwpdb_rsrz=suppressWarnings(as.numeric(attr("rsrz"))), + stringsAsFactors=FALSE + ) + records$chain[is.na(records$chain)] <- "" + records$insertion_code[is.na(records$insertion_code)] <- "" + records$altcode[is.na(records$altcode)] <- "" + records +} + +ram_external_validation_join <- function(residues, official, model=1L) { + required <- c("chain","resi","insertion_code","resn") + if (!all(required %in% names(residues)) || + !all(c(required,"model","altcode") %in% names(official))) + stop("Residue identifiers and a wwPDB model number are required.") + other <- official[!is.na(official$model) & + official$model==as.character(model) & !is.na(official$resi),,drop=FALSE] + # wwPDB may list multiple alternate conformers. Prefer blank over A, + # and reject same-priority ambiguity rather than fabricating a match. + if(nrow(other)) { + pref <- ifelse(other$altcode %in% c("",".","?"),0L, + ifelse(other$altcode=="A",1L,2L)) + other <- other[order(pref),,drop=FALSE] + } + key <- function(x) paste(x$chain,x$resi,x$insertion_code, + toupper(x$resn),sep="\r") + ids <- key(other) + other <- other[!duplicated(ids),,drop=FALSE] + ids <- unique(ids) + rows <- match(key(residues),ids) + out <- residues + fields <- grep("^wwpdb_",names(official),value=TRUE) + for(field in fields) out[[field]] <- other[[field]][rows] + out$wwpdb_matched <- !is.na(rows) + out$wwpdb_report_model <- rep(as.integer(model),nrow(out)) + out +} + +ram_external_validation_summary <- function(data) { + if (!"wwpdb_matched" %in% names(data)) return(NULL) + matched <- data$wwpdb_matched %in% TRUE + counts <- function(column, predicate=function(x) x>0) { + if (!column %in% names(data)) return(NA_integer_) + sum(matched & !is.na(data[[column]]) & predicate(data[[column]])) + } + list(matched=sum(matched),total=nrow(data), + official_rama_outliers=counts("wwpdb_rama",function(x) x=="outlier"), + official_rotamer_outliers=counts("wwpdb_rotamer", + function(x) x %in% c("outlier","outliers")), + residues_with_clashes=counts("wwpdb_clashes"), + residues_with_bond_outliers=counts("wwpdb_bond_outliers"), + residues_with_angle_outliers=counts("wwpdb_angle_outliers")) +} From befea365afbd66903da6b6e303f4ff895d52d10e Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:03:04 +0200 Subject: [PATCH 05/50] test: add synthetic schema-shaped external geometry report --- validation/fixtures/synthetic-geometry.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 validation/fixtures/synthetic-geometry.xml diff --git a/validation/fixtures/synthetic-geometry.xml b/validation/fixtures/synthetic-geometry.xml new file mode 100644 index 0000000..ecba033 --- /dev/null +++ b/validation/fixtures/synthetic-geometry.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + From 196e0c195fe0848c032a87d7af822d4f027247aa Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:03:07 +0200 Subject: [PATCH 06/50] test(evidence): verify model/alt-location/insertion-aware wwPDB import --- tests/experimental.R | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/experimental.R diff --git a/tests/experimental.R b/tests/experimental.R new file mode 100644 index 0000000..8effec0 --- /dev/null +++ b/tests/experimental.R @@ -0,0 +1,35 @@ +source("shinyRam/R/experimental.R") +assert <- function(x,msg) if(!isTRUE(x)) stop(msg,call.=FALSE) +if(!requireNamespace("xml2",quietly=TRUE)) + stop("Install xml2 to run independent-validation import tests.") +source_file <- "validation/fixtures/synthetic-geometry.xml" +official <- ram_external_validation_read(source_file) +assert(nrow(official)==4L, "Official XML residue extraction lost entries.") +assert(official$wwpdb_clashes[[1]]==2L && + official$wwpdb_bond_outliers[[1]]==1L && + official$wwpdb_angle_outliers[[1]]==1L && + official$wwpdb_rotamer[[1]]=="outlier", + "Independent wwPDB subelements and rotamer labels must be preserved.") +ours <- data.frame(chain=c("A","A","B"),resi=c(1L,2L,1L), + insertion_code=c("","A",""),resn=c("SER","THR","ALA"), + phi=c(-70,-110,100),stringsAsFactors=FALSE) +joined <- ram_external_validation_join(ours,official,model=1L) +assert(identical(joined$wwpdb_matched,c(TRUE,TRUE,FALSE)), + "Independent report must match chain, insertion code and resname.") +assert(identical(joined$wwpdb_rama[1:2],c("favored","allowed")) && + joined$wwpdb_rotamer[[2]]=="outlier" && + is.na(joined$wwpdb_rama[[3]]), + "Unlabelled conformers must outrank alt A and missing IDs stay missing.") +assert(joined$wwpdb_symmetry_clashes[[2]]==1L, + "Symmetry clashes must be distinguished from local contacts.") +m2 <- ram_external_validation_join(ours,official,model=2L) +assert(m2$wwpdb_rama[[1]]=="outlier" && + !any(m2$wwpdb_matched[2:3]), + "Validation of one model must never silently mix NMR models.") +summary <- ram_external_validation_summary(joined) +assert(summary$matched==2L && summary$official_rotamer_outliers==2L && + summary$residues_with_clashes==1L, + "Independent report counts must use only matched residues.") +bad <- try(ram_external_validation_read(source_file,max_bytes=8L),silent=TRUE) +assert(inherits(bad,"try-error"),"Oversized XML must be rejected.") +message("External wwPDB geometry/rotamer/clash XML import tests passed.") From c25715aa0b6856d1e6f546f13e681f514a1e6104 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:03:47 +0200 Subject: [PATCH 07/50] feat(ensemble): compare model geometry by residue with circular statistics and class consistency --- shinyRam/R/ensemble.R | 113 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 shinyRam/R/ensemble.R diff --git a/shinyRam/R/ensemble.R b/shinyRam/R/ensemble.R new file mode 100644 index 0000000..bab09ce --- /dev/null +++ b/shinyRam/R/ensemble.R @@ -0,0 +1,113 @@ +# Ensemble diagnostics. All comparisons use residue identifiers and circular +# angular statistics; an average of +179° and -179° is 180°, not zero. +# Does not infer prediction confidence or a single "best" model. + +ram_ensemble_circular <- function(values) { + z <- as.numeric(values[is.finite(values)]) + if(!length(z)) return(c(mean=NA_real_,sd=NA_real_)) + if(length(z)==1L) return(c(mean=z[[1L]],sd=NA_real_)) + radians <- z*pi/180 + x <- mean(cos(radians)); y <- mean(sin(radians)) + length_mean <- sqrt(x*x+y*y) + mean_angle <- if(length_mean < 1e-8) NA_real_ else + ((atan2(y,x)*180/pi+180) %% 360) - 180 + sd <- if(length_mean < 1e-8) NA_real_ else + sqrt(-2*log(min(1,length_mean)))*180/pi + c(mean=mean_angle,sd=sd) +} + +ram_ensemble_key <- function(data) { + paste(as.character(data$chain),data$resi, + as.character(data$insertion_code), + toupper(as.character(data$resn)),sep="\r") +} + +ram_ensemble_summary <- function(models) { + if(!is.list(models) || !length(models)) + stop("Provide at least one model's residue table.") + required <- c("chain","resi","insertion_code","resn","phi","psi","region") + for(tbl in models) { + if(!is.data.frame(tbl) || !all(required %in% names(tbl))) + stop("Every model needs identifiers, phi/psi angles and classifications.") + if(anyDuplicated(ram_ensemble_key(tbl))) + stop("Duplicate residue identifiers in an ensemble model.") + } + keys <- lapply(models,ram_ensemble_key) + all_keys <- unique(unlist(keys,use.names=FALSE)) + n <- length(all_keys) + nmodels <- length(models) + if(!n) return(data.frame(chain=character(),resi=integer(), + insertion_code=character(),resn=character(),models_present=integer(), + phi_models=integer(),psi_models=integer(), + phi_mean=numeric(),phi_sd=numeric(),psi_mean=numeric(),psi_sd=numeric(), + classified_models=integer(),class_consistency=numeric(), + region_mode=character(),changes_class=logical(), + stringsAsFactors=FALSE)) + get <- function(col, type="numeric") { + result <- matrix(if(type=="character") NA_character_ else NA_real_, + nrow=n,ncol=nmodels) + for(i in seq_along(models)) { + idx <- match(all_keys,keys[[i]]) + good <- which(!is.na(idx)) + if(length(good)) result[good,i] <- models[[i]][[col]][idx[good]] + } + result + } + phi <- get("phi");psi <- get("psi");region <- get("region","character") + name_rows <- data.frame(chain=character(),resi=integer(), + insertion_code=character(),resn=character(),stringsAsFactors=FALSE) + # Use the first occurrence of each ID across all models, not row number. + ids <- do.call(rbind,lapply(models,function(x) + x[,required[1:4],drop=FALSE])) + first <- !duplicated(unlist(keys,use.names=FALSE)) + info <- ids[first,,drop=FALSE] + info <- info[match(all_keys,unlist(keys,use.names=FALSE)[first]),,drop=FALSE] + calc <- function(matrix_values) { + result <- t(vapply(seq_len(n),function(i) + ram_ensemble_circular(matrix_values[i,]),numeric(2))) + colnames(result) <- c("mean","sd") + result + } + ph <- calc(phi);ps <- calc(psi) + region_mode <- vapply(seq_len(n),function(i) { + values <- region[i,]; values <- values[!is.na(values)] + if(!length(values)) NA_character_ else + names(sort(table(values),decreasing=TRUE))[[1L]] + },character(1)) + agreement <- vapply(seq_len(n),function(i) { + values <- region[i,];values <- values[!is.na(values)] + if(!length(values)) NA_real_ else max(table(values))/length(values) + },numeric(1)) + out <- data.frame(info, + models_present=as.integer(rowSums(!is.na(phi)|!is.na(psi))), + phi_models=as.integer(rowSums(is.finite(phi))), + psi_models=as.integer(rowSums(is.finite(psi))), + phi_mean=ph[,"mean"],phi_sd=ph[,"sd"], + psi_mean=ps[,"mean"],psi_sd=ps[,"sd"], + classified_models=as.integer(rowSums(!is.na(region))), + class_consistency=as.numeric(agreement),region_mode=region_mode, + changes_class=!is.na(agreement) & agreement<1, + stringsAsFactors=FALSE,check.names=FALSE) + out[order(-as.integer(out$changes_class), + -pmax(replace(out$phi_sd,is.na(out$phi_sd),0), + replace(out$psi_sd,is.na(out$psi_sd),0)), + out$chain,out$resi,out$insertion_code),,drop=FALSE] +} + +ram_ensemble_analyze <- function(pdb,classifier,max_models=30L) { + if(!exists("ram_model_count",mode="function") || + !exists("ram_model_at",mode="function") || + !exists("ram_extract_torsions",mode="function")) + stop("Load structure IO and backbone functions first.") + total <- ram_model_count(pdb) + count <- min(total,as.integer(max_models)) + if(is.na(count) || count < 1L || count>100L) + stop("Analyze between 1 and 100 structural models.") + result <- lapply(seq_len(count),function(i) { + single <- ram_model_at(pdb,i) + torsions <- ram_extract_torsions(single) + classifier(torsions) + }) + list(summary=ram_ensemble_summary(result),analyzed_models=count, + available_models=total,limited=count Date: Mon, 28 Sep 2026 23:04:09 +0200 Subject: [PATCH 08/50] test(ensemble): guard angular wrap, model matching and incomplete coverage --- tests/ensemble.R | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/ensemble.R diff --git a/tests/ensemble.R b/tests/ensemble.R new file mode 100644 index 0000000..6bfa1a4 --- /dev/null +++ b/tests/ensemble.R @@ -0,0 +1,33 @@ +source("shinyRam/R/ensemble.R") +assert <- function(x,msg) if(!isTRUE(x)) stop(msg,call.=FALSE) +mean <- ram_ensemble_circular(c(179,-179)) +assert(abs(abs(mean[["mean"]])-180)<2 && mean[["sd"]]<2, + "Circular angle statistics must not average across the -180/180 seam.") +assert(is.na(ram_ensemble_circular(c(NA_real_))[["mean"]]) && + is.na(ram_ensemble_circular(c(179))[["sd"]]), + "Missing observations and one-model uncertainty must remain explicit.") +m <- data.frame(chain="A",resi=c(1L,2L),insertion_code="", + resn=c("SER","PRO"),phi=c(179,-60),psi=c(-179,145), + region=c("Favoured","Allowed"),stringsAsFactors=FALSE) +other <- m[2:1,,drop=FALSE] +other$phi[other$resi==1L] <- -179 +other$psi[other$resi==1L] <- 179 +other$region[other$resi==2L] <- "Not allowed" +out <- ram_ensemble_summary(list(m,other)) +one <- out[out$resi==1L,,drop=FALSE] +two <- out[out$resi==2L,,drop=FALSE] +assert(one$phi_models==2L && one$psi_models==2L && + abs(abs(one$phi_mean)-180)<2 && one$phi_sd<2 && + !one$changes_class,"Ensemble must match rows by residue identity.") +assert(two$changes_class && two$class_consistency==0.5 && + two$classified_models==2L, + "Classification changes across models must be explicit.") +only_one <- other[other$resi!=1L,,drop=FALSE] +partial <- ram_ensemble_summary(list(m,only_one)) +p <- partial[partial$resi==1L,,drop=FALSE] +assert(p$models_present==1L && p$phi_models==1L && + is.na(p$phi_sd),"A residue missing in model 2 must not be fabricated.") +bad <- rbind(m,m[1,,drop=FALSE]) +assert(inherits(try(ram_ensemble_summary(list(bad)),silent=TRUE), + "try-error"),"Ambiguous IDs must be rejected.") +message("Circular ensemble geometry and residue-alignment tests passed.") From aa2126e1c332e480c1faa5878da5ad42a7881159 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:04:26 +0200 Subject: [PATCH 09/50] fix(ensemble): count model presence independently of defined torsions --- shinyRam/R/ensemble.R | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/shinyRam/R/ensemble.R b/shinyRam/R/ensemble.R index bab09ce..1d35b97 100644 --- a/shinyRam/R/ensemble.R +++ b/shinyRam/R/ensemble.R @@ -36,6 +36,9 @@ ram_ensemble_summary <- function(models) { all_keys <- unique(unlist(keys,use.names=FALSE)) n <- length(all_keys) nmodels <- length(models) + membership <- matrix(FALSE,nrow=n,ncol=nmodels) + for(i in seq_along(models)) + membership[,i] <- !is.na(match(all_keys,keys[[i]])) if(!n) return(data.frame(chain=character(),resi=integer(), insertion_code=character(),resn=character(),models_present=integer(), phi_models=integer(),psi_models=integer(), @@ -54,8 +57,6 @@ ram_ensemble_summary <- function(models) { result } phi <- get("phi");psi <- get("psi");region <- get("region","character") - name_rows <- data.frame(chain=character(),resi=integer(), - insertion_code=character(),resn=character(),stringsAsFactors=FALSE) # Use the first occurrence of each ID across all models, not row number. ids <- do.call(rbind,lapply(models,function(x) x[,required[1:4],drop=FALSE])) @@ -79,7 +80,7 @@ ram_ensemble_summary <- function(models) { if(!length(values)) NA_real_ else max(table(values))/length(values) },numeric(1)) out <- data.frame(info, - models_present=as.integer(rowSums(!is.na(phi)|!is.na(psi))), + models_present=as.integer(rowSums(membership)), phi_models=as.integer(rowSums(is.finite(phi))), psi_models=as.integer(rowSums(is.finite(psi))), phi_mean=ph[,"mean"],phi_sd=ph[,"sd"], From db72fe02edd17e0d093e0c0cfe4f11482fd69819 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:05:16 +0200 Subject: [PATCH 10/50] feat(batch): add offline reproducible multi-structure runner with provenance and exports --- shinyRam/R/batch.R | 164 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 shinyRam/R/batch.R diff --git a/shinyRam/R/batch.R b/shinyRam/R/batch.R new file mode 100644 index 0000000..a7e5515 --- /dev/null +++ b/shinyRam/R/batch.R @@ -0,0 +1,164 @@ +# Local, reproducible batch analysis. No external service is contacted by default. +# The CLI entry point is scripts/ramplotr-batch.R. +ram_batch_options <- function(args) { + keys <- c("input","output","reference","background","mode","model", + "max-files","prediction-source","validation-xml","ensemble-models") + flags <- c("report","no-json","overwrite","help") + out <- list(input=NULL,output=NULL,reference="original", + background="General",mode="residue",model=1L,max_files=100L, + prediction_source="experimental",validation_xml=NULL, + ensemble_models=0L,report=FALSE,json=TRUE,overwrite=FALSE,help=FALSE) + i <- 1L + while(i<=length(args)) { + flag <- sub("^--","",args[[i]]) + if (!startsWith(args[[i]],"--") || !flag %in% c(keys,flags)) + stop("Unknown batch option: ",args[[i]],call.=FALSE) + if(flag %in% flags) { + name <- switch(flag,"no-json"="json",flag) + out[[name]] <- flag!="no-json" + } else { + if(i==length(args) || startsWith(args[[i+1L]],"--")) + stop("Missing value for --",flag,call.=FALSE) + value <- args[[i+1L]] + name <- switch(flag,"max-files"="max_files", + "prediction-source"="prediction_source", + "validation-xml"="validation_xml", + "ensemble-models"="ensemble_models",flag) + out[[name]] <- value + i <- i+1L + } + i <- i+1L + } + if(out$help) return(out) + if(is.null(out$input) || is.null(out$output)) + stop("Both --input and --output are required. See --help.") + if(!out$reference %in% c("original","alphafold","alphafold_filtered", + "astral2.08","custom_high_resolution")) + stop("Unknown reference distribution.") + if(!out$background %in% c("General","GLY","PRO","preProline")) + stop("Unknown plotting background.") + if(!out$mode %in% c("residue","legacy")) stop("Invalid classification mode.") + if(!out$prediction_source %in% c("experimental","alphafold_db", + "alphafold2","alphafold3","esmfold","other_prediction")) + stop("Unknown prediction provenance.") + for(field in c("model","max_files","ensemble_models")) { + value <- suppressWarnings(as.integer(out[[field]])) + if(length(value)!=1L || is.na(value) || + valueif(field=="max_files") 1000L else 100L) + stop("Invalid value for ",field) + out[[field]] <- value + } + if(!is.null(out$validation_xml) && dir.exists(out$input)) + stop("--validation-xml requires one structure file to avoid ambiguous matching.") + out +} + +ram_batch_files <- function(input,max_files=100L) { + if(dir.exists(input)) { + files <- list.files(input,pattern="\\.(pdb|ent|cif|mmcif|mcif)$", + full.names=TRUE,ignore.case=TRUE,recursive=FALSE) + } else if(file.exists(input)) { + ram_detect_format(input) + files <- input + } else stop("Input path does not exist.") + files <- sort(files) + if(!length(files) || length(files)>max_files) + stop("No supported structures, or more than ",max_files," input files.") + normalizePath(files,mustWork=TRUE) +} + +ram_batch_run <- function(options,repo_root=".") { + if(!requireNamespace("bio3d",quietly=TRUE)) + stop("Install Bio3D before running the batch analysis.") + files <- ram_batch_files(options$input,options$max_files) + destination <- normalizePath(options$output,mustWork=FALSE) + dir.create(destination,recursive=TRUE,showWarnings=FALSE) + if(!dir.exists(destination)) stop("Unable to create output directory.") + refs <- file.path(repo_root,"shinyRam","static",options$reference) + reference_file <- file.path(refs,options$background) + background <- ram_read_reference(reference_file) + official <- if(!is.null(options$validation_xml)) + ram_external_validation_read(options$validation_xml) else NULL + source_names <- make.unique(gsub("[^A-Za-z0-9._-]","_", + basename(files))) + summary <- vector("list",length(files)) + for(i in seq_along(files)) { + input <- files[[i]] + prefix <- source_names[[i]] + summary[[i]] <- tryCatch({ + base <- file.path(destination,prefix) + targets <- c(paste0(base,".residues.csv"), + if(options$json) paste0(base,".json"), + if(options$report) c(paste0(base,".svg"), + paste0(base,".html")), + if(options$ensemble_models>0L) paste0(base,".ensemble.csv")) + if(!options$overwrite && any(file.exists(targets))) + stop("Output already exists; use --overwrite to replace it.") + pdb <- ram_load_structure(path=input,original_name=basename(input)) + selected <- ram_model_at(pdb,options$model) + backbone <- ram_extract_torsions(selected) + classified <- ram_classify_torsions(backbone,refs,background, + options$mode,threshold_fn=ram_density_thresholds) + diagnostic <- ram_extra_geometry(selected,backbone) + classified <- ram_join_geometry(classified,diagnostic) + if(options$prediction_source!="experimental") { + confidence <- ram_prediction_from_atoms(selected,backbone, + options$prediction_source) + classified$plddt <- confidence$plddt + classified$confidence_category <- confidence$confidence_category + } + if(!is.null(official)) classified <- + ram_external_validation_join(classified,official,options$model) + metadata <- ram_report_metadata(basename(input),options$reference, + options$background,options$mode,options$model,reference_file) + metadata$input_md5 <- unname(tools::md5sum(input)) + metadata$prediction_provenance <- options$prediction_source + metadata$native_diagnostics <- "peptide omega and descriptive chi1" + metadata$independent_wwpdb <- if(is.null(official)) "none" else + basename(options$validation_xml) + if(!is.null(official)) metadata$independent_wwpdb_md5 <- + unname(tools::md5sum(options$validation_xml)) + utils::write.csv(classified,paste0(base,".residues.csv"), + row.names=FALSE,na="") + ens <- NULL + if(options$ensemble_models>0L && ram_model_count(pdb)>1L) { + ens <- ram_ensemble_analyze(pdb,classifier=function(torsions) + ram_classify_torsions(torsions,refs,background,options$mode, + threshold_fn=ram_density_thresholds), + max_models=options$ensemble_models) + utils::write.csv(ens$summary,paste0(base,".ensemble.csv"), + row.names=FALSE,na="") + metadata$ensemble_analyzed <- ens$analyzed_models + metadata$ensemble_available <- ens$available_models + } + if(options$json) { + if(!requireNamespace("jsonlite",quietly=TRUE)) + stop("Install jsonlite or use --no-json.") + jsonlite::write_json(list(metadata=metadata, + residues=classified,ensemble=if(is.null(ens)) NULL else ens$summary, + independent_summary=ram_external_validation_summary(classified)), + paste0(base,".json"),pretty=TRUE,auto_unbox=TRUE,na="null", + dataframe="rows",digits=8) + } + if(options$report) { + svg <- paste0(base,".svg") + ram_save_figure(svg,classified,background, + c("#f1faf8","#b7d6ce","#69ada4","#126c73"), + chain_colors=c(),format="svg",title=basename(input)) + ram_save_html_report(paste0(base,".html"),classified,metadata,svg) + } + data.frame(input=basename(input),status="ok", + atoms=nrow(selected$atom),residues=nrow(classified), + classified=sum(!is.na(classified$region)),error="",stringsAsFactors=FALSE) + },error=function(e) data.frame(input=basename(input),status="error", + atoms=NA_integer_,residues=NA_integer_,classified=NA_integer_, + error=conditionMessage(e),stringsAsFactors=FALSE)) + } + tab <- do.call(rbind,summary) + utils::write.csv(tab,file.path(destination,"batch-summary.csv"), + row.names=FALSE) + if(any(tab$status=="error")) + warning("Some structures failed; inspect batch-summary.csv.") + tab +} From 7cbbe3e13e025217e01c544b462d9c63ac6b24cc Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:05:40 +0200 Subject: [PATCH 11/50] cli: expose offline structure batch processing with optional official validation and ensemble reports --- scripts/ramplotr-batch.R | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 scripts/ramplotr-batch.R diff --git a/scripts/ramplotr-batch.R b/scripts/ramplotr-batch.R new file mode 100644 index 0000000..b2748ed --- /dev/null +++ b/scripts/ramplotr-batch.R @@ -0,0 +1,35 @@ +#!/usr/bin/env Rscript +# From the repository root: +# Rscript scripts/ramplotr-batch.R --input structures/ --output results/ \ +# --reference original --model 1 --report --ensemble-models 20 +help <- paste( + "RamplotR batch analysis (local structures; no network requests).", + "Usage: Rscript scripts/ramplotr-batch.R --input FILE_OR_DIRECTORY --output DIR", + " [--reference original|alphafold|alphafold_filtered|astral2.08|custom_high_resolution]", + " [--background General|GLY|PRO|preProline] [--mode residue|legacy]", + " [--model N] [--ensemble-models N] [--max-files N]", + " [--prediction-source experimental|alphafold2|alphafold3|esmfold|other_prediction]", + " [--validation-xml PATH] [--report] [--no-json] [--overwrite]", + "", + "CSV output includes native diagnostic omega/chi1 angles. Rotamer/clash", + "annotations are exclusively from the optional independent wwPDB XML.", + "For ensemble analysis, the first N consistent atom-record models are analysed.", + sep="\n" +) +required <- c("shinyRam/R/io.R","shinyRam/R/batch.R") +if(!all(file.exists(required))) + stop("Run this script from the RamplotR repository root.",call.=FALSE) +for(filename in c("io","backbone","ramachandran","geometry","experimental", + "ensemble","predictions","reports","batch")) + source(file.path("shinyRam","R",paste0(filename,".R"))) +args <- commandArgs(trailingOnly=TRUE) +if("--help" %in% args) {cat(help,"\n");quit(status=0L)} +options <- tryCatch(ram_batch_options(args),error=function(e) { + cat("Error:",conditionMessage(e),"\n\n",help,"\n",file=stderr()) + quit(status=2L) +}) +if(options$json && !requireNamespace("jsonlite",quietly=TRUE)) + stop("Install jsonlite for machine-readable JSON, or supply --no-json.") +results <- ram_batch_run(options,repo_root=".") +print(results,row.names=FALSE) +quit(status=if(any(results$status=="error")) 1L else 0L) From c0cd1db0b337003749999bce5c058a220e795a59 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:06:04 +0200 Subject: [PATCH 12/50] test(cli): verify argument validation, offline discovery and resource guards --- tests/batch.R | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/batch.R diff --git a/tests/batch.R b/tests/batch.R new file mode 100644 index 0000000..0b840a7 --- /dev/null +++ b/tests/batch.R @@ -0,0 +1,29 @@ +source("shinyRam/R/io.R") +source("shinyRam/R/batch.R") +assert <- function(x,msg) if(!isTRUE(x)) stop(msg,call.=FALSE) +opts <- ram_batch_options(c("--input","/tmp/one.pdb","--output","/tmp/result", + "--reference","original","--report","--ensemble-models","2", + "--prediction-source","esmfold")) +assert(opts$model==1L && opts$ensemble_models==2L && opts$report && + opts$json && opts$prediction_source=="esmfold", + "Batch argument parsing lost scientific defaults.") +assert(!ram_batch_options(c("--input","input.pdb","--output","out", + "--no-json"))$json,"JSON must be optional.") +reject <- function(args) inherits(try(ram_batch_options(args),silent=TRUE), + "try-error") +assert(reject(c("--input","a","--output","b","--mode","invented")), + "Unknown classification modes must be rejected.") +assert(reject(c("--input","a","--output","b","--max-files","50000")), + "Resource limits must be enforced.") +assert(reject(c("--input","a","--output","b","--prediction-source","experimental", + "--validation-xml","x","--bad-option")), + "Unknown options must be rejected.") +temp <- tempfile("ram-batch-");dir.create(temp) +file.create(file.path(temp,c("B.pdb","a.cif","notes.txt"))) +files <- ram_batch_files(temp) +assert(identical(basename(files),sort(c("B.pdb","a.cif"))), + "Only supported local structure files may enter a batch.") +assert(inherits(try(ram_batch_files(temp,max_files=1),silent=TRUE), + "try-error"),"Max-file guard must apply to directories.") +unlink(temp,recursive=TRUE) +message("Batch CLI argument and local file-discovery tests passed.") From 762eb485e283b2d3da363f9deedaaed877125c44 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:07:15 +0200 Subject: [PATCH 13/50] ci: exercise Phase C geometry, wwPDB evidence, ensembles and CLI on Windows/Linux --- .github/workflows/scientific-tests.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/scientific-tests.yml b/.github/workflows/scientific-tests.yml index 6c30221..5eca077 100644 --- a/.github/workflows/scientific-tests.yml +++ b/.github/workflows/scientific-tests.yml @@ -35,6 +35,16 @@ jobs: node tests/ui.test.cjs node tests/prediction-ui.test.cjs shell: bash + - name: Install optional official wwPDB XML parser + run: Rscript -e 'install.packages("xml2")' + shell: bash + - name: Verify Phase C geometry, ensembles and offline CLI + run: | + Rscript tests/geometry.R + Rscript tests/experimental.R + Rscript tests/ensemble.R + Rscript tests/batch.R + shell: bash - name: Verify AlphaFold and ESMFold confidence formats run: Rscript tests/predictions.R shell: bash From f3b60dec1bfccee9625955010d4fefbc75fa8321 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:09:14 +0200 Subject: [PATCH 14/50] test: avoid collinear synthetic peptide atoms so omega is well defined --- tests/geometry.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/geometry.R b/tests/geometry.R index 7834c36..5dbcf4b 100644 --- a/tests/geometry.R +++ b/tests/geometry.R @@ -13,7 +13,7 @@ residue <- function(chain, no, name, insert, atoms, coords) { first <- residue("A",1,"SER","",c("N","CA","C","CB","OG"), rbind(c(0,0,0),c(1,0,0),c(1,1,0),c(1,-1,0),c(2,-1,1))) second <- residue("A",2,"PRO","",c("N","CA","C","CB","CG"), - rbind(c(1,2.4,0),c(2,2.4,1),c(3,2.4,1),c(2,1.4,1),c(3,1.4,2))) + rbind(c(1.6,2.25,0.2),c(2,2.4,1),c(3,2.4,1),c(2,1.4,1),c(3,1.4,2))) pdb <- list(atom=rbind(first,second)) torsion <- ram_extract_torsions(pdb) assert(nrow(torsion)==2L && torsion$bonded_to_next[[1]], From 799ba6f8d539da7139d94810fa59c3c221311d8d Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:09:17 +0200 Subject: [PATCH 15/50] ci: select an explicit CRAN mirror on Windows for optional XML parser --- .github/workflows/scientific-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scientific-tests.yml b/.github/workflows/scientific-tests.yml index 5eca077..ea7c988 100644 --- a/.github/workflows/scientific-tests.yml +++ b/.github/workflows/scientific-tests.yml @@ -36,7 +36,7 @@ jobs: node tests/prediction-ui.test.cjs shell: bash - name: Install optional official wwPDB XML parser - run: Rscript -e 'install.packages("xml2")' + run: Rscript -e 'install.packages("xml2", repos="https://cloud.r-project.org")' shell: bash - name: Verify Phase C geometry, ensembles and offline CLI run: | From 444c6dad3ef263fc52ff23e6e9db1f148911858c Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:10:01 +0200 Subject: [PATCH 16/50] ui(verification): show independent wwPDB evidence and omega/chi1 diagnostics beside plots --- shinyRam/app.R | 119 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/shinyRam/app.R b/shinyRam/app.R index e3f959a..7d4f9ca 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -29,6 +29,9 @@ 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", "geometry.R"), local = TRUE) +source(file.path("R", "experimental.R"), local = TRUE) +source(file.path("R", "ensemble.R"), local = TRUE) # Chain colours and contour colours are designed together for a recognisable # RamplotR publication identity. Region meaning is encoded by ordered contrast, @@ -396,7 +399,8 @@ ui <- fluidPage( ) ) ), - uiOutput("predictionPanel") + uiOutput("predictionPanel"), + uiOutput("geometryPanel") ), tabPanel( title = "Residue list", value = "residues", @@ -517,6 +521,7 @@ ui <- fluidPage( server <- function(input, output, session) { loaded <- reactiveVal(NULL) comparison_loaded <- reactiveVal(NULL) + external_validation <- reactiveVal(NULL) selected_residue <- reactiveVal(NULL) viewer_ready <- reactiveVal(FALSE) current_model <- reactive({ @@ -805,6 +810,31 @@ server <- function(input, output, session) { if (model == 1L) return(data$torsions) ram_extract_torsions(ram_model_at(data$pdb, model)) }) + # Extra geometry depends on the selected model, not on plot palettes. + model_geometry <- reactive({ + structure <- req(loaded()) + ram_extra_geometry(ram_model_at(structure$pdb,current_model()), + model_torsions()) + }) + observeEvent(loaded(), external_validation(NULL), ignoreInit=TRUE) + observeEvent(input$attachValidation, { + structure <- req(loaded()) + file <- req(input$validationXml) + record <- tryCatch(ram_external_validation_read(file$datapath), + error=function(e) { + showNotification(conditionMessage(e),type="error",duration=12) + NULL + }) + if(is.null(record)) return() + external_validation(list(key=structure$key,records=record, + name=file$name,md5=unname(tools::md5sum(file$datapath)))) + showNotification("Official wwPDB annotations attached. Check model and residue coverage.", + type="message") + },ignoreInit=TRUE) + observeEvent(input$clearValidation, { + external_validation(NULL) + },ignoreInit=TRUE) + classified <- reactive({ structure <- req(loaded(), input$validationMode, input$bgtype) result <- ram_classify_torsions( @@ -816,6 +846,11 @@ server <- function(input, output, session) { ) if (current_model() == 1L) result <- ram_apply_prediction(result, structure$prediction) + result <- ram_join_geometry(result,model_geometry()) + official <- external_validation() + if(!is.null(official) && identical(official$key,structure$key)) + result <- ram_external_validation_join(result,official$records, + model=current_model()) result }) displayed <- reactive({ @@ -950,6 +985,13 @@ server <- function(input, output, session) { provenance$confidence_limitations <- paste( structure$prediction$notes, collapse = "; ") } + ext <- external_validation() + if(!is.null(ext) && identical(ext$key,structure$key)) { + provenance$official_wwPDB_report <- ext$name + provenance$official_wwPDB_md5 <- ext$md5 + provenance$official_wwPDB_model <- current_model() + } + provenance$extended_native_geometry <- "Omega and descriptive chi1; not MolProbity-equivalent" ram_save_html_report(file, data, provenance, image) } ) @@ -1056,6 +1098,67 @@ server <- function(input, output, session) { ) }) + output$geometryPanel <- renderUI({ + structure <- req(loaded()) + tags$details(id="ram-geometry-panel",class="ram-confidence-panel", + tags$summary( + tags$span(class="ram-confidence-title","Extended structure verification"), + tags$span(class="ram-confidence-subtitle", + "Peptide and side-chain diagnostics · independent wwPDB evidence") + ), + tags$div(class="ram-confidence-body", + tags$p(class="ram-confidence-explainer", + "Omega and chi1 are descriptive measurements. Rotamer, clash, bond-angle and experimental-fit assessments are imported from the official wwPDB report when you attach one."), + uiOutput("geometrySummary"), + tags$div(class="ram-phase-c-attach", + fileInput("validationXml","Attach wwPDB validation XML (.xml or .xml.gz)", + accept=c(".xml",".gz")), + actionButton("attachValidation","Attach report",class="btn-primary btn-sm"), + actionButton("clearValidation","Clear",class="btn-default btn-sm") + ), + uiOutput("officialSummary"), + tags$p(class="ram-confidence-explainer", + "Independent validation reports are for deposited experimental structures. They cannot validate an unpublished AlphaFold or ESMFold prediction."), + downloadButton("downloadGeometry","Export detailed residue CSV") + ) + ) + }) + output$geometrySummary <- renderUI({ + data <- displayed() + if(!nrow(data)) return(NULL) + metric <- function(label,count) + tags$span(class="ram-confidence-metric",paste(label,format(count,big.mark=","))) + tags$div(class="ram-confidence-metrics", + metric("Cis peptide bonds",sum(data$omega_status=="Cis",na.rm=TRUE)), + metric("Twisted peptide bonds",sum(data$omega_status=="Twisted",na.rm=TRUE)), + metric("Measured χ1 angles",sum(is.finite(data$chi1))), + metric("Missing ω",sum(!is.finite(data$omega))) + ) + }) + output$officialSummary <- renderUI({ + ext <- external_validation() + structure <- req(loaded()) + if(is.null(ext) || !identical(ext$key,structure$key)) + return(tags$p(class="ram-field-hint","No official validation report attached.")) + values <- ram_external_validation_summary(classified()) + tags$div(class="ram-official-summary", + tags$strong(paste("Official report:",ext$name)), + tags$p(sprintf("Matched %s of %s residues in model %s. %s independent Ramachandran outliers, %s rotamer outliers and %s residues with local clashes.", + values$matched,values$total,current_model(), + values$official_rama_outliers,values$official_rotamer_outliers, + values$residues_with_clashes)), + if(values$matched==0L) + tags$p(class="ram-confidence-warning", + "No report residues match this model's chain, numbering, insertion codes and residue types."), + tags$p(class="ram-field-hint", + "Independent wwPDB values may disagree with RamplotR's reference-specific region labels. Provenance and report checksum are preserved in exports.") + ) + }) + output$downloadGeometry <- downloadHandler( + filename=function() safe_filename("extended.csv"), + content=function(file) utils::write.csv(displayed(),file,row.names=FALSE,na="") + ) + output$predictionPanel <- renderUI({ structure <- req(loaded()) prediction <- structure$prediction @@ -1434,6 +1537,20 @@ server <- function(input, output, session) { tags$span(paste("ψ", angle(row$psi[[1L]]))), tags$span(if (is.finite(row$density[[1L]])) sprintf("Density percentile %.1f", row$density[[1L]]) else ""), + if ("omega" %in% names(row) && is.finite(row$omega[[1L]])) + tags$span(sprintf("ω %.1f° · %s",row$omega[[1L]], + row$omega_status[[1L]])), + if ("chi1" %in% names(row) && is.finite(row$chi1[[1L]])) + tags$span(sprintf("χ1 %.1f°",row$chi1[[1L]])), + if ("wwpdb_rotamer" %in% names(row) && + !is.na(row$wwpdb_rotamer[[1L]])) + tags$span(class="ram-inspector-plddt", + paste("wwPDB rotamer",row$wwpdb_rotamer[[1L]])), + if ("wwpdb_clashes" %in% names(row) && + is.finite(row$wwpdb_clashes[[1L]]) && + row$wwpdb_clashes[[1L]]>0) + tags$span(class="ram-inspector-warning", + paste("Official wwPDB clashes",row$wwpdb_clashes[[1L]])), if ("plddt" %in% names(row) && is.finite(row$plddt[[1L]])) tags$span(class = "ram-inspector-plddt", sprintf("pLDDT %.1f · %s", row$plddt[[1L]], From f631f96ada2d26935b26b26532bb273a42e22260 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:10:52 +0200 Subject: [PATCH 17/50] ui(ensemble): add on-demand model comparison with linked residue selection and CSV --- shinyRam/app.R | 110 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/shinyRam/app.R b/shinyRam/app.R index 7d4f9ca..b88e8da 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -486,6 +486,7 @@ ui <- fluidPage( ) ), htmlOutput("summary"), + uiOutput("ensemblePanel"), tags$details(class = "ram-details ram-export-panel", tags$summary("Export figures and a reproducible report"), tags$p(class = "ram-field-hint", @@ -522,6 +523,7 @@ server <- function(input, output, session) { loaded <- reactiveVal(NULL) comparison_loaded <- reactiveVal(NULL) external_validation <- reactiveVal(NULL) + ensemble_results <- reactiveVal(NULL) selected_residue <- reactiveVal(NULL) viewer_ready <- reactiveVal(FALSE) current_model <- reactive({ @@ -1344,6 +1346,114 @@ server <- function(input, output, session) { sele_target=paste0(":",input$compareChainB)) }) + output$ensemblePanel <- renderUI({ + structure <- req(loaded()) + if(structure$nmodels<=1L) return(NULL) + tags$details(id="ram-ensemble-panel",class="ram-confidence-panel", + tags$summary( + tags$span(class="ram-confidence-title","Ensemble analysis"), + tags$span(class="ram-confidence-subtitle", + paste(structure$nmodels,"structural models · circular φ/ψ variation and region consistency")) + ), + tags$div(class="ram-confidence-body", + tags$p(class="ram-confidence-explainer", + "Model variation is matched by chain, residue and insertion code. Circular statistics correctly handle the -180°/180° boundary; models with missing coordinates contribute only observed angles."), + tags$div(class="ram-ensemble-actions", + actionButton("calculateEnsemble","Analyse ensemble", + class="btn-primary btn-sm"), + downloadButton("downloadEnsemble","Export ensemble CSV") + ), + uiOutput("ensembleResultSummary"), + tags$div(class="ram-residue-table",DT::DTOutput("ensembleRows")) + ) + ) + }) + ensemble_matches <- reactive({ + value <- ensemble_results() + if(is.null(value)) return(NULL) + if(!identical(value$key,req(loaded())$key) || + !identical(value$mode,input$validationMode) || + !identical(value$reference,input$bgtype) || + !identical(value$background,input$background)) return(NULL) + value$result + }) + observeEvent(input$calculateEnsemble, { + structure <- req(loaded()) + if(structure$nmodels<=1L) return() + withProgress(message="Analysing compatible ensemble models",value=0.2,{ + result <- tryCatch( + ram_ensemble_analyze(structure$pdb,max_models=min(30L,structure$nmodels), + classifier=function(torsions) + ram_classify_torsions(torsions, + reference_dir=file.path("static",input$bgtype), + selected_reference=plot_reference(),mode=input$validationMode, + threshold_fn=ram_density_thresholds)), + error=function(e) { + showNotification(conditionMessage(e),type="error",duration=12) + NULL + }) + if(!is.null(result)) + ensemble_results(list(key=structure$key,mode=input$validationMode, + reference=input$bgtype,background=input$background,result=result)) + incProgress(0.8) + }) + },ignoreInit=TRUE) + output$ensembleResultSummary <- renderUI({ + result <- ensemble_matches() + if(is.null(result)) return(tags$p(class="ram-field-hint", + "Run the ensemble analysis. Results are recalculated on request after changing the reference or classification settings.")) + data <- result$summary + tags$div(class="ram-confidence-metrics", + tags$span(class="ram-confidence-metric", + sprintf("%s of %s models analysed",result$analyzed_models, + result$available_models)), + tags$span(class="ram-confidence-metric", + sprintf("%s residues with classification changes", + sum(data$changes_class,na.rm=TRUE))), + tags$span(class="ram-confidence-metric", + sprintf("%s residues with ≥20° angular spread", + sum(pmax(data$phi_sd,data$psi_sd,na.rm=TRUE)>=20, + na.rm=TRUE))), + if(result$limited) + tags$span(class="ram-confidence-warning", + "Only the first 30 models are included; export records this limit.") + ) + }) + output$ensembleRows <- DT::renderDT({ + result <- ensemble_matches() + req(result) + data <- result$summary + if(!nrow(data)) return(DT::datatable(data,rownames=FALSE)) + fields <- c("chain","resi","insertion_code","resn", + "phi_mean","phi_sd","psi_mean","psi_sd","models_present", + "class_consistency","changes_class") + shown <- data[,fields,drop=FALSE] + for(field in c("phi_mean","phi_sd","psi_mean","psi_sd")) + shown[[field]] <- round(shown[[field]],1L) + shown$class_consistency <- round(shown$class_consistency*100,1L) + shown$changes_class <- ifelse(shown$changes_class,"Changed","Stable") + DT::datatable(shown,rownames=FALSE,selection="single", + colnames=c("Chain","Residue","Ins.","AA","φ mean","φ SD", + "ψ mean","ψ SD","Models","Class agreement (%)","Class"), + options=list(pageLength=12,autoWidth=FALSE,scrollX=TRUE, + dom="ftip",order=list(list(10,"asc"))), + class="compact stripe hover") + },server=FALSE) + observeEvent(input$ensembleRows_rows_selected, { + data <- req(ensemble_matches())$summary + ix <- input$ensembleRows_rows_selected[[1L]] + if(!is.finite(ix) || ix<1L || ix>nrow(data)) return() + row <- data[ix,,drop=FALSE] + selected_residue(list(chain=as.character(row$chain[[1L]]), + resi=as.integer(row$resi[[1L]]), + insertion_code=as.character(row$insertion_code[[1L]]))) + }) + output$downloadEnsemble <- downloadHandler( + filename=function() safe_filename("ensemble.csv"), + content=function(file) utils::write.csv(req(ensemble_matches())$summary, + file,row.names=FALSE,na="") + ) + output$summary <- renderUI({ data <- displayed() eligible <- data[!is.na(data$region) & !data$resn %in% c("GLY", "PRO"), From e9f9939fd506df13a76cfd88051d96fa8b0cad60 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:12:28 +0200 Subject: [PATCH 18/50] report: separate native geometry, official wwPDB evidence and model ensembles --- shinyRam/R/reports.R | 132 +++++++++++++++++++++++++++++-------------- 1 file changed, 89 insertions(+), 43 deletions(-) diff --git a/shinyRam/R/reports.R b/shinyRam/R/reports.R index 406f41e..2141de5 100644 --- a/shinyRam/R/reports.R +++ b/shinyRam/R/reports.R @@ -77,60 +77,106 @@ ram_report_metadata <- function(name, reference_set, background, } ram_save_html_report <- function(path, data, metadata, svg_path, - max_report_rows = 10000L) { - esc <- htmltools::htmlEscape - h <- function(label, value) { - htmltools::tags$tr(htmltools::tags$th(label), - htmltools::tags$td(as.character(value))) + max_report_rows=10000L, ensemble=NULL) { + h <- function(label,value) htmltools::tags$tr( + htmltools::tags$th(label), + htmltools::tags$td(paste(as.character(value),collapse=", "))) + table_for <- function(frame) { + rows <- lapply(seq_len(nrow(frame)),function(i) + htmltools::tags$tr(lapply(frame[i,,drop=FALSE],function(value) + htmltools::tags$td(if(is.na(value[[1L]])) "" else + as.character(value[[1L]]))))) + htmltools::tags$div(style="overflow-x:auto", + htmltools::tags$table( + htmltools::tags$thead(htmltools::tags$tr( + lapply(names(frame),htmltools::tags$th))), + htmltools::tags$tbody(rows))) } - summary_regions <- c("Favoured", "Allowed", "Generously allowed", "Not allowed") - tally <- vapply(summary_regions, - function(value) sum(data$region == value, na.rm = TRUE), - integer(1)) - counts <- lapply(seq_along(tally), function(k) - h(summary_regions[[k]], tally[[k]])) - totals <- list(h("Missing angles", sum(is.na(data$phi) | is.na(data$psi))), - h("All selected residues", nrow(data))) - meta <- lapply(names(metadata), function(k) - h(gsub("_", " ", k), metadata[[k]])) - shown <- utils::head(data[, intersect( - c("chain", "resi", "insertion_code", "resn", "phi", "psi", "region", "density", "plddt", "confidence_category"), - colnames(data)), drop = FALSE], max_report_rows) - if ("phi" %in% names(shown)) shown$phi <- round(shown$phi, 2) - if ("psi" %in% names(shown)) shown$psi <- round(shown$psi, 2) - if ("density" %in% names(shown)) shown$density <- round(shown$density, 2) - lines <- readLines(svg_path, warn = FALSE) - # SVG is generated by this application, not loaded from an arbitrary source. - chart <- htmltools::HTML(paste(lines[!grepl("^<\\?xml", lines)], collapse="\n")) + tally <- vapply(c("Favoured","Allowed","Generously allowed","Not allowed"), + function(region) sum(data$region==region,na.rm=TRUE),integer(1)) + counts <- lapply(names(tally),function(label) h(label,tally[[label]])) + counts <- c(counts,list( + h("Missing angles",sum(is.na(data$phi)|is.na(data$psi))), + h("All selected residues",nrow(data)))) + meta <- lapply(names(metadata),function(name) + h(gsub("_"," ",name),metadata[[name]])) + shown <- utils::head(data[,intersect(c( + "chain","resi","insertion_code","resn","phi","psi","region", + "density","omega","omega_status","chi1","plddt","confidence_category"), + names(data)),drop=FALSE],max_report_rows) + for(field in intersect(c("phi","psi","density","omega","chi1","plddt"), + names(shown))) + shown[[field]] <- round(shown[[field]],2L) + official <- NULL + if("wwpdb_matched" %in% names(data)) { + fields <- intersect(c("chain","resi","insertion_code","resn", + "wwpdb_rama","wwpdb_rotamer","wwpdb_clashes", + "wwpdb_bond_outliers","wwpdb_angle_outliers","wwpdb_rscc","wwpdb_rsrz"), + names(data)) + official <- utils::head(data[data$wwpdb_matched %in% TRUE, + fields,drop=FALSE],max_report_rows) + } + if(!is.null(ensemble)) { + ensemble <- utils::head(ensemble,max_report_rows) + for(field in intersect(c("phi_mean","phi_sd","psi_mean","psi_sd", + "class_consistency"),names(ensemble))) + ensemble[[field]] <- round(ensemble[[field]],2L) + } + lines <- readLines(svg_path,warn=FALSE) + chart <- htmltools::HTML(paste(lines[!grepl("^<\\?xml",lines)], + collapse="\n")) doc <- htmltools::tags$html( htmltools::tags$head( - htmltools::tags$meta(charset = "UTF-8"), + htmltools::tags$meta(charset="UTF-8"), htmltools::tags$title("RamplotR structure analysis"), htmltools::tags$style(htmltools::HTML( - "body{font:15px system-ui,sans-serif;margin:3em auto;max-width:1000px;color:#18323b}h1,h2{color:#146a70}table{border-collapse:collapse;width:100%}th,td{padding:6px 12px;border-bottom:1px solid #dbe6e6;text-align:left}th{background:#eef6f4}svg{width:min(100%,760px);height:auto}p.note{color:#516b74}section{margin:2em 0}" - )) + "body{font:15px system-ui,sans-serif;margin:3em auto;max-width:1100px;color:#18323b}h1,h2{color:#146a70}table{border-collapse:collapse;width:100%}th,td{padding:6px 12px;border-bottom:1px solid #dbe6e6;text-align:left}th{background:#eef6f4}svg{width:min(100%,760px);height:auto}p.note{color:#516b74;line-height:1.5}section{margin:2em 0}")) ), htmltools::tags$body( htmltools::tags$h1("RamplotR structure analysis"), - htmltools::tags$p(class = "note", - "Portable report: figure, counts, classifications and reproducibility settings."), - htmltools::tags$section(htmltools::tags$h2("Ramachandran plot"), chart), - htmltools::tags$section(htmltools::tags$h2("Region counts"), - htmltools::tags$table(c(counts, totals))), + htmltools::tags$p(class="note", + "Portable report: figure, counts, geometry and provenance. Native and independent results are reported separately."), + htmltools::tags$section(htmltools::tags$h2("Ramachandran plot"),chart), + htmltools::tags$section(htmltools::tags$h2("RamplotR region counts"), + table_for(data.frame(Region=names(tally),Residues=as.integer(tally)))), + if("omega_status" %in% names(data)) + htmltools::tags$section( + htmltools::tags$h2("Additional descriptive geometry"), + htmltools::tags$p(class="note", + "Omega (peptide dihedral) and chi1 (first side-chain dihedral) are calculated locally. No rotamer or clash outlier status is inferred from these measurements."), + htmltools::tags$p(sprintf( + "%d cis and %d twisted peptide bonds; %d measurable chi1 angles.", + sum(data$omega_status=="Cis",na.rm=TRUE), + sum(data$omega_status=="Twisted",na.rm=TRUE), + sum(is.finite(data$chi1)))) + ), + if(!is.null(official)) + htmltools::tags$section( + htmltools::tags$h2("Independent wwPDB validation"), + htmltools::tags$p(class="note", + "Official residue-level validation values from the attached report, not computed by RamplotR. Missing records are not scored; underlying contour classifications may differ."), + if(sum(data$wwpdb_matched %in% TRUE)>max_report_rows) + htmltools::tags$p(class="note", + "Independent table truncated; use the detailed CSV for every matched residue."), + table_for(official)), + if(!is.null(ensemble)) + htmltools::tags$section( + htmltools::tags$h2("Structural ensemble"), + htmltools::tags$p(class="note", + "Residue-matched circular dihedral means and model consistency. Missing and one-model observations do not imply zero variation."), + table_for(ensemble)), htmltools::tags$section(htmltools::tags$h2("Analysis provenance"), - htmltools::tags$table(meta)), + table_for(data.frame(Setting=gsub("_"," ",names(metadata)), + Value=vapply(metadata,function(x)paste(as.character(x), + collapse=", "),character(1))))), htmltools::tags$section(htmltools::tags$h2("Residue details"), - if (nrow(data) > max_report_rows) htmltools::tags$p(class="note", - sprintf("Showing the first %s of %s residues; export the CSV for all rows.", - max_report_rows, nrow(data))), - htmltools::tags$table( - htmltools::tags$thead(htmltools::tags$tr( - lapply(names(shown), htmltools::tags$th))), - htmltools::tags$tbody(lapply(seq_len(nrow(shown)), function(i) - htmltools::tags$tr(lapply(shown[i, ], function(value) - htmltools::tags$td(if (is.na(value)) "" else as.character(value)))))))) + if(nrow(data)>max_report_rows) + htmltools::tags$p(class="note", + sprintf("Showing the first %d of %d residues; the CSV contains all rows.", + max_report_rows,nrow(data))), + table_for(shown)) ) ) - writeLines(as.character(doc), path, useBytes = TRUE) + writeLines(as.character(doc),path,useBytes=TRUE) invisible(path) } From eaa4f83367280c99a1a02a5e42664066b2321034 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:12:50 +0200 Subject: [PATCH 19/50] test(batch): verify real 1CRN reports and 1D3Z ensemble CLI output --- tests/batch-integration.R | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/batch-integration.R diff --git a/tests/batch-integration.R b/tests/batch-integration.R new file mode 100644 index 0000000..aeb7778 --- /dev/null +++ b/tests/batch-integration.R @@ -0,0 +1,54 @@ +# End-to-end offline batch regression with known downloaded wwPDB structures. +# Called by ui-preview.yml after fetching 1CRN and 1D3Z. +for(filename in c("io","backbone","ramachandran","geometry","experimental", + "ensemble","predictions","reports","batch")) + source(file.path("shinyRam","R",paste0(filename,".R"))) +assert <- function(x,msg) if(!isTRUE(x)) stop(msg,call.=FALSE) +root <- "benchmarks/output/ui-preview" +one <- file.path(root,"1CRN.pdb") +nmr <- file.path(root,"1D3Z.pdb") +if(!all(file.exists(c(one,nmr)))) stop("Download 1CRN and 1D3Z first.") +output <- tempfile("ram-phase-c-batch-") +on.exit <- NULL +a <- ram_batch_options(c("--input",one,"--output",output,"--report")) +run <- ram_batch_run(a) +prefix <- file.path(output,"1CRN.pdb") +assert(nrow(run)==1L && run$status[[1]]=="ok" && + run$residues[[1]]>=40 && + file.exists(paste0(prefix,".residues.csv")) && + file.exists(paste0(prefix,".json")) && + file.exists(paste0(prefix,".html")) && + file.exists(paste0(prefix,".svg")), + paste("Batch figure/report/JSON export failed:",run$error[[1]])) +csv <- utils::read.csv(paste0(prefix,".residues.csv")) +assert(all(c("omega","omega_status","chi1","phi","psi","region") %in% + names(csv)) && + all(csv$omega_status %in% c("Cis","Trans","Twisted","Missing")), + "Batch must export new geometry without changing original columns.") +doc <- paste(readLines(paste0(prefix,".html"),warn=FALSE),collapse="\n") +assert(grepl("Additional descriptive geometry",doc,fixed=TRUE) && + grepl("Analysis provenance",doc,fixed=TRUE), + "Batch HTML report must contain geometry and provenance.") +if(!requireNamespace("jsonlite",quietly=TRUE)) + stop("jsonlite is required by the batch integration workflow.") +json <- jsonlite::fromJSON(paste0(prefix,".json")) +assert(json$metadata$reference_set=="original" && + !is.null(json$metadata$input_md5), + "Machine-readable output must preserve scientific provenance.") +b <- ram_batch_options(c("--input",nmr,"--output",output, + "--ensemble-models","2","--no-json")) +run2 <- ram_batch_run(b) +assert(nrow(run2)==1L && run2$status[[1]]=="ok" && + file.exists(file.path(output,"1D3Z.pdb.ensemble.csv")), + paste("Multi-model ensemble batch failed:",run2$error[[1]])) +ensemble <- utils::read.csv(file.path(output,"1D3Z.pdb.ensemble.csv")) +assert(nrow(ensemble)>=50 && + all(c("phi_mean","phi_sd","changes_class","models_present") %in% + names(ensemble)), + "NMR ensemble CSV is missing circular statistics.") +repeat_error <- ram_batch_run(a) +assert(repeat_error$status[[1]]=="error" && + grepl("already exists",repeat_error$error[[1]]), + "Batch runs must not silently overwrite earlier published results.") +unlink(output,recursive=TRUE) +message("Real-structure batch figure, JSON, HTML and NMR-ensemble tests passed.") From 7af38f65b92dd9107050e3154c1850d8885f0bf4 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:13:13 +0200 Subject: [PATCH 20/50] ci: check Phase C batch, report and ensemble exports against real PDBs --- .github/workflows/ui-preview.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ui-preview.yml b/.github/workflows/ui-preview.yml index 7390bfe..b04dc44 100644 --- a/.github/workflows/ui-preview.yml +++ b/.github/workflows/ui-preview.yml @@ -26,7 +26,7 @@ jobs: use-public-rspm: true - name: Install the application's R dependencies run: | - Rscript -e 'install.packages(c("shiny", "shinyWidgets", "colourpicker", "bio3d", "NGLVieweR", "DT", "jsonlite"))' + Rscript -e 'install.packages(c("shiny", "shinyWidgets", "colourpicker", "bio3d", "NGLVieweR", "DT", "jsonlite", "xml2"))' - name: Validate publication figure and report exports run: Rscript tests/reports.R - name: Install headless-browser controller @@ -53,6 +53,8 @@ jobs: https://files.rcsb.org/download/1D3Z.pdb \ -o benchmarks/output/ui-preview/1D3Z.pdb Rscript tests/model-integration.R + - name: Exercise offline batch analysis and NMR-ensemble exports + run: Rscript tests/batch-integration.R - name: Start the Shiny application shell: bash run: | From 46d0d4eb3ecf6ea43f7d4a91aec6845830fc912d Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:14:29 +0200 Subject: [PATCH 21/50] ui(cryo-EM): support local map upload, contour adjustment and safe viewer reset --- shinyRam/app.R | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/shinyRam/app.R b/shinyRam/app.R index b88e8da..0e7344a 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -395,6 +395,27 @@ ui <- fluidPage( ), tags$p(class = "ram-viewer-hint", "Surface rendering can take longer for large structures.") + ), + tags$details(class="ram-density-panel", + tags$summary("Local cryo-EM density map"), + tags$p(class="ram-field-hint", + "Overlay a local CCP4/MRC map. This is a visual aid, not an experimental map-fit score."), + tags$input(type="file",id="ram-density-file", + accept=".map,.mrc,.ccp4"), + tags$div(class="ram-density-level", + tags$label("Map threshold (σ)", `for`="ram-density-level"), + tags$input(type="range",id="ram-density-level", + min="0.5",max="5",step="0.25",value="2"), + tags$span(id="ram-density-value","2.0σ") + ), + tags$div(class="ram-density-buttons", + tags$button(type="button",id="ram-density-load", + class="btn btn-primary btn-sm","Show map"), + tags$button(type="button",id="ram-density-clear", + class="btn btn-default btn-sm","Remove") + ), + tags$p(id="ram-density-status",role="status", + "No map loaded.") ) ) ) @@ -514,7 +535,8 @@ ui <- fluidPage( ) ), tags$script(src = "custom.js"), - tags$script(src = "prediction.js") + tags$script(src = "prediction.js"), + tags$script(src = "density.js") ) # Structure parsing is deliberately triggered by the Analyse button. Every # downstream result is a reactive expression, so adjusting settings never @@ -740,6 +762,7 @@ server <- function(input, output, session) { selected = chains ) }) + session$sendCustomMessage("ram-clear-density",list()) 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)) From 2d9f03262cb10e4e4f13e1b571156b81bc04dd68 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:14:59 +0200 Subject: [PATCH 22/50] feat(cryo-EM): render local CCP4/MRC isosurfaces via NGL without uploading maps externally --- shinyRam/www/density.js | 107 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 shinyRam/www/density.js diff --git a/shinyRam/www/density.js b/shinyRam/www/density.js new file mode 100644 index 0000000..10905e8 --- /dev/null +++ b/shinyRam/www/density.js @@ -0,0 +1,107 @@ +/* Local cryo-EM density overlay: the user's File is parsed by NGL in the + * browser, not uploaded to an external map-fitting service. No quantitative + * fit assessment is inferred from simply displaying an isosurface. + */ +(function () { + "use strict"; + let component = null, representation = null, owningStage = null; + let generation = 0; + const bytesLimit = 64 * 1024 * 1024; + const find = id => document.getElementById(id); + const status = text => { + const node = find("ram-density-status"); + if (node) node.textContent = text; + }; + const currentStage = () => + typeof window.getNGLStage === "function" ? + window.getNGLStage("NGL") : null; + const level = () => { + const input = find("ram-density-level"); + const n = Number(input ? input.value : 2); + return Number.isFinite(n) && n >= 0.5 && n <= 5 ? n : 2; + }; + function clearMap(announce = true) { + generation++; + if (component && owningStage && + typeof owningStage.removeComponent === "function") { + try { owningStage.removeComponent(component); } catch (_) {} + } + component = representation = owningStage = null; + if (announce) status("Map removed."); + } + async function loadMap() { + const input = find("ram-density-file"); + const file = input && input.files && input.files[0]; + if (!file) return status("Choose a local CCP4/MRC map first."); + if (!/\.(map|mrc|ccp4)$/i.test(file.name)) + return status("Only .map, .mrc and .ccp4 files are supported."); + if (!file.size || file.size > bytesLimit) + return status("Choose a nonempty map of 64 MB or less for interactive rendering."); + const stage = currentStage(); + if (!stage || typeof stage.loadFile !== "function") + return status("Load a molecular structure before displaying its map."); + clearMap(false); + const token = generation; + status("Reading local density map in NGL…"); + let next = null; + try { + // NGL's CCP4 parser supports binary CCP4/MRC density maps. Let the + // browser read the File object directly; no server URL is generated. + next = await stage.loadFile(file, {ext:"ccp4",defaultRepresentation:false}); + if (token !== generation || stage !== currentStage()) { + if (next && typeof stage.removeComponent==="function") + stage.removeComponent(next); + return; + } + if (!next || typeof next.addRepresentation !== "function") + throw new Error("NGL did not return a compatible volume component."); + const chosen = level(); + const added = next.addRepresentation("surface", { + isolevelType:"sigma",isolevel:chosen,opacity:0.30, + color:"#188e9a",useWorker:true + }); + component = next; + owningStage = stage; + representation = added && typeof added.setParameters==="function" ? + added : next.reprList && next.reprList[0]; + status("Local map displayed at " + chosen.toFixed(2) + + "σ (visual overlay, not a validation score)."); + } catch (error) { + if (next && typeof stage.removeComponent==="function") { + try { stage.removeComponent(next); } catch (_) {} + } + if (token === generation) + status("Unable to display this map: " + (error && error.message || error)); + } + } + document.addEventListener("click", function (event) { + if (!event.target || !event.target.closest) return; + if (event.target.closest("#ram-density-load")) { + event.preventDefault(); + loadMap(); + } else if (event.target.closest("#ram-density-clear")) { + event.preventDefault(); + clearMap(); + } + }); + document.addEventListener("change", function (event) { + if (!event.target || event.target.id !== "ram-density-level") return; + const value = level(); + const display = find("ram-density-value"); + if (display) display.textContent = value.toFixed(2) + "σ"; + if (!component || !representation || + typeof representation.setParameters !== "function") return; + try { + representation.setParameters({isolevelType:"sigma",isolevel:value}); + status("Local map displayed at " + value.toFixed(2) + + "σ (visual overlay, not a validation score)."); + } catch (error) { + status("Could not update the map threshold."); + } + }); + if (window.Shiny && typeof window.Shiny.addCustomMessageHandler==="function") + window.Shiny.addCustomMessageHandler("ram-clear-density",function (_message) { + clearMap(false); + status("No map loaded."); + }); +})(); From b4dcba832366150e74ca681c77f45a3c39cbf373 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:15:28 +0200 Subject: [PATCH 23/50] style: keep optional geometry, ensemble and cryo-EM controls compact and accessible --- shinyRam/www/styles.css | 50 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/shinyRam/www/styles.css b/shinyRam/www/styles.css index 7f0e4bc..734cc42 100644 --- a/shinyRam/www/styles.css +++ b/shinyRam/www/styles.css @@ -978,3 +978,53 @@ body > .container-fluid { max-width: none; padding: 0; } padding:2px 0; font-size:10px; line-height:1.2; } } + + +/* Phase C diagnostics and optional experimental evidence keep the primary + 2D/3D workspace uncluttered until explicitly expanded. */ +.ram-phase-c-attach { + display:flex; align-items:flex-end; flex-wrap:wrap; + gap:8px 12px; margin:14px 0 8px; +} +.ram-phase-c-attach > .form-group { + min-width:min(100%,260px); max-width:370px; flex:1 1 260px; + margin:0; +} +.ram-phase-c-attach .btn { min-height:36px; white-space:nowrap; } +.ram-official-summary { + background:#f3faf7; border-left:3px solid #39988d; + padding:11px 13px; border-radius:5px; + margin-top:10px; font-size:12px; +} +.ram-official-summary strong { color:#165d60; font-size:13px; } +.ram-official-summary p { margin:5px 0; line-height:1.5; } +.ram-ensemble-actions { display:flex; gap:8px; flex-wrap:wrap; margin:12px 0; } +.ram-ensemble-panel .ram-residue-table { width:100%; max-width:100%; overflow-x:auto; } +.ram-density-panel { + border-top:1px solid #e0eae9; margin-top:12px; padding-top:10px; + font-size:12px; color:#244653; +} +.ram-density-panel > summary { + font-size:12px; font-weight:750; color:#1b6570; + cursor:pointer; padding:5px 2px; +} +.ram-density-panel[open] { padding-bottom:10px; } +.ram-density-panel input[type=file] { + display:block; border:1px dashed #a4c8c5; + background:#f9fcfb; border-radius:8px; width:100%; + max-width:420px; padding:9px; font-size:11px; + margin:9px 0; +} +.ram-density-level { display:flex; gap:10px; align-items:center; + max-width:430px; margin:10px 0; } +.ram-density-level label { flex:0 0 auto; font-size:11px; font-weight:700; } +.ram-density-level input[type=range] { flex:1 1 auto; accent-color:#16868c; } +.ram-density-level span { min-width:45px; font-variant-numeric:tabular-nums; } +.ram-density-buttons { display:flex; flex-wrap:wrap; gap:8px; } +#ram-density-status { font-size:11px; line-height:1.5; margin:9px 0 0; color:#4d7377; } +@media(max-width:580px) { + .ram-phase-c-attach { display:grid; grid-template-columns:1fr 1fr; } + .ram-phase-c-attach .form-group { grid-column:1/-1; min-width:0; } + .ram-density-level { gap:6px; flex-wrap:wrap; } + .ram-density-level input[type=range] { min-width:130px; } +} From 6a268fda1e75663ed10f89d1efa3846b11c5d4e1 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:15:50 +0200 Subject: [PATCH 24/50] test(cryo-EM): verify local map, contour updates and map-size guard in JS --- tests/density-ui.test.cjs | 62 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/density-ui.test.cjs diff --git a/tests/density-ui.test.cjs b/tests/density-ui.test.cjs new file mode 100644 index 0000000..c588c96 --- /dev/null +++ b/tests/density-ui.test.cjs @@ -0,0 +1,62 @@ +// Browser-independent NGL volume handler contract; no actual experimental data. +const assert=require("node:assert/strict"); +const vm=require("node:vm"),fs=require("node:fs"); +(async function(){ + const listeners={};const handlers={}; + const nodes={ + "ram-density-file":{files:[{name:"local-map.mrc",size:2048}]}, + "ram-density-level":{value:"2"}, + "ram-density-value":{textContent:""}, + "ram-density-status":{textContent:""} + }; + const events={ + load:{target:{closest:selector=>selector==="#ram-density-load"}}, + clear:{target:{closest:selector=>selector==="#ram-density-clear"}} + }; + const representation={changes:[],setParameters(p){this.changes.push(p);}}; + const component={reprList:[representation], + addRepresentation(type,params) { + assert.equal(type,"surface"); + assert.equal(params.isolevelType,"sigma"); + return representation; + }}; + const removed=[]; + const stage={loadFile:async(file,options)=>{ + assert.equal(file.name,"local-map.mrc"); + assert.equal(options.ext,"ccp4"); + return component; + },removeComponent(value){removed.push(value);}}; + const sandbox={ + document:{ + getElementById:id=>nodes[id]||null, + addEventListener:(event,handler)=>listeners[event]=handler + }, + window:{ + getNGLStage:()=>stage, + Shiny:{addCustomMessageHandler:(name,handler)=>{ + assert.equal(handler.length,1,"Shiny handlers require one message argument"); + handlers[name]=handler; + }} + },console,Promise,Number + }; + vm.runInNewContext(fs.readFileSync("shinyRam/www/density.js","utf8"), + sandbox); + assert.equal(typeof handlers["ram-clear-density"],"function"); + listeners.click(events.load); + await new Promise(resolve=>setImmediate(resolve)); + assert.match(nodes["ram-density-status"].textContent,/2.00σ/); + nodes["ram-density-level"].value="3.25"; + listeners.change({target:{id:"ram-density-level"}}); + assert.equal(representation.changes.length,1); + assert.equal(representation.changes[0].isolevel,3.25); + assert.match(nodes["ram-density-value"].textContent,/3.25σ/); + handlers["ram-clear-density"]({}); + assert.equal(removed.length,1); + assert.match(nodes["ram-density-status"].textContent,/No map/); + nodes["ram-density-file"].files=[{name:"huge.ccp4",size:70*1024*1024}]; + listeners.click(events.load); + await new Promise(resolve=>setImmediate(resolve)); + assert.match(nodes["ram-density-status"].textContent,/64 MB/); + assert.equal(removed.length,1); + console.log("Local NGL map controls and safety checks passed."); +})().catch(error=>{console.error(error);process.exitCode=1;}); From 9b763706f562f4a8fc86519e721def6d78a53b7c Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:16:06 +0200 Subject: [PATCH 25/50] ci: validate local NGL cryo-EM controls alongside cross-platform Phase C tests --- .github/workflows/scientific-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/scientific-tests.yml b/.github/workflows/scientific-tests.yml index ea7c988..7a0c18d 100644 --- a/.github/workflows/scientific-tests.yml +++ b/.github/workflows/scientific-tests.yml @@ -32,6 +32,8 @@ jobs: run: | node --check shinyRam/www/custom.js node --check shinyRam/www/prediction.js + node --check shinyRam/www/density.js + node tests/density-ui.test.cjs node tests/ui.test.cjs node tests/prediction-ui.test.cjs shell: bash From a10af537c1e1eeaf23daf273315452df5d7e45e4 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:17:03 +0200 Subject: [PATCH 26/50] fix(cli): enforce explicit numeric resource limits and normalized option keys --- shinyRam/R/batch.R | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/shinyRam/R/batch.R b/shinyRam/R/batch.R index a7e5515..3c274e9 100644 --- a/shinyRam/R/batch.R +++ b/shinyRam/R/batch.R @@ -20,10 +20,7 @@ ram_batch_options <- function(args) { if(i==length(args) || startsWith(args[[i+1L]],"--")) stop("Missing value for --",flag,call.=FALSE) value <- args[[i+1L]] - name <- switch(flag,"max-files"="max_files", - "prediction-source"="prediction_source", - "validation-xml"="validation_xml", - "ensemble-models"="ensemble_models",flag) + name <- gsub("-","_",flag,fixed=TRUE) out[[name]] <- value i <- i+1L } @@ -43,9 +40,9 @@ ram_batch_options <- function(args) { stop("Unknown prediction provenance.") for(field in c("model","max_files","ensemble_models")) { value <- suppressWarnings(as.integer(out[[field]])) - if(length(value)!=1L || is.na(value) || - valueif(field=="max_files") 1000L else 100L) + lower <- if(field=="ensemble_models") 0L else 1L + upper <- if(field=="max_files") 1000L else 100L + if(length(value)!=1L || is.na(value) || valueupper) stop("Invalid value for ",field) out[[field]] <- value } From da62629ac17f22a8ddf5ccda0b08d94d8107f5b7 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:18:25 +0200 Subject: [PATCH 27/50] test: mock click preventDefault in density-map interactions --- tests/density-ui.test.cjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/density-ui.test.cjs b/tests/density-ui.test.cjs index c588c96..260e02d 100644 --- a/tests/density-ui.test.cjs +++ b/tests/density-ui.test.cjs @@ -10,8 +10,10 @@ const vm=require("node:vm"),fs=require("node:fs"); "ram-density-status":{textContent:""} }; const events={ - load:{target:{closest:selector=>selector==="#ram-density-load"}}, - clear:{target:{closest:selector=>selector==="#ram-density-clear"}} + load:{target:{closest:selector=>selector==="#ram-density-load"}, + preventDefault(){}}, + clear:{target:{closest:selector=>selector==="#ram-density-clear"}, + preventDefault(){}} }; const representation={changes:[],setParameters(p){this.changes.push(p);}}; const component={reprList:[representation], From 769e4cb3ef2de6addb99611365d42029367a5e47 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:19:48 +0200 Subject: [PATCH 28/50] test(browser): exercise independent validation, local density controls and NMR ensemble --- tests/phase-c-browser.cjs | 114 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/phase-c-browser.cjs diff --git a/tests/phase-c-browser.cjs b/tests/phase-c-browser.cjs new file mode 100644 index 0000000..c79b6de --- /dev/null +++ b/tests/phase-c-browser.cjs @@ -0,0 +1,114 @@ +// Phase C live Shiny smoke test. Fixtures are synthetic, not claims about +// experimental map fit or official wwPDB assessments. +const fs=require("node:fs"),path=require("node:path"); +const assert=require("node:assert/strict"); +const puppeteer=require("puppeteer-core"); +(async function(){ + const output=path.resolve("benchmarks/output/ui-preview"); + const pdb=path.join(output,"1CRN.pdb"),nmr=path.join(output,"1D3Z.pdb"); + const atom=fs.readFileSync(pdb,"utf8").split(/\r?\n/) + .find(line=>line.startsWith("ATOM ")); + const chain=atom.slice(21,22),resno=atom.slice(22,26).trim(); + const resn=atom.slice(17,20).trim(),insert=atom.slice(26,27).trim(); + const xml=path.join(output,"synthetic-official-geometry.xml"); + fs.writeFileSync(xml,[ + '', + '', + '', + '', + '' + ].join("\n")); + const map=path.join(output,"synthetic-display-only.map"); + fs.writeFileSync(map,Buffer.alloc(2048)); + const chrome=[process.env.CHROME_BIN,"/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable","/usr/bin/chromium"] + .find(f=>f&&fs.existsSync(f)); + if(!chrome)throw Error("Chrome/Chromium not found"); + const browser=await puppeteer.launch({executablePath:chrome,headless:true, + args:["--no-sandbox","--disable-dev-shm-usage","--disable-gpu"]}); + try{ + const page=await browser.newPage(); + page.on("pageerror",error=>console.error("Page error:",error.stack||error)); + await page.setViewport({width:1366,height:900}); + await page.goto("http://127.0.0.1:8765", + {waitUntil:"networkidle2",timeout:60000}); + await page.evaluate(()=>document.querySelector( + 'input[name="inputSource"][value="upload"]').click()); + await page.waitForFunction(()=> + !document.getElementById("ram-upload-wrap").classList.contains("is-hidden")); + await (await page.$("#structfile")).uploadFile(pdb); + await new Promise(resolve=>setTimeout(resolve,1400)); + await page.click("#submit"); + await page.waitForFunction(()=>{ + const node=document.querySelector("#ram-geometry-panel"); + return node&&node.querySelector("summary")&& + node.textContent.includes("Extended structure verification"); + },{timeout:90000}); + await page.click("#ram-geometry-panel summary"); + await page.waitForSelector("#validationXml"); + await (await page.$("#validationXml")).uploadFile(xml); + await new Promise(resolve=>setTimeout(resolve,1400)); + await page.click("#attachValidation"); + await page.waitForFunction(()=> + document.querySelector(".ram-official-summary")&& + document.querySelector(".ram-official-summary").textContent + .includes("Matched 1 of"),{timeout:30000}); + await page.screenshot({path:path.join(output,"phase-c-wwpdb.png"), + fullPage:true}); + // Mock only volume parsing to test local map controls deterministically. + // Physical map alignment still requires a genuine CCP4 map and review. + await page.evaluate(()=>{ + const stage=window.getNGLStage&&window.getNGLStage("NGL"); + if(!stage)throw Error("NGL stage unavailable."); + window.__ramDensityChecks={loaded:0,levels:[],removed:0}; + stage.loadFile=async(file,opts)=>{ + if(opts.ext!=="ccp4")throw Error("Wrong CCP4 parser."); + window.__ramDensityChecks.loaded++; + return {addRepresentation:(name,params)=>({ + setParameters:values=>window.__ramDensityChecks.levels.push(values) + })}; + }; + stage.removeComponent=()=>window.__ramDensityChecks.removed++; + }); + await page.click(".ram-density-panel summary"); + await (await page.$("#ram-density-file")).uploadFile(map); + await page.click("#ram-density-load"); + await page.waitForFunction(()=>document.getElementById("ram-density-status") + .textContent.includes("displayed at"),{timeout:20000}); + await page.$eval("#ram-density-level",field=>{ + field.value="3.25";field.dispatchEvent(new Event("change",{bubbles:true})); + }); + const overlay=await page.evaluate(()=>window.__ramDensityChecks); + assert.equal(overlay.loaded,1); + assert.equal(overlay.levels[0].isolevel,3.25); + await page.click("#ram-density-clear"); + const removed=await page.evaluate(()=>window.__ramDensityChecks.removed); + assert.equal(removed,1); + + // Replace the structure with a real NMR ensemble and run model analysis. + await (await page.$("#structfile")).uploadFile(nmr); + await new Promise(resolve=>setTimeout(resolve,1400)); + await page.click("#submit"); + await page.waitForFunction(()=>document.querySelector("#ram-ensemble-panel"), + {timeout:90000}); + await page.evaluate(()=>{ + const anchor=[...document.querySelectorAll(".nav-tabs a")] + .find(x=>x.textContent.trim()==="Summary"); + if(!anchor)throw Error("Summary tab unavailable."); + anchor.click(); + }); + await page.click("#ram-ensemble-panel summary"); + await page.click("#calculateEnsemble"); + await page.waitForFunction(()=>{ + const table=document.querySelector("#ensembleRows table"); + const summary=document.querySelector("#ensembleResultSummary"); + return !!(table&&table.querySelectorAll("tbody tr").length>2&& + summary&&summary.textContent.includes("models analysed")); + },{timeout:90000}); + await page.screenshot({path:path.join(output,"phase-c-ensemble.png"), + fullPage:true}); + console.log("Phase C wwPDB import, NGL overlay and NMR ensemble passed."); + }finally{await browser.close();} +})().catch(error=>{console.error(error);process.exitCode=1;}); From 6ddc4e401a9fbf179447bc5958a06e00f5a5e150 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:20:05 +0200 Subject: [PATCH 29/50] ci: add live browser checks for Phase C evidence, cryo-EM controls and ensembles --- .github/workflows/ui-preview.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ui-preview.yml b/.github/workflows/ui-preview.yml index b04dc44..a7f074f 100644 --- a/.github/workflows/ui-preview.yml +++ b/.github/workflows/ui-preview.yml @@ -73,6 +73,8 @@ jobs: run: node tests/ui-browser.cjs - name: Verify AlphaFold and ESMFold uploads in the live browser run: node tests/prediction-browser.cjs + - name: Verify official wwPDB evidence, density UI and NMR ensemble + run: node tests/phase-c-browser.cjs - name: Archive desktop/mobile screenshot previews and logs if: always() uses: actions/upload-artifact@v4 From f26348155c4c4db02a90337a36f1cf0ffa58e2de Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:21:15 +0200 Subject: [PATCH 30/50] docs: explain independent geometry, maps, ensembles and reproducible batch usage --- docs/phase-c-guide.md | 130 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/phase-c-guide.md diff --git a/docs/phase-c-guide.md b/docs/phase-c-guide.md new file mode 100644 index 0000000..dfce11e --- /dev/null +++ b/docs/phase-c-guide.md @@ -0,0 +1,130 @@ +# Phase C: structural verification, ensembles and batch analysis + +Phase C extends the established RamplotR Ramachandran analysis without +changing its reference densities, four-region labels, or the historical +`v0.1.0-legacy` release. + +## Extended native geometry + +Once a structure loads, expand **Extended structure verification** below the +main plots. RamplotR calculates the peptide dihedral **omega** (CA–C–N–CA) +only across geometrically connected peptide bonds, and **chi1** (N–CA–CB–X1) +for residues with an appropriate first side-chain atom. All angles are in +degrees. Missing atoms, chain breaks and terminal residues have undefined +measurements; they are not scored as outliers. + +For exploration, omega within 30° of 0° is labelled *cis*, omega within 30° +of ±180° is labelled *trans*, and other measured values are marked *twisted*. +These are descriptive flags, **not an independent MolProbity assessment**. +Chi1 is a measurement, not a rotamer outlier prediction. + +## Independent experimental validation + +For a deposited structure, obtain the official wwPDB validation XML or +`XML.gz` for the corresponding entry. Attach the report in the expandable +verification panel. The report is parsed locally and matched by **model, +chain, residue number, insertion code and residue type**, with unlabelled +alternate conformations preferred over alternate A. + +When available, the inspector and detailed CSV show independent wwPDB +Ramachandran, side-chain rotamer, local clash, symmetry clash, bond-length +outlier, bond-angle outlier, RSCC and RSRZ annotations. A missing independent +record remains missing. The app reports exact matching coverage and preserves +the source file's MD5 checksum in its HTML report. + +The original RamplotR contour interpretation and the wwPDB/MolProbity +reference systems are **not equivalent**. The independent outlier-rich +[Phase A results](phase-a-results.md) explicitly show genuine discrepancies. +Importing a report does not rewrite the original region classification. +Do not attach experimental wwPDB validation reports to an unrelated +AlphaFold/ESMFold prediction, even if their sequences are similar. + +Official validation information: +https://www.wwpdb.org/validation/validation-reports + +## Optional cryo-EM map overlay + +Open **Local cryo-EM density map** beneath the NGL viewer, choose your own +CCP4/MRC file, and select *Show map*. The map is read by NGL directly in the +browser, with no external map-fitting service. It is rendered as a translucent +teal isosurface and can be adjusted from 0.5 to 5 sigma. *Remove* clears +the map; loading a different structure also clears any previous overlay. +Only files up to 64 MB are supported to protect ordinary laptop sessions. +NGL can be slow with large maps; the overlay is a qualitative visual aid, +**not an RSCC/Q-score or local map-model-fit measurement**. Use the +corresponding official report or a validated external map-fit tool when +quantitative claims are required. + +NGL stage and volume API: +https://nglviewer.org/ngl/api/class/src/stage/stage.js~Stage.html + +## NMR / multi-model ensemble + +For an input containing multiple atom-compatible models, expand **Ensemble +analysis** on the Summary tab, then select *Analyse ensemble*. It analyses +up to the first 30 models on demand, using the currently chosen reference +dataset, classification mode and plotting background. The tool matches +residues by chain, residue number, insertion code and amino-acid identity, +not by row number. It reports circular means and standard deviations of +phi/psi angles, observed-model counts and the proportion of models agreeing +on a class. Single-angle observations have undefined variability. + +A changed reference invalidates the previous analysis until recalculated. +Clicking an ensemble residue selects it in the shared inspector, plot and 3D +viewer. Use **Export ensemble CSV** to keep the per-residue results. A +prediction ensemble is an ensemble of output conformations, not proof of +experimental flexibility or pLDDT uncertainty. + +## Offline batch mode + +From the repository root, with R and Bio3D installed, run: + +```bash +Rscript scripts/ramplotr-batch.R --input structures/ --output results/ \ + --reference original --mode residue --model 1 --report +``` + +The command accepts one local PDB/mmCIF file or a nonrecursive directory of +up to 1000 supported structure files. By default it produces residue-level +CSV and JSON, a batch-summary CSV, and optionally a standalone SVG plus HTML +report. Each file's output is named from its source filename, and existing +files are protected unless `--overwrite` is specified. The full CSV +includes omega/chi1 and available prediction-confidence or independent +validation annotations. + +For a consistent NMR structure, request an ensemble table: + +```bash +Rscript scripts/ramplotr-batch.R --input 1D3Z.pdb --output results/ \ + --model 1 --ensemble-models 20 --report +``` + +For **one** experimental structure, add its official XML: + +```bash +Rscript scripts/ramplotr-batch.R --input 1CRN.pdb --output results/ \ + --validation-xml 1crn_validation.xml.gz --report +``` + +For an ESMFold prediction, explicitly set +`--prediction-source esmfold`; never request this for an experimental +structure. For AlphaFold 2/3, the CLI can read declared pLDDT from compatible +B-factor files; AF3 confidence sidecar parsing remains available in the +interactive Phase B uploader. + +Use `--no-json` to avoid the optional jsonlite dependency; `--report` +requires htmltools and an SVG-capable R graphics device, while wwPDB XML +requires xml2. The CLI never downloads structures or sends coordinates to +external servers. Nonzero exit status indicates at least one failed input, +and `batch-summary.csv` records any per-file errors. + +## Reproducibility and methodological limits + +The outputs record the current model, file MD5 checksum, reference file MD5, +R/Bio3D versions, chosen scientific mode and prediction provenance where +declared. The HTML report distinguishes computed geometry from imported +independent evidence and prints model-ensemble statistics when available. +Use the pinned Phase A corpus and reference-validation reports before +making formal validation-performance claims. Avoid presenting any visual +density overlay or geometric heuristic as an official experimental +fit or MolProbity-equivalent score. From c49a8ba7b490b8d2f187c00fe8e85b42801e7f08 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:21:33 +0200 Subject: [PATCH 31/50] docs: link optional Phase C geometry, wwPDB, NMR and batch features from README --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 07a61b4..8d501c9 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,20 @@ AlphaFold DB accession lookup and explicit AlphaFold 2/3, ColabFold and ESMFold For screenshots, limitations and the complete workflow see the [inspection and publication guide](docs/inspection-user-guide.md). The interface layout and browser verification history are documented in [interface-refresh.md](docs/interface-refresh.md). +## Extended verification and research workflows (Phase C) + +RamplotR now provides optional **peptide omega and descriptive chi1 +measurements**, imports official **wwPDB rotamer, clash and bond/angle outlier +annotations** as separate evidence, and can overlay **local CCP4/MRC cryo-EM +maps** in NGL. Consistent multi-model structures can be analysed as circular +phi/psi ensembles with residue-level classification agreement. For +reproducible offline studies, `scripts/ramplotr-batch.R` processes a directory +of PDB/mmCIF files and writes CSV, JSON, SVG and standalone HTML reports. +None of the newly computed angle diagnostics is presented as an official +MolProbity or density-fit score. See the +[Phase C guide](docs/phase-c-guide.md) for usage, validation provenance, +resource limits and scientific caveats. + ## Independent Phase A validation The [independent validation protocol](docs/wwpdb-validation.md) uses official From 46df172b16f9ea89b42e316db1da6f79f3c4b578 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:22:35 +0200 Subject: [PATCH 32/50] fix(report): retain missing-angle and total-residue counts in HTML exports --- shinyRam/R/reports.R | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shinyRam/R/reports.R b/shinyRam/R/reports.R index 2141de5..4683dc7 100644 --- a/shinyRam/R/reports.R +++ b/shinyRam/R/reports.R @@ -138,7 +138,10 @@ ram_save_html_report <- function(path, data, metadata, svg_path, "Portable report: figure, counts, geometry and provenance. Native and independent results are reported separately."), htmltools::tags$section(htmltools::tags$h2("Ramachandran plot"),chart), htmltools::tags$section(htmltools::tags$h2("RamplotR region counts"), - table_for(data.frame(Region=names(tally),Residues=as.integer(tally)))), + table_for(data.frame( + Region=c(names(tally),"Missing angles","All selected residues"), + Residues=c(as.integer(tally), + sum(is.na(data$phi)|is.na(data$psi)),nrow(data))))), if("omega_status" %in% names(data)) htmltools::tags$section( htmltools::tags$h2("Additional descriptive geometry"), From 165af60c471c78dc51732cab66729b4217eacf7e Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:24:40 +0200 Subject: [PATCH 33/50] test(browser): activate Summary before awaiting its lazy ensemble controls --- tests/phase-c-browser.cjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/phase-c-browser.cjs b/tests/phase-c-browser.cjs index c79b6de..80ea3fa 100644 --- a/tests/phase-c-browser.cjs +++ b/tests/phase-c-browser.cjs @@ -91,14 +91,20 @@ const puppeteer=require("puppeteer-core"); await (await page.$("#structfile")).uploadFile(nmr); await new Promise(resolve=>setTimeout(resolve,1400)); await page.click("#submit"); - await page.waitForFunction(()=>document.querySelector("#ram-ensemble-panel"), - {timeout:90000}); + await page.waitForFunction(()=>{ + const status=document.querySelector("#ram-current-structure"); + return status && status.textContent.includes("1D3Z"); + },{timeout:90000}); + // Shiny intentionally suspends outputs in hidden tabs, so activate + // Summary before waiting for the lazily generated ensemble panel. await page.evaluate(()=>{ const anchor=[...document.querySelectorAll(".nav-tabs a")] .find(x=>x.textContent.trim()==="Summary"); if(!anchor)throw Error("Summary tab unavailable."); anchor.click(); }); + await page.waitForFunction(()=>document.querySelector("#ram-ensemble-panel"), + {timeout:30000}); await page.click("#ram-ensemble-panel summary"); await page.click("#calculateEnsemble"); await page.waitForFunction(()=>{ From 5703e392845edd99f1ee9f410663cda2af80fd0d Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:26:24 +0200 Subject: [PATCH 34/50] test(browser): capture local density-map failure state and stage diagnostics --- tests/phase-c-browser.cjs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/phase-c-browser.cjs b/tests/phase-c-browser.cjs index 80ea3fa..7e06232 100644 --- a/tests/phase-c-browser.cjs +++ b/tests/phase-c-browser.cjs @@ -75,8 +75,23 @@ const puppeteer=require("puppeteer-core"); await page.click(".ram-density-panel summary"); await (await page.$("#ram-density-file")).uploadFile(map); await page.click("#ram-density-load"); - await page.waitForFunction(()=>document.getElementById("ram-density-status") - .textContent.includes("displayed at"),{timeout:20000}); + try { + await page.waitForFunction(()=>document.getElementById("ram-density-status") + .textContent.includes("displayed at"),{timeout:12000}); + } catch(error) { + const diagnostics=await page.evaluate(()=>({ + status:document.getElementById("ram-density-status").textContent, + filename:document.getElementById("ram-density-file").files[0]&& + document.getElementById("ram-density-file").files[0].name, + counters:window.__ramDensityChecks, + stageAvailable:typeof window.getNGLStage==="function" && + !!window.getNGLStage("NGL") + })); + console.error("Phase C map controls:",JSON.stringify(diagnostics)); + await page.screenshot({path:path.join(output,"phase-c-map-failure.png"), + fullPage:true}); + throw error; + } await page.$eval("#ram-density-level",field=>{ field.value="3.25";field.dispatchEvent(new Event("change",{bubbles:true})); }); From e0ab1386fc9460187a1e1b7d426a8d2ba142da2e Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:30:05 +0200 Subject: [PATCH 35/50] privacy(cryo-EM): create map picker after Shiny input binding to keep local maps in browser --- shinyRam/app.R | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/shinyRam/app.R b/shinyRam/app.R index 0e7344a..f1320c0 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -400,8 +400,10 @@ ui <- fluidPage( tags$summary("Local cryo-EM density map"), tags$p(class="ram-field-hint", "Overlay a local CCP4/MRC map. This is a visual aid, not an experimental map-fit score."), - tags$input(type="file",id="ram-density-file", - accept=".map,.mrc,.ccp4"), + # Insert the file picker after Shiny's initial input + # binding so the map stays client-side in the browser. + tags$div(id="ram-density-file-slot", + class="ram-density-picker"), tags$div(class="ram-density-level", tags$label("Map threshold (σ)", `for`="ram-density-level"), tags$input(type="range",id="ram-density-level", From 757c7949ac116f7200b4d57ab054639d49cf054b Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:31:59 +0200 Subject: [PATCH 36/50] privacy(cryo-EM): mount map picker after Shiny binds inputs so maps stay browser-local --- shinyRam/www/density.js | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/shinyRam/www/density.js b/shinyRam/www/density.js index 10905e8..dfae9f9 100644 --- a/shinyRam/www/density.js +++ b/shinyRam/www/density.js @@ -6,6 +6,7 @@ "use strict"; let component = null, representation = null, owningStage = null; let generation = 0; + let selectedFile = null; const bytesLimit = 64 * 1024 * 1024; const find = id => document.getElementById(id); const status = text => { @@ -15,6 +16,37 @@ const currentStage = () => typeof window.getNGLStage === "function" ? window.getNGLStage("NGL") : null; + // File inputs created inside the original Shiny UI are bound by Shiny + // and upload their contents to the R server before NGL can use them. + // Install this plain browser-only input after the Shiny connection has + // completed; never call Shiny.bindAll on this locally owned element. + function installLocalPicker() { + const slot = find("ram-density-file-slot"); + if (!slot || find("ram-density-file")) return; + const input = document.createElement("input"); + input.type = "file"; + input.id = "ram-density-file"; + input.accept = ".map,.mrc,.ccp4"; + input.setAttribute("aria-label", "Choose local CCP4/MRC map"); + input.addEventListener("change", function () { + selectedFile = input.files && input.files[0] || null; + status(selectedFile + ? "Selected " + selectedFile.name + ". Choose Show map to overlay." + : "No map selected."); + }); + slot.appendChild(input); + } + if (window.jQuery && window.Shiny) { + window.jQuery(document).one("shiny:connected", installLocalPicker); + if (window.Shiny.shinyapp && + typeof window.Shiny.shinyapp.isConnected === "function" && + window.Shiny.shinyapp.isConnected()) installLocalPicker(); + } else { + // In standalone browser previews there is no Shiny input binder. + if (document.readyState === "loading") + document.addEventListener("DOMContentLoaded", installLocalPicker); + else installLocalPicker(); + } const level = () => { const input = find("ram-density-level"); const n = Number(input ? input.value : 2); @@ -31,7 +63,7 @@ } async function loadMap() { const input = find("ram-density-file"); - const file = input && input.files && input.files[0]; + const file = selectedFile || input && input.files && input.files[0]; if (!file) return status("Choose a local CCP4/MRC map first."); if (!/\.(map|mrc|ccp4)$/i.test(file.name)) return status("Only .map, .mrc and .ccp4 files are supported."); @@ -102,6 +134,9 @@ if (window.Shiny && typeof window.Shiny.addCustomMessageHandler==="function") window.Shiny.addCustomMessageHandler("ram-clear-density",function (_message) { clearMap(false); + selectedFile = null; + const input = find("ram-density-file"); + if (input) input.value = ""; status("No map loaded."); }); })(); From 9a3cf5d2f7251bc63d3cd68067f1533c0dfa5534 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:32:20 +0200 Subject: [PATCH 37/50] test(browser): assert CCP4/MRC picker is unbound by Shiny and locally retained --- tests/phase-c-browser.cjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/phase-c-browser.cjs b/tests/phase-c-browser.cjs index 7e06232..b3956ab 100644 --- a/tests/phase-c-browser.cjs +++ b/tests/phase-c-browser.cjs @@ -73,7 +73,18 @@ const puppeteer=require("puppeteer-core"); stage.removeComponent=()=>window.__ramDensityChecks.removed++; }); await page.click(".ram-density-panel summary"); + await page.waitForSelector("#ram-density-file",{timeout:15000}); + const browserLocal=await page.$eval("#ram-density-file",input=> + !input.classList.contains("shiny-bound-input") && + !input.closest(".shiny-input-container")); + assert.equal(browserLocal,true, + "CCP4/MRC map files must remain in the browser and never enter Shiny's upload bindings."); await (await page.$("#ram-density-file")).uploadFile(map); + await page.waitForFunction(()=>{ + const picker=document.getElementById("ram-density-file"); + return picker&&picker.files&&picker.files.length===1 && + document.getElementById("ram-density-status").textContent.includes("Selected "); + }); await page.click("#ram-density-load"); try { await page.waitForFunction(()=>document.getElementById("ram-density-status") From 612647fb4bb07b11407db26caa7d95ff3a700b94 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:41:36 +0200 Subject: [PATCH 38/50] test(browser): restore NGL structure loader after mocking local map overlay and report ensemble failures --- tests/phase-c-browser.cjs | 63 +++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/tests/phase-c-browser.cjs b/tests/phase-c-browser.cjs index b3956ab..dd4f3fb 100644 --- a/tests/phase-c-browser.cjs +++ b/tests/phase-c-browser.cjs @@ -63,14 +63,29 @@ const puppeteer=require("puppeteer-core"); const stage=window.getNGLStage&&window.getNGLStage("NGL"); if(!stage)throw Error("NGL stage unavailable."); window.__ramDensityChecks={loaded:0,levels:[],removed:0}; + const originalLoad=stage.loadFile.bind(stage); + const originalRemove=stage.removeComponent.bind(stage); + stage.__ramOriginalLoadFile=originalLoad; + stage.__ramOriginalRemoveComponent=originalRemove; stage.loadFile=async(file,opts)=>{ - if(opts.ext!=="ccp4")throw Error("Wrong CCP4 parser."); - window.__ramDensityChecks.loaded++; - return {addRepresentation:(name,params)=>({ - setParameters:values=>window.__ramDensityChecks.levels.push(values) - })}; + // Only simulate volume parsing. NGLVieweR also uses loadFile for + // ordinary structure updates and must not be intercepted. + if(opts&&opts.ext==="ccp4") { + window.__ramDensityChecks.loaded++; + return {addRepresentation:(name,params)=>({ + setParameters:values=>window.__ramDensityChecks.levels.push(values) + })}; + } + return originalLoad(file,opts); + }; + stage.removeComponent=comp=>{ + if(comp && typeof comp.addRepresentation==="function" && + !comp.structure && !comp.volume) { + window.__ramDensityChecks.removed++; + return; + } + return originalRemove(comp); }; - stage.removeComponent=()=>window.__ramDensityChecks.removed++; }); await page.click(".ram-density-panel summary"); await page.waitForSelector("#ram-density-file",{timeout:15000}); @@ -112,6 +127,14 @@ const puppeteer=require("puppeteer-core"); await page.click("#ram-density-clear"); const removed=await page.evaluate(()=>window.__ramDensityChecks.removed); assert.equal(removed,1); + // Restore the original NGL Stage methods before changing structures. + await page.evaluate(()=>{ + const stage=window.getNGLStage("NGL"); + if(stage && stage.__ramOriginalLoadFile) + stage.loadFile=stage.__ramOriginalLoadFile; + if(stage && stage.__ramOriginalRemoveComponent) + stage.removeComponent=stage.__ramOriginalRemoveComponent; + }); // Replace the structure with a real NMR ensemble and run model analysis. await (await page.$("#structfile")).uploadFile(nmr); @@ -133,12 +156,28 @@ const puppeteer=require("puppeteer-core"); {timeout:30000}); await page.click("#ram-ensemble-panel summary"); await page.click("#calculateEnsemble"); - await page.waitForFunction(()=>{ - const table=document.querySelector("#ensembleRows table"); - const summary=document.querySelector("#ensembleResultSummary"); - return !!(table&&table.querySelectorAll("tbody tr").length>2&& - summary&&summary.textContent.includes("models analysed")); - },{timeout:90000}); + try { + await page.waitForFunction(()=>{ + const table=document.querySelector("#ensembleRows table"); + const summary=document.querySelector("#ensembleResultSummary"); + return !!(table&&table.querySelectorAll("tbody tr").length>2&& + summary&&summary.textContent.includes("models analysed")); + },{timeout:90000}); + }catch(error){ + const details=await page.evaluate(()=>({ + currentStructure:document.querySelector("#ram-current-structure")?.textContent, + ensemblePanel:document.querySelector("#ram-ensemble-panel")?.textContent.slice(0,500), + summary:document.querySelector("#ensembleResultSummary")?.textContent, + tableRows:document.querySelectorAll("#ensembleRows table tbody tr").length, + currentTab:document.querySelector(".nav-tabs li.active a")?.textContent, + notifications:[...document.querySelectorAll(".shiny-notification")] + .map(node=>node.textContent) + })); + console.error("Phase C ensemble diagnostics:",JSON.stringify(details)); + await page.screenshot({path:path.join(output,"phase-c-ensemble-failure.png"), + fullPage:true}); + throw error; + } await page.screenshot({path:path.join(output,"phase-c-ensemble.png"), fullPage:true}); console.log("Phase C wwPDB import, NGL overlay and NMR ensemble passed."); From 267f366ff4c91fb1496de508d3904031f5129c34 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:43:57 +0200 Subject: [PATCH 39/50] fix(science): require matching-deposition acknowledgement before importing official experimental reports --- shinyRam/app.R | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/shinyRam/app.R b/shinyRam/app.R index f1320c0..ee538bf 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -767,7 +767,8 @@ server <- function(input, output, session) { session$sendCustomMessage("ram-clear-density",list()) 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)) + viewer_format = viewer_format, prediction = prediction, + declared_source = declared_source, input_source = source_type)) incProgress(0.25, detail = "Preparing interactive views") }) }, ignoreInit = TRUE) @@ -846,6 +847,19 @@ server <- function(input, output, session) { observeEvent(loaded(), external_validation(NULL), ignoreInit=TRUE) observeEvent(input$attachValidation, { structure <- req(loaded()) + if (!identical(structure$declared_source, "experimental") || + identical(structure$input_source, "afdb")) { + showNotification( + "Official wwPDB reports apply to their deposited experimental structure, not a predicted model.", + type = "error", duration = 14) + return() + } + if (!isTRUE(input$confirmValidationSource)) { + showNotification( + "Confirm that the official wwPDB report belongs to this exact deposited structure and model.", + type = "warning", duration = 12) + return() + } file <- req(input$validationXml) record <- tryCatch(ram_external_validation_read(file$datapath), error=function(e) { @@ -855,6 +869,7 @@ server <- function(input, output, session) { if(is.null(record)) return() external_validation(list(key=structure$key,records=record, name=file$name,md5=unname(tools::md5sum(file$datapath)))) + updateCheckboxInput(session, "confirmValidationSource", value = FALSE) showNotification("Official wwPDB annotations attached. Check model and residue coverage.", type="message") },ignoreInit=TRUE) @@ -1140,6 +1155,9 @@ server <- function(input, output, session) { tags$div(class="ram-phase-c-attach", fileInput("validationXml","Attach wwPDB validation XML (.xml or .xml.gz)", accept=c(".xml",".gz")), + checkboxInput("confirmValidationSource", + "This official report belongs to the loaded deposited experimental structure.", + value = FALSE), actionButton("attachValidation","Attach report",class="btn-primary btn-sm"), actionButton("clearValidation","Clear",class="btn-default btn-sm") ), From 6738a944f739006feb16c9410040fdbe47cf8468 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:44:39 +0200 Subject: [PATCH 40/50] test(browser): confirm official report belongs to loaded experimental structure --- tests/phase-c-browser.cjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/phase-c-browser.cjs b/tests/phase-c-browser.cjs index dd4f3fb..25167f1 100644 --- a/tests/phase-c-browser.cjs +++ b/tests/phase-c-browser.cjs @@ -50,6 +50,12 @@ const puppeteer=require("puppeteer-core"); await page.waitForSelector("#validationXml"); await (await page.$("#validationXml")).uploadFile(xml); await new Promise(resolve=>setTimeout(resolve,1400)); + await page.click("#confirmValidationSource"); + await page.waitForFunction(() => + window.Shiny && window.Shiny.shinyapp && + window.Shiny.shinyapp.$inputValues && + window.Shiny.shinyapp.$inputValues.confirmValidationSource === true, + {timeout:15000}); await page.click("#attachValidation"); await page.waitForFunction(()=> document.querySelector(".ram-official-summary")&& From 3c6eadaa1a5358512044339a904d7b5171120b9d Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:45:22 +0200 Subject: [PATCH 41/50] docs(science): require matching deposited structure provenance for imported official validation --- docs/phase-c-guide.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/phase-c-guide.md b/docs/phase-c-guide.md index dfce11e..97b0500 100644 --- a/docs/phase-c-guide.md +++ b/docs/phase-c-guide.md @@ -21,8 +21,13 @@ Chi1 is a measurement, not a rotamer outlier prediction. ## Independent experimental validation For a deposited structure, obtain the official wwPDB validation XML or -`XML.gz` for the corresponding entry. Attach the report in the expandable -verification panel. The report is parsed locally and matched by **model, +`XML.gz` for the **same deposited entry and structural model**. Attach it in +the expandable verification panel and confirm that it belongs to the +loaded experimental structure. The app rejects official reports for declared +predicted-model inputs, including AlphaFold DB. For local or accession-loaded +experimental structures, you must still verify accession/model provenance: +matching sequence numbering alone cannot establish that two deposits are the +same experiment. The report is parsed locally and matched by **model, chain, residue number, insertion code and residue type**, with unlabelled alternate conformations preferred over alternate A. From eee79c20214e02ff38313a3d7169503341147071 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:46:32 +0200 Subject: [PATCH 42/50] test(browser): check all DataTables body rows after ensemble analysis, not just first table --- tests/phase-c-browser.cjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/phase-c-browser.cjs b/tests/phase-c-browser.cjs index 25167f1..575172c 100644 --- a/tests/phase-c-browser.cjs +++ b/tests/phase-c-browser.cjs @@ -164,10 +164,12 @@ const puppeteer=require("puppeteer-core"); await page.click("#calculateEnsemble"); try { await page.waitForFunction(()=>{ - const table=document.querySelector("#ensembleRows table"); + // DataTables can create a separate header table when its tab is + // resized; inspect rows across the output, not only its first table. + const rows=document.querySelectorAll("#ensembleRows tbody tr"); const summary=document.querySelector("#ensembleResultSummary"); - return !!(table&&table.querySelectorAll("tbody tr").length>2&& - summary&&summary.textContent.includes("models analysed")); + return !!(rows.length>2&&summary&& + summary.textContent.includes("models analysed")); },{timeout:90000}); }catch(error){ const details=await page.evaluate(()=>({ From b5223973b1acf162ec89ec5f2fb7a081df72ab40 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:47:16 +0200 Subject: [PATCH 43/50] fix(security): cap uncompressed wwPDB gzip bytes before XML parsing --- shinyRam/R/experimental.R | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/shinyRam/R/experimental.R b/shinyRam/R/experimental.R index 995bba4..8f52d08 100644 --- a/shinyRam/R/experimental.R +++ b/shinyRam/R/experimental.R @@ -9,9 +9,16 @@ ram_external_validation_read <- function(path, max_bytes=32000000L, if (!file.exists(path) || !is.finite(file.info(path)$size) || file.info(path)$size <= 0 || file.info(path)$size > max_bytes) stop("Provide a nonempty wwPDB validation XML (or XML.gz), 32 MB maximum.") - raw <- readBin(path,what="raw",n=file.info(path)$size) - if (grepl("\\.gz$",path,ignore.case=TRUE)) - raw <- memDecompress(raw,type="gzip") + # Read gzip streams through a capped connection. memDecompress() on an + # entire uploaded gzip allocates the uncompressed size before it can be + # checked, allowing a small gzip bomb to exhaust a Shiny worker. + if (grepl("\\.gz$",path,ignore.case=TRUE)) { + con <- gzfile(path,open="rb") + on.exit(close(con),add=TRUE) + raw <- readBin(con,what="raw",n=as.integer(max_bytes)+1L) + } else { + raw <- readBin(path,what="raw",n=as.integer(max_bytes)+1L) + } if (length(raw) > max_bytes) stop("The uncompressed wwPDB report exceeds the 32 MB limit.") doc <- xml2::read_xml(raw) From 16ae26b04942a730002997ee0a32ea47e0965f7e Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:47:33 +0200 Subject: [PATCH 44/50] test: verify streamed wwPDB XML.gz and decompressed upload limit --- tests/experimental.R | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/experimental.R b/tests/experimental.R index 8effec0..e2620fa 100644 --- a/tests/experimental.R +++ b/tests/experimental.R @@ -32,4 +32,17 @@ assert(summary$matched==2L && summary$official_rotamer_outliers==2L && "Independent report counts must use only matched residues.") bad <- try(ram_external_validation_read(source_file,max_bytes=8L),silent=TRUE) assert(inherits(bad,"try-error"),"Oversized XML must be rejected.") +gzpath <- tempfile(fileext=".xml.gz") +con <- gzfile(gzpath,open="wb") +writeBin(readBin(source_file,what="raw",n=file.info(source_file)$size),con) +close(con) +on.exit_gz <- function() unlink(gzpath) +compressed <- ram_external_validation_read(gzpath) +assert(nrow(compressed)==nrow(official), + "The streamed gzip parser must preserve every official record.") +blocked <- try(ram_external_validation_read(gzpath,max_bytes=32L), + silent=TRUE) +assert(inherits(blocked,"try-error"), + "Uncompressed XML limits must also apply to compressed reports.") +on.exit_gz() message("External wwPDB geometry/rotamer/clash XML import tests passed.") From c6278bd9dd48f9f010b32c4c8db798e67d660d65 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:48:48 +0200 Subject: [PATCH 45/50] test(security): reject small gzip files that inflate beyond XML size cap --- tests/experimental.R | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/experimental.R b/tests/experimental.R index e2620fa..2dcd7e0 100644 --- a/tests/experimental.R +++ b/tests/experimental.R @@ -40,9 +40,18 @@ on.exit_gz <- function() unlink(gzpath) compressed <- ram_external_validation_read(gzpath) assert(nrow(compressed)==nrow(official), "The streamed gzip parser must preserve every official record.") -blocked <- try(ram_external_validation_read(gzpath,max_bytes=32L), +# A tiny compressed file that expands beyond the configured limit must +# fail while streaming, not after allocating its entire inflated contents. +bombpath <- tempfile(fileext=".xml.gz") +bombcon <- gzfile(bombpath,open="wb") +writeBin(charToRaw(paste0("",strrep(" ",50000),"")),bombcon) +close(bombcon) +assert(file.info(bombpath)$size < 1024L, + "Synthetic gzip fixture must be smaller than the upload limit.") +blocked <- try(ram_external_validation_read(bombpath,max_bytes=1024L), silent=TRUE) assert(inherits(blocked,"try-error"), - "Uncompressed XML limits must also apply to compressed reports.") + "Expanded XML limits must also apply to compressed reports.") +unlink(bombpath) on.exit_gz() message("External wwPDB geometry/rotamer/clash XML import tests passed.") From 7dfd91f3267f87fce9ed728e5420e801c8b5ab7e Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:49:55 +0200 Subject: [PATCH 46/50] =?UTF-8?q?feat(geometry):=20report=20descriptive=20?= =?UTF-8?q?C=CE=B2=20bond=20length=20and=20backbone-relative=20signed=20vo?= =?UTF-8?q?lume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shinyRam/R/geometry.R | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/shinyRam/R/geometry.R b/shinyRam/R/geometry.R index 51a9ba6..cbaa9c0 100644 --- a/shinyRam/R/geometry.R +++ b/shinyRam/R/geometry.R @@ -27,7 +27,9 @@ ram_extra_geometry <- function(pdb, torsions, peptide_min = 1.0, result <- data.frame( omega=rep(NA_real_,n), peptide_bond_length=rep(NA_real_,n), omega_status=rep("Missing",n), chi1=rep(NA_real_,n), - chi1_available=rep(FALSE,n), stringsAsFactors=FALSE) + chi1_available=rep(FALSE,n), + cb_ca_distance=rep(NA_real_,n), cb_signed_volume=rep(NA_real_,n), + stringsAsFactors=FALSE) if (!n) return(result) if (!"insert" %in% names(atoms)) atoms$insert <- "" if (!"alt" %in% names(atoms)) atoms$alt <- "" @@ -60,6 +62,21 @@ ram_extra_geometry <- function(pdb, torsions, peptide_min = 1.0, c_atom <- get_atom(seq_len(n), "C") n_atom <- get_atom(seq_len(n), "N") cb <- get_atom(seq_len(n), "CB") + cb_rows <- which(complete.cases(cbind(ca,cb))) + if(length(cb_rows)) result$cb_ca_distance[cb_rows] <- + sqrt(rowSums((cb[cb_rows,,drop=FALSE]-ca[cb_rows,,drop=FALSE])^2)) + # Signed tetrahedral volume describes N–CA–C–CB chirality. It is a + # measured geometry value, NOT a validated Cβ-deviation/outlier score. + cb_volume_rows <- which(complete.cases(cbind(n_atom,ca,c_atom,cb))) + if(length(cb_volume_rows)) { + v1 <- n_atom[cb_volume_rows,,drop=FALSE]-ca[cb_volume_rows,,drop=FALSE] + v2 <- c_atom[cb_volume_rows,,drop=FALSE]-ca[cb_volume_rows,,drop=FALSE] + v3 <- cb[cb_volume_rows,,drop=FALSE]-ca[cb_volume_rows,,drop=FALSE] + cross <- cbind(v1[,2]*v2[,3]-v1[,3]*v2[,2], + v1[,3]*v2[,1]-v1[,1]*v2[,3], + v1[,1]*v2[,2]-v1[,2]*v2[,1]) + result$cb_signed_volume[cb_volume_rows] <- rowSums(cross*v3) + } chi_target <- unname(ram_geom_chi1_atom[as.character(torsions$resn)]) target <- matrix(NA_real_, nrow=n, ncol=3L) for (atom_name in unique(chi_target[!is.na(chi_target)])) { From 3fc595e441c4d8d0a25e35b140fe342be351dd5d Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:50:17 +0200 Subject: [PATCH 47/50] =?UTF-8?q?test(geometry):=20cover=20C=CE=B2=20lengt?= =?UTF-8?q?hs,=20chirality=20sign=20and=20missing=20side=20chains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/geometry.R | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/geometry.R b/tests/geometry.R index 5dbcf4b..54163c0 100644 --- a/tests/geometry.R +++ b/tests/geometry.R @@ -23,6 +23,19 @@ assert(nrow(geom)==2L && geom$chi1_available[[1]] && geom$chi1_available[[2]] && is.finite(geom$omega[[1]]) && is.finite(geom$peptide_bond_length[[1]]), "Complete peptide/side chain must yield finite diagnostic angles.") +assert(isTRUE(all.equal(geom$cb_ca_distance[[1]],1)) && + all(is.finite(geom$cb_ca_distance)) && + is.finite(geom$cb_signed_volume[[2]]) && + abs(geom$cb_signed_volume[[2]])>1e-5, + "Cβ bond length and signed volume must be computed when all atoms exist.") +mirror <- pdb +ix <- which(mirror$atom$resno==2L & mirror$atom$elety=="CB") +mirror$atom$y[ix] <- 2*mirror$atom$y[ + which(mirror$atom$resno==2L & mirror$atom$elety=="CA")] - + mirror$atom$y[ix] +opposite <- ram_extra_geometry(mirror,torsion) +assert(geom$cb_signed_volume[[2]]*opposite$cb_signed_volume[[2]]<0, + "Reflecting Cβ across the backbone plane must reverse signed volume.") assert(is.na(geom$omega[[2]]) && geom$omega_status[[2]]=="Missing", "Termini must not be assigned peptide omega.") # Break between chains even if atom coordinates remain chemically close. @@ -37,6 +50,13 @@ deleted$atom <- deleted$atom[deleted$atom$elety!="OG",,drop=FALSE] missing <- ram_extra_geometry(deleted,torsion) assert(is.na(missing$chi1[[1]]) && !missing$chi1_available[[1]], "Missing side-chain atoms must remain explicitly unclassified.") +without_cb <- pdb +without_cb$atom <- without_cb$atom[!( + without_cb$atom$resno==1L & without_cb$atom$elety=="CB"),,drop=FALSE] +absent <- ram_extra_geometry(without_cb,torsion) +assert(is.na(absent$cb_ca_distance[[1]]) && + is.na(absent$cb_signed_volume[[1]]), + "Missing Cβ must not produce fabricated geometry values.") # Insertion-code and chain identifiers are preserved by the backbone join. joined <- ram_join_geometry(torsion,geom) assert(nrow(joined)==nrow(torsion) && From bc19ea87b991afbc3f8bffbe04c6e030fd70c6bf Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:50:37 +0200 Subject: [PATCH 48/50] =?UTF-8?q?report:=20include=20descriptive=20C=CE=B2?= =?UTF-8?q?=20geometry=20in=20scientific=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shinyRam/R/reports.R | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/shinyRam/R/reports.R b/shinyRam/R/reports.R index 4683dc7..dad6db9 100644 --- a/shinyRam/R/reports.R +++ b/shinyRam/R/reports.R @@ -102,9 +102,11 @@ ram_save_html_report <- function(path, data, metadata, svg_path, h(gsub("_"," ",name),metadata[[name]])) shown <- utils::head(data[,intersect(c( "chain","resi","insertion_code","resn","phi","psi","region", - "density","omega","omega_status","chi1","plddt","confidence_category"), + "density","omega","omega_status","chi1","cb_ca_distance", + "cb_signed_volume","plddt","confidence_category"), names(data)),drop=FALSE],max_report_rows) - for(field in intersect(c("phi","psi","density","omega","chi1","plddt"), + for(field in intersect(c("phi","psi","density","omega","chi1","cb_ca_distance", + "cb_signed_volume","plddt"), names(shown))) shown[[field]] <- round(shown[[field]],2L) official <- NULL From bf7110ce95ed43ab0b5dd8e73fbcb10f0bcec805 Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:50:40 +0200 Subject: [PATCH 49/50] =?UTF-8?q?ui:=20display=20measured=20C=CE=B2=20cove?= =?UTF-8?q?rage=20in=20extended=20geometry=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shinyRam/app.R | 1 + 1 file changed, 1 insertion(+) diff --git a/shinyRam/app.R b/shinyRam/app.R index ee538bf..98299c2 100644 --- a/shinyRam/app.R +++ b/shinyRam/app.R @@ -1177,6 +1177,7 @@ server <- function(input, output, session) { metric("Cis peptide bonds",sum(data$omega_status=="Cis",na.rm=TRUE)), metric("Twisted peptide bonds",sum(data$omega_status=="Twisted",na.rm=TRUE)), metric("Measured χ1 angles",sum(is.finite(data$chi1))), + metric("Measured Cβ bonds",sum(is.finite(data$cb_ca_distance))), metric("Missing ω",sum(!is.finite(data$omega))) ) }) From 569c3a0412d293593c6bd8065536a399ccbed3ee Mon Sep 17 00:00:00 2001 From: spomlol <77454330+CedricHermansBIT@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:50:43 +0200 Subject: [PATCH 50/50] =?UTF-8?q?docs:=20distinguish=20measured=20C=CE=B2?= =?UTF-8?q?=20geometry=20from=20independent=20deviation=20scores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/phase-c-guide.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/phase-c-guide.md b/docs/phase-c-guide.md index 97b0500..dd29c41 100644 --- a/docs/phase-c-guide.md +++ b/docs/phase-c-guide.md @@ -9,8 +9,11 @@ changing its reference densities, four-region labels, or the historical Once a structure loads, expand **Extended structure verification** below the main plots. RamplotR calculates the peptide dihedral **omega** (CA–C–N–CA) only across geometrically connected peptide bonds, and **chi1** (N–CA–CB–X1) -for residues with an appropriate first side-chain atom. All angles are in -degrees. Missing atoms, chain breaks and terminal residues have undefined +for residues with an appropriate first side-chain atom. Cβ measurements +include the observed **CA–CB distance** (Å) and signed N–CA–C–CB tetrahedral +volume (ų) for residues with all four atoms. These are descriptive +measurements, not independently validated Cβ-deviation or chirality-outlier +classifications. All angles are in degrees. Missing atoms, chain breaks and terminal residues have undefined measurements; they are not scored as outliers. For exploration, omega within 30° of 0° is labelled *cis*, omega within 30°