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°