Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9323fe2
Add shinyRam/R/group-comparison.R
CedricHermansBIT Oct 6, 2026
6103734
Add tests/group-comparison.R
CedricHermansBIT Oct 6, 2026
5602366
Add docs/group-conformation-comparison.md
CedricHermansBIT Oct 6, 2026
ca1c98f
Integrate structure-group comparison on current main
CedricHermansBIT Oct 6, 2026
8a8646a
Link group comparison track on current main
CedricHermansBIT Oct 6, 2026
bc34bbf
Style group comparison on current main
CedricHermansBIT Oct 6, 2026
4451bd6
Run group comparison regressions
CedricHermansBIT Oct 6, 2026
a5b66e5
Document group comparison alongside current workflows
CedricHermansBIT Oct 6, 2026
25e0cb8
Exercise group comparison in browser workflow
CedricHermansBIT Oct 6, 2026
5366686
Harden official validation browser toggle
CedricHermansBIT Oct 6, 2026
0aeac9d
Fix group comparison UI syntax
CedricHermansBIT Oct 7, 2026
e69ba95
Add coverage and evidence profiles to group conformational shifts
CedricHermansBIT Oct 7, 2026
3d3462d
Test group shift support and coverage profiles
CedricHermansBIT Oct 7, 2026
d84825b
Explain group shifts with coverage and within-group support
CedricHermansBIT Oct 7, 2026
5a529f3
Distinguish high-support and sparse group shifts visually
CedricHermansBIT Oct 7, 2026
0da582e
Document evidence profiles for structure-group comparison
CedricHermansBIT Oct 7, 2026
a1a3736
Replace escaped newline in group comparison UI
CedricHermansBIT Oct 7, 2026
6f32906
Explain selected group conformational shifts in context
CedricHermansBIT Oct 7, 2026
b9196a1
Style selected group evidence card
CedricHermansBIT Oct 7, 2026
8b63763
Test group evidence explanation in browser
CedricHermansBIT Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/scientific-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
Rscript tests/geometry.R
Rscript tests/experimental.R
Rscript tests/ensemble.R
Rscript tests/group-comparison.R
Rscript tests/batch.R
shell: bash
- name: Verify AlphaFold and ESMFold confidence formats
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ RamplotR is an open-source R Shiny application that brings backbone geometry, a
| **Work with predicted models** | Import AlphaFold DB models or upload AlphaFold 2/3, ColabFold and ESMFold structures. Examine pLDDT/PAE, analyse multiple AF2/ColabFold or ESMFold seeds as a prediction ensemble, and search experimental PDB counterparts for direct residue-level comparison. |
| **Examine structural geometry** | Explore peptide ω, side-chain χ1 and descriptive Cβ measurements. Optionally attach the matching deposited structure's official wwPDB validation report for independent rotamer, clash and geometry annotations. |
| **Inspect experimental evidence** | Overlay a local CCP4/MRC cryo-EM map in the 3D viewer as a qualitative aid, without uploading the map to a separate service. |
| **Compare models** | Sequence-align chains from two structures; use the Conformational Change Explorer to navigate residue-level wrapped φ/ψ displacement, then inspect paired residues in linked 2D/3D views alongside native and Rama8000 category changes. |
| **Compare models** | Sequence-align two structures with the Conformational Change Explorer, or compare biologically defined structure sets (for example apo/holo or WT/mutant) using residue-level circular φ/ψ means, dispersion and between-group backbone shifts. |
| **Publish or automate** | Export SVG and high-resolution PNG figures, filtered CSV tables and standalone HTML reports. Run the offline R command-line tool on individual files or a directory of structures. |

Advanced analysis stays in collapsible panels or dedicated comparison/summary views, keeping the everyday 2D/3D inspection screen uncluttered.
Expand Down Expand Up @@ -106,6 +106,7 @@ The default RamplotR teal contour palette provides consistent, recognisable publ
- [Geometry, official wwPDB evidence, cryo-EM overlays, ensembles and batch mode](docs/structural-verification.md)
- [Rama8000 standard validation and direct wwPDB comparison](docs/rama8000-validation.md)
- [Prediction ensemble analysis](docs/prediction-ensembles.md)
- [Structure-group conformational comparison](docs/group-conformation-comparison.md)
- [Experimental counterpart discovery for predicted models](docs/experimental-counterparts.md)
- [Independent wwPDB angle-validation protocol](docs/wwpdb-validation.md) · [Results](docs/validation-results.md)
- [Performance and large-structure benchmarks](docs/benchmark-results.md) · [Scaling results](docs/scaling-results.md)
Expand Down
133 changes: 133 additions & 0 deletions docs/group-conformation-comparison.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Structure-group conformational comparison

