molframe-pdb 0.6.1

Legacy PDB reading and writing for molframe
Documentation
//! Selection-aware ranges; O(residues log residues) identity preflight, O(residues) output.

use super::pdb::{PdbOptions, chain_label, insertion_code, residue_field, residue_sequence};
use molframe_core::SecondaryStructure as Ss;
use molframe_core::diagnostic::{Code, Diagnostic};
use molframe_core::io::Select;
use molframe_core::structure::{ChainRef, ResidueRef, Structure};
use std::fmt;

pub(super) fn preflight(
    structure: &Structure,
    options: &PdbOptions,
    select: &impl Select,
) -> Vec<Diagnostic> {
    let mut count = 0;
    let mut refusals = Vec::new();
    ranges(structure, select, |_, first, last, _| {
        count += 1;
        if last.index().get() - first.index().get() + 1 > 99_999 {
            refusals.push(
                Diagnostic::new(Code::E2001).with_context("field", "HELIX length exceeds 99999"),
            );
        }
    });
    if count > 999 {
        refusals.push(
            Diagnostic::new(Code::E2001)
                .with_context("field", "secondary record serial exceeds 999"),
        );
    }
    if let Some(models) = structure.ragged_models()
        && models.iter().any(|model| {
            model
                .secondary_structure()
                .iter()
                .any(|state| state.is_helix() || state.is_strand())
        })
    {
        refusals.push(
            Diagnostic::new(Code::E2001)
                .with_context("field", "secondary records require a single model snapshot"),
        );
    }
    if count == 0 {
        return refusals;
    }
    let namespace = options.identifier_namespace();
    let maximum = if options.uses_hybrid36() {
        crate::hybrid36::decode("zzzz", 4)
    } else {
        Some(9999)
    };
    let mut identities = Vec::with_capacity(structure.data().topology.residues.len());
    for chain in structure
        .data()
        .chains()
        .filter(|chain| select.accept_chain(chain.index()))
    {
        let original = match super::pdb::chain_name(chain, namespace) {
            Some(label) => label,
            None => "",
        };
        let label = options.label_for(original);
        for residue in chain.residues().filter(|residue| {
            select.accept_residue(residue.index())
                && residue.atoms().any(|atom| select.accept_atom(atom.index()))
        }) {
            if let Some(sequence) = residue_sequence(residue, namespace) {
                identities.push((label, sequence, insertion_code(residue)));
            }
            let kind = deposited_state(structure, residue);
            if !kind.is_helix() && !kind.is_strand() {
                continue;
            }
            let name = match namespace {
                super::pdb::PdbIdentifierNamespace::Label => residue.name(),
                super::pdb::PdbIdentifierNamespace::Auth => residue.auth_name(),
            };
            let valid_name =
                name.is_some_and(|name| !name.is_empty() && name.len() <= 3 && name.is_ascii());
            let valid_sequence = residue_sequence(residue, namespace).is_some_and(|seq| {
                maximum.is_some_and(|maximum| (-999..=maximum).contains(&i64::from(seq)))
            });
            let insertion = insertion_code(residue);
            if !valid_name
                || !valid_sequence
                || super::pdb::chain_name(chain, namespace).is_none()
                || label.len() > 1
                || !label.is_ascii()
                || insertion.len() != 1
                || !insertion.is_ascii()
            {
                refusals.push(
                    Diagnostic::new(Code::E2001)
                        .with_context("field", "secondary endpoint identity does not fit PDB"),
                );
            }
        }
    }
    identities.sort_unstable();
    if identities.windows(2).any(|pair| pair[0] == pair[1]) {
        refusals.push(
            Diagnostic::new(Code::E2001)
                .with_context("field", "ambiguous secondary residue identity"),
        );
    }
    refusals
}

pub(super) fn write(
    out: &mut impl fmt::Write,
    structure: &Structure,
    options: &PdbOptions,
    select: &impl Select,
) {
    let mut id = 0;
    ranges(structure, select, |chain, first, last, kind| {
        let namespace = options.identifier_namespace();
        let (Some(begin), Some(end)) = (
            residue_sequence(first, namespace),
            residue_sequence(last, namespace),
        ) else {
            return;
        };
        let (first_name, last_name) = match namespace {
            super::pdb::PdbIdentifierNamespace::Label => (first.name(), last.name()),
            super::pdb::PdbIdentifierNamespace::Auth => (first.auth_name(), last.auth_name()),
        };
        let (Some(first_name), Some(last_name)) = (first_name, last_name) else {
            return;
        };
        let label = options.label_for(chain_label(&chain, namespace));
        let begin = residue_field(i64::from(begin), options);
        let end = residue_field(i64::from(end), options);
        let first_code = insertion_code(first);
        let last_code = insertion_code(last);
        id += 1;
        if kind == Ss::Strand {
            let _ = writeln!(
                out,
                "SHEET  {id:>3} {id:>3} {:>2} {first_name:>3}{label}{begin}{first_code} {last_name:>3} {label}{end}{last_code}{:>2}",
                1, 0
            );
        } else {
            let class = match kind {
                Ss::AlphaHelix => 1,
                Ss::PiHelix => 3,
                Ss::ThreeTenHelix => 5,
                Ss::PolyProline => 10,
                _ => 2,
            };
            let length = last.index().get() - first.index().get() + 1;
            let _ = writeln!(
                out,
                "HELIX  {id:>3} {id:>3} {first_name:>3} {label} {begin}{first_code} {last_name:>3} {label} {end}{last_code}{class:>2}{:31}{length:>5}",
                ""
            );
        }
    });
}

/// The residue's state when the file itself deposited it.
///
/// States a program derived on read (DSSP, the Cα-only fallback) are analysis,
/// not part of the deposited record, so they are not written back as if the
/// file had said them. A structure with no recorded source is taken as written.
fn deposited_state(structure: &Structure, residue: ResidueRef<'_>) -> Ss {
    let index = residue.index().as_usize();
    if let Some(molframe_core::SecondarySource::Dssp | molframe_core::SecondarySource::CaOnly) =
        structure.secondary_source().get(index)
    {
        return Ss::Unknown;
    }
    match structure.secondary_structure().get(index) {
        Some(state) => *state,
        None => Ss::Unknown,
    }
}

fn ranges<'a>(
    structure: &'a Structure,
    select: &impl Select,
    mut visit: impl FnMut(ChainRef<'a>, ResidueRef<'a>, ResidueRef<'a>, Ss),
) {
    for chain in structure.data().chains() {
        if !select.accept_chain(chain.index()) {
            continue;
        }
        let mut pending: Option<(ResidueRef<'_>, ResidueRef<'_>, Ss)> = None;
        for residue in chain.residues() {
            let kind = deposited_state(structure, residue);
            let accepted = select.accept_residue(residue.index())
                && residue.atoms().any(|atom| select.accept_atom(atom.index()))
                && (kind.is_helix() || kind.is_strand());
            if let Some((first, last, old)) = pending {
                if accepted && old == kind {
                    pending = Some((first, residue, kind));
                    continue;
                }
                visit(chain, first, last, old);
            }
            pending = accepted.then_some((residue, residue, kind));
        }
        if let Some((first, last, kind)) = pending {
            visit(chain, first, last, kind);
        }
    }
}

#[cfg(test)]
#[path = "secondary_tests.rs"]
mod tests;