RamplotR can compare two **sets** of related protein structures at residue level.
Typical examples include apo versus ligand-bound structures, wild type versus
mutants, or experimental versus predicted model sets.

The analysis is deliberately conformation-centred. It compares circular
backbone phi/psi distributions after sequence alignment instead of reducing
each structure pair to a single Cartesian RMSD.

## Workflow

1. Load a representative structure in RamplotR.
2. Open **Compare** and expand **Compare groups of structures**.
3. Select the protein chain that defines the reference residue coordinate
system.
4. Define Group A and Group B labels.
5. Optionally include the currently loaded structure in Group A.
6. Upload one or more PDB/mmCIF files for each group.
7. Run **Analyse groups**.

Each uploaded file contributes model 1. RamplotR chooses the best matching
protein chain independently for every file using sequence identity and
reference-chain coverage. The default acceptance thresholds are 70% identity
and 70% reference coverage; both can be changed before analysis.

## Residue-level calculations

Every accepted candidate chain is globally sequence-aligned to the selected
reference chain. Candidate phi/psi values are then represented on the reference
residue coordinate system. Insertions without a reference residue are not
invented as comparable positions.

For each residue and each group RamplotR calculates:

- number of models contributing phi and psi;
- circular mean phi and psi;
- circular standard deviation of phi and psi;
- modal Rama8000 category and its within-group consistency.

The between-group effect is reported as wrapped differences between the two
circular means:

```
delta_phi = wrap(phi_mean_B - phi_mean_A)
delta_psi = wrap(psi_mean_B - psi_mean_A)
backbone_shift = sqrt(delta_phi^2 + delta_psi^2)
```

Wrapping is performed independently across the -180/180-degree boundary.

The combined backbone shift is a **navigation effect size**, not a statistical
significance score and not a Cartesian distance.

## Evidence profile and support markers

RamplotR keeps the between-group effect and the evidence supporting it
separate. Every residue reports:

- the combined wrapped backbone shift;
- the maximum within-group circular SD across phi and psi;
- the fraction of structures in each group contributing both phi and psi;
- the minimum within-group Rama8000 modal-category consistency;
- an evidence profile.

The evidence profile is deliberately descriptive:

- **Small shift**: <15 degrees;
- **Moderate shift**: 15--30 degrees;
- **Large but variable**: >=30 degrees without low within-group dispersion;
- **Low-dispersion shift**: >=30 degrees with maximum within-group SD <=15 degrees;
- **Sparse coverage**: fewer than 75% of structures in either group contribute
both angles;
- **Unavailable**: no comparable finite group means.

A residue receives the stronger **high-support shift** marker only when it is
a low-dispersion shift, both groups have at least 75% residue coverage, and
the Rama8000 modal category is at least 75% consistent within each group when
that information is available.

These thresholds are transparent navigation criteria, not a hypothesis test,
p-value or claim of biological significance. The exact group means, angular
differences, coverage and within-group dispersion remain visible in the table.

A separate marker indicates when the modal Rama8000 category differs between
the two groups.

## Interpretation

Group-level conformational differences can reflect genuine structural states,
but can also arise from:

- ligands or cofactors;
- construct boundaries and engineered mutations;
- crystal packing;
- cryo-EM classification;
- differences in experimental resolution;
- prediction uncertainty;
- different domain arrangements or oligomeric states.

Sequence alignment alone therefore does not prove that two groups are
biologically exchangeable. The exported member table records the automatically
selected chain, sequence identity and coverage for every input structure so
these assumptions can be audited.

For prediction ensembles, use RamplotR's dedicated prediction-ensemble
workflow when the main question is seed/model uncertainty. Group comparison is
more appropriate when the researcher has already defined biologically
meaningful sets such as apo/holo or WT/mutant.

## Exports

The group comparison can export:

- one residue-level CSV containing circular means, SDs, wrapped differences,
combined displacement and Rama8000 mode changes;
- one member CSV recording each structure label, selected chain, identity,
reference coverage, candidate coverage and aligned residue count.

## Testing

Unit tests cover:

- automatic best-chain selection;
- rejection of unrelated chains at explicit thresholds;
- circular means across +179/-179 degrees;
- wrapped between-group differences;
- low-dispersion and high-support shift detection;
- sparse-coverage downgrading;
- Rama8000 modal-category changes.

The browser test additionally compares 1CRN against an identical uploaded
1CRN group and requires every residue to remain in the smallest shift band.
218 changes: 218 additions & 0 deletions shinyRam/R/group-comparison.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# Group-level conformational comparison.
#
# Structures are sequence-aligned to a single reference chain. Per-group
# circular phi/psi summaries reuse the same statistics as ensemble analysis.
# Between-group shifts are descriptive effect sizes for navigation; no
# significance test is implied.

ram_alignment_quality <- function(reference, candidate) {
pairing <- ram_align_residues(reference, candidate)
both <- !is.na(pairing$index_a) & !is.na(pairing$index_b)
aligned <- sum(both)
if (!aligned) return(list(
identity=0, reference_coverage=0, candidate_coverage=0,
aligned=0L, score=0
))
ra <- toupper(as.character(reference$resn[pairing$index_a[both]]))
rb <- toupper(as.character(candidate$resn[pairing$index_b[both]]))
identity <- mean(ra == rb)
ref_cov <- aligned / max(1L,nrow(reference))
cand_cov <- aligned / max(1L,nrow(candidate))
list(
identity=identity,
reference_coverage=ref_cov,
candidate_coverage=cand_cov,
aligned=as.integer(aligned),
score=identity * sqrt(ref_cov*cand_cov)
)
}

ram_best_chain_match <- function(reference, candidate,
min_identity=0.30,
min_reference_coverage=0.50) {
if (!nrow(reference) || !nrow(candidate))
stop("Reference and candidate structures need protein residues.")
chain_values <- unique(as.character(candidate$chain))
if (!length(chain_values)) stop("Candidate structure has no protein chain.")
scored <- lapply(chain_values,function(chain) {
table <- candidate[as.character(candidate$chain)==chain,,drop=FALSE]
quality <- ram_alignment_quality(reference,table)
data.frame(
chain=chain,
identity=quality$identity,
reference_coverage=quality$reference_coverage,
candidate_coverage=quality$candidate_coverage,
aligned=quality$aligned,
score=quality$score,
stringsAsFactors=FALSE
)
})
scores <- do.call(rbind,scored)
order_index <- order(-scores$score,-scores$identity,
-scores$reference_coverage,scores$chain)
scores <- scores[order_index,,drop=FALSE]
best <- scores[1L,,drop=FALSE]
if (!is.finite(best$identity) || best$identity < min_identity ||
!is.finite(best$reference_coverage) ||
best$reference_coverage < min_reference_coverage) {
stop(sprintf(
"No candidate chain met the minimum alignment criteria (identity %.0f%%, reference coverage %.0f%%). Best chain %s: %.1f%% identity, %.1f%% reference coverage.",
100*min_identity,100*min_reference_coverage,best$chain,
100*best$identity,100*best$reference_coverage
))
}
list(chain=best$chain, metrics=best, all=scores)
}

ram_map_chain_to_reference <- function(reference, candidate,
source_label,
source_chain=unique(candidate$chain)[[1L]]) {
pairing <- ram_align_residues(reference,candidate)
both <- !is.na(pairing$index_a) & !is.na(pairing$index_b)
pairing <- pairing[both,,drop=FALSE]
if (!nrow(pairing)) return(reference[0,,drop=FALSE])
ref_idx <- pairing$index_a
cand_idx <- pairing$index_b
required <- c("chain","resi","insertion_code","resn","phi","psi","region")
if (!all(required %in% names(reference)) ||
!all(required %in% names(candidate)))
stop("Mapped structures require residue identifiers, phi/psi and regions.")
out <- reference[ref_idx,c("chain","resi","insertion_code","resn"),
drop=FALSE]
out$phi <- candidate$phi[cand_idx]
out$psi <- candidate$psi[cand_idx]
out$region <- candidate$region[cand_idx]
optional <- c("rama8000_region","rama8000_group","rama8000_score","plddt")
for(field in optional)
if(field %in% names(candidate)) out[[field]] <- candidate[[field]][cand_idx]
out$source_label <- as.character(source_label)
out$source_chain <- as.character(source_chain)
out
}

ram_prepare_structure_group <- function(reference, structures, labels=NULL,
min_identity=0.30,
min_reference_coverage=0.50) {
if (!is.list(structures) || !length(structures))
stop("Each comparison group needs at least one structure.")
if (is.null(labels)) labels <- paste("Structure",seq_along(structures))
labels <- as.character(labels)
if (length(labels)!=length(structures) || any(!nzchar(labels)))
stop("Every group structure needs a non-empty label.")
mapped <- vector("list",length(structures))
model_info <- vector("list",length(structures))
for(i in seq_along(structures)) {
table <- structures[[i]]
best <- ram_best_chain_match(
reference,table,min_identity,min_reference_coverage)
chain <- table[as.character(table$chain)==best$chain,,drop=FALSE]
mapped[[i]] <- ram_map_chain_to_reference(
reference,chain,labels[[i]],best$chain)
model_info[[i]] <- data.frame(
model=labels[[i]],
chain=best$chain,
identity=best$metrics$identity,
reference_coverage=best$metrics$reference_coverage,
candidate_coverage=best$metrics$candidate_coverage,
aligned=best$metrics$aligned,
stringsAsFactors=FALSE
)
}
list(models=mapped, model_summary=do.call(rbind,model_info))
}

ram_group_conformation_compare <- function(reference, group_a, group_b,
label_a="Group A",
label_b="Group B") {
if (!is.list(group_a) || !length(group_a) ||
!is.list(group_b) || !length(group_b))
stop("Both groups need at least one mapped structure.")
a <- ram_ensemble_summary(group_a)
b <- ram_ensemble_summary(group_b)
key <- function(data)
paste(data$chain,data$resi,data$insertion_code,toupper(data$resn),sep="\r")
ka <- key(a); kb <- key(b)
all_keys <- unique(c(ka,kb))
ia <- match(all_keys,ka); ib <- match(all_keys,kb)
template <- rbind(
a[!duplicated(ka),c("chain","resi","insertion_code","resn"),drop=FALSE],
b[!duplicated(kb),c("chain","resi","insertion_code","resn"),drop=FALSE]
)
kt <- key(template)
ids <- template[match(all_keys,kt),,drop=FALSE]
pick <- function(data,index,field,missing=NA_real_) {
out <- rep(missing,length(index))
good <- !is.na(index)
if(any(good) && field %in% names(data)) out[good] <- data[[field]][index[good]]
out
}
out <- ids
numeric_fields <- c("phi_models","psi_models","phi_mean","phi_sd",
"psi_mean","psi_sd","rama8000_models",
"rama8000_consistency")
char_fields <- c("rama8000_mode")
for(field in numeric_fields) {
out[[paste0("a_",field)]] <- pick(a,ia,field)
out[[paste0("b_",field)]] <- pick(b,ib,field)
}
for(field in char_fields) {
out[[paste0("a_",field)]] <- pick(a,ia,field,NA_character_)
out[[paste0("b_",field)]] <- pick(b,ib,field,NA_character_)
}
out$delta_phi <- ram_angular_difference(out$a_phi_mean,out$b_phi_mean)
out$delta_psi <- ram_angular_difference(out$a_psi_mean,out$b_psi_mean)
out$angular_displacement <- ram_backbone_angular_displacement(
out$delta_phi,out$delta_psi)
out$shift_band <- ram_backbone_shift_band(out$angular_displacement)
max_finite <- function(...) {
values <- cbind(...)
apply(values,1L,function(row) {
row <- row[is.finite(row)]
if(!length(row)) NA_real_ else max(row)
})
}
out$max_within_group_sd <- max_finite(
out$a_phi_sd,out$a_psi_sd,out$b_phi_sd,out$b_psi_sd)
n_a <- length(group_a)
n_b <- length(group_b)
out$a_coverage <- pmin(out$a_phi_models,out$a_psi_models) / max(1L,n_a)
out$b_coverage <- pmin(out$b_phi_models,out$b_psi_models) / max(1L,n_b)
out$min_group_coverage <- pmin(out$a_coverage,out$b_coverage)
out$min_rama8000_consistency <- pmin(
out$a_rama8000_consistency,out$b_rama8000_consistency,na.rm=TRUE)
out$min_rama8000_consistency[
!is.finite(out$a_rama8000_consistency) |
!is.finite(out$b_rama8000_consistency)] <- NA_real_

enough <- out$a_phi_models>=2 & out$a_psi_models>=2 &
out$b_phi_models>=2 & out$b_psi_models>=2
out$consistent_shift <- enough &
is.finite(out$angular_displacement) &
out$angular_displacement>=30 &
is.finite(out$max_within_group_sd) &
out$max_within_group_sd<=15
out$high_support_shift <- out$consistent_shift &
is.finite(out$min_group_coverage) & out$min_group_coverage>=0.75 &
(is.na(out$min_rama8000_consistency) |
out$min_rama8000_consistency>=0.75)
out$rama8000_mode_changed <- !is.na(out$a_rama8000_mode) &
!is.na(out$b_rama8000_mode) &
out$a_rama8000_mode != out$b_rama8000_mode

out$evidence_profile <- ifelse(
!is.finite(out$angular_displacement),"Unavailable",
ifelse(out$min_group_coverage<0.75,"Sparse coverage",
ifelse(out$angular_displacement>=30 &
is.finite(out$max_within_group_sd) &
out$max_within_group_sd<=15,
"Low-dispersion shift",
ifelse(out$angular_displacement>=30,"Large but variable",
ifelse(out$angular_displacement>=15,"Moderate shift","Small shift")))))
out$group_a <- label_a
out$group_b <- label_b
out[order(-as.integer(out$high_support_shift),
-as.integer(out$consistent_shift),
-replace(out$angular_displacement,
!is.finite(out$angular_displacement),-Inf),
out$chain,out$resi,out$insertion_code),,drop=FALSE]
}
Loading