Skip to main content

molframe_pdb/writer/
pdb.rs

1//! Writing the legacy fixed-column format, or refusing to.
2//!
3//! The format's fields have fixed widths, and a structure can outgrow every one
4//! of them: a chain label of two characters, a serial past ninety-nine thousand,
5//! a residue number past ten thousand, a coordinate too large for eight columns.
6//!
7//! Every one of those is checked before a single byte is written, and a
8//! structure that does not fit is refused with a diagnostic naming the field and
9//! offering a way out. Truncating a chain label from `AA` to `A` produces a file
10//! that opens cleanly in any viewer and describes a different molecule, which is
11//! the failure this library exists to prevent.
12
13use crate::hybrid36;
14use crate::{PDB_HEADERS_EXTENSION, PdbHeaders};
15use molframe_core::diagnostic::{Code, Diagnostic};
16use molframe_core::io::{Select, SelectAll};
17use molframe_core::structure::{AtomRef, ChainRef, ResidueRef, Structure};
18use std::collections::HashMap;
19use std::fmt;
20
21#[path = "identity.rs"]
22mod identity;
23pub use identity::PdbIdentifierNamespace;
24pub(crate) use identity::{atom_name, chain_name, component_name, residue_sequence};
25
26/// Bounds whose rounding would overflow an eight-column, three-decimal field.
27/// The negative side loses one digit to the sign, so the limits are asymmetric.
28const MIN_COORDINATE: f64 = -999.999_5;
29const MAX_COORDINATE: f64 = 9_999.999_5;
30
31/// How to write, and what to do about a structure that does not fit.
32#[derive(Clone, Debug)]
33pub struct PdbOptions {
34    /// Rename chains on the way out, so a structure with labels the format
35    /// cannot hold can still be written deliberately.
36    chain_map: HashMap<Box<str>, Box<str>>,
37    /// Write serials and residue numbers past the decimal range in hybrid-36.
38    ///
39    /// Off by default: a consumer that does not implement the scheme would read
40    /// a wrong number without noticing, so it is offered rather than assumed.
41    hybrid36: bool,
42    /// Identifier namespace used consistently for every deposited identity.
43    namespace: PdbIdentifierNamespace,
44}
45
46impl Default for PdbOptions {
47    fn default() -> Self {
48        Self {
49            chain_map: HashMap::new(),
50            hybrid36: false,
51            namespace: PdbIdentifierNamespace::Label,
52        }
53    }
54}
55
56impl PdbOptions {
57    /// Options that refuse anything the format cannot hold.
58    #[must_use]
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Renames one chain on the way out.
64    #[must_use]
65    pub fn chain_map(mut self, from: impl Into<Box<str>>, to: impl Into<Box<str>>) -> Self {
66        self.chain_map.insert(from.into(), to.into());
67        self
68    }
69
70    /// Writes counts past the decimal range in hybrid-36 rather than refusing.
71    #[must_use]
72    pub const fn hybrid36(mut self, enabled: bool) -> Self {
73        self.hybrid36 = enabled;
74        self
75    }
76
77    /// Selects one identifier namespace for chains, residues and atoms.
78    #[must_use]
79    pub const fn namespace(mut self, namespace: PdbIdentifierNamespace) -> Self {
80        self.namespace = namespace;
81        self
82    }
83
84    pub(crate) const fn identifier_namespace(&self) -> PdbIdentifierNamespace {
85        self.namespace
86    }
87
88    /// The label a chain is written under.
89    pub(crate) fn label_for<'a>(&'a self, label: &'a str) -> &'a str {
90        match self.chain_map.get(label) {
91            Some(replacement) => replacement,
92            None => label,
93        }
94    }
95}
96
97/// Writes a structure, or explains why it cannot be written.
98///
99/// # Errors
100///
101/// Returns every capacity the structure exceeds, so a caller sees the whole
102/// problem rather than fixing one field at a time.
103pub fn write(structure: &Structure, options: &PdbOptions) -> Result<String, Vec<Diagnostic>> {
104    write_selected(structure, options, &SelectAll)
105}
106
107/// Writes the parts of a structure a filter accepts.
108///
109/// # Errors
110///
111/// Returns every capacity the accepted parts exceed.
112pub fn write_selected(
113    structure: &Structure,
114    options: &PdbOptions,
115    select: &impl Select,
116) -> Result<String, Vec<Diagnostic>> {
117    let mut out = String::with_capacity(structure.atom_count() as usize * 81);
118    render_selected(&mut out, structure, options, select)?;
119    Ok(out)
120}
121
122pub(super) fn render_selected(
123    out: &mut impl fmt::Write,
124    structure: &Structure,
125    options: &PdbOptions,
126    select: &impl Select,
127) -> Result<(), Vec<Diagnostic>> {
128    let refusals = check_capacity(structure, options, select, RequiredAtomFields::Pdb);
129    if !refusals.is_empty() {
130        return Err(refusals);
131    }
132
133    let data = structure.data();
134
135    if let Some(headers) = data.extensions.get::<PdbHeaders>(PDB_HEADERS_EXTENSION) {
136        for record in headers.records() {
137            let _ = writeln!(out, "{}", record.line());
138        }
139    } else {
140        write_generated_metadata(out, structure);
141    }
142
143    let mut serial = 1i64;
144    for chain in data.chains() {
145        if !select.accept_chain(chain.index()) {
146            continue;
147        }
148        let label = chain_label(&chain, options.namespace);
149        let label = options.label_for(label);
150
151        for residue in chain.residues() {
152            if !select.accept_residue(residue.index()) {
153                continue;
154            }
155            for atom in residue.atoms() {
156                if !select.accept_atom(atom.index()) {
157                    continue;
158                }
159                if let Err(finding) =
160                    write_atom(out, structure, &atom, &residue, label, serial, options)
161                {
162                    return Err(vec![finding]);
163                }
164                serial += 1;
165            }
166        }
167        let _ = writeln!(out, "TER   {:>5}", serial_field(serial, options));
168        serial += 1;
169    }
170    let _ = out.write_str("END\n");
171    Ok(())
172}
173
174fn write_generated_metadata(out: &mut impl fmt::Write, structure: &Structure) {
175    let data = structure.data();
176    if let Some(id) = &data.entry.id {
177        let _ = writeln!(out, "HEADER    {:<40}{:<9}   {:>4}", "", "", id);
178    }
179    if let Some(title) = &data.entry.title {
180        let _ = writeln!(out, "TITLE     {title}");
181    }
182    if let Some(method) = &data.entry.method {
183        let _ = writeln!(out, "EXPDTA    {method}");
184    }
185    if let Some(cell) = data.cell {
186        let _ = writeln!(
187            out,
188            "CRYST1{:9.3}{:9.3}{:9.3}{:7.2}{:7.2}{:7.2}",
189            cell.lengths[0],
190            cell.lengths[1],
191            cell.lengths[2],
192            cell.angles[0],
193            cell.angles[1],
194            cell.angles[2],
195        );
196    }
197}
198
199fn write_atom(
200    out: &mut impl fmt::Write,
201    structure: &Structure,
202    atom: &AtomRef<'_>,
203    residue: &ResidueRef<'_>,
204    chain: &str,
205    serial: i64,
206    options: &PdbOptions,
207) -> Result<(), Diagnostic> {
208    let record = if residue.is_het() { "HETATM" } else { "ATOM  " };
209    let name = required_text(
210        atom_name(*atom, options.namespace),
211        "atom identifier",
212        *atom,
213        options,
214    )?;
215    let element = match atom.element() {
216        Some(element) => element.symbol(),
217        None => "",
218    };
219    // An atom whose position was never recorded still has a row here, but the
220    // format has no way to say "unrecorded" — so it is left out of the file
221    // rather than written as an origin the experiment never observed.
222    let position = required_value(atom.position(), "coordinates", *atom, options)?;
223    let seq = i64::from(required_value(
224        residue_sequence(*residue, options.namespace),
225        "residue sequence identifier",
226        *atom,
227        options,
228    )?);
229    let component = required_text(
230        component_name(*atom, *residue, options.namespace),
231        "component identifier",
232        *atom,
233        options,
234    )?;
235    let occupancy = required_value(atom.occupancy(), "occupancy", *atom, options)?;
236    let b_factor = required_value(atom.b_factor(), "B factor", *atom, options)?;
237
238    let _ = writeln!(
239        out,
240        "{record}{serial:>5} {name:<4}{alt:1}{comp:>3} {chain:>1}{seq}{ins:1}   \
241         {x:8.3}{y:8.3}{z:8.3}{occ:6.2}{b:6.2}          {element:>2}",
242        serial = serial_field(serial, options),
243        name = atom_name_field(name),
244        alt = alt_field(structure, atom),
245        comp = component,
246        seq = residue_field(seq, options),
247        ins = insertion_code(*residue),
248        x = f64::from(position[0]),
249        y = f64::from(position[1]),
250        z = f64::from(position[2]),
251        occ = f64::from(occupancy),
252        b = f64::from(b_factor),
253    );
254    Ok(())
255}
256
257/// The four-column name field.
258///
259/// A name shorter than four characters starts in the second column, which is the
260/// convention that lets a reader tell a one-letter element from a two-letter one.
261pub(crate) fn atom_name_field(name: &str) -> String {
262    if name.len() >= 4 {
263        name.to_owned()
264    } else {
265        format!(" {name:<3}")
266    }
267}
268
269/// The one-column alternate-location field.
270///
271/// A label longer than one character has already been refused by the capacity
272/// check, so anything reaching here fits.
273pub(crate) fn alt_field<'a>(structure: &'a Structure, atom: &AtomRef<'a>) -> &'a str {
274    let Some(alt) = atom.alt_id() else { return " " };
275    let Some(symbol) = alt.symbol() else {
276        return " ";
277    };
278    match structure.resolve(symbol) {
279        Some(label) => label,
280        None => " ",
281    }
282}
283
284pub(crate) fn serial_field(serial: i64, options: &PdbOptions) -> String {
285    match options
286        .hybrid36
287        .then(|| hybrid36::encode(serial, 5))
288        .flatten()
289    {
290        Some(field) => field,
291        None => format!("{serial:>5}"),
292    }
293}
294
295pub(crate) fn residue_field(seq: i64, options: &PdbOptions) -> String {
296    match options.hybrid36.then(|| hybrid36::encode(seq, 4)).flatten() {
297        Some(field) => field,
298        None => format!("{seq:>4}"),
299    }
300}
301
302pub(crate) fn chain_label<'a>(
303    chain: &'a ChainRef<'a>,
304    namespace: PdbIdentifierNamespace,
305) -> &'a str {
306    let Some(name) = chain_name(*chain, namespace) else {
307        return "";
308    };
309    name
310}
311
312pub(crate) fn insertion_code(residue: ResidueRef<'_>) -> &str {
313    let Some(code) = residue.ins_code() else {
314        return " ";
315    };
316    code
317}
318
319#[derive(Clone, Copy)]
320pub(crate) enum RequiredAtomFields {
321    Pdb,
322    Pqr,
323    Pdbqt,
324}
325
326/// Everything about the structure that the format cannot hold.
327pub(crate) fn check_capacity(
328    structure: &Structure,
329    options: &PdbOptions,
330    select: &impl Select,
331    required: RequiredAtomFields,
332) -> Vec<Diagnostic> {
333    let data = structure.data();
334    let mut refusals = Vec::new();
335
336    for chain in data.chains() {
337        if !select.accept_chain(chain.index()) {
338            continue;
339        }
340        let original = chain_label(&chain, options.namespace);
341        let written = options.label_for(original);
342        if written.chars().count() > 1 {
343            refusals.push(
344                Diagnostic::new(Code::E4102)
345                    .with_context("chain", original.to_string())
346                    .with_context("written as", written.to_string()),
347            );
348        }
349        for residue in chain.residues() {
350            if !select.accept_residue(residue.index()) {
351                continue;
352            }
353            check_residue(&residue, options, select, required, &mut refusals);
354        }
355    }
356
357    let atoms = i64::from(structure.atom_count());
358    if !options.hybrid36 && hybrid36::needs_hybrid36(atoms, 5) {
359        refusals.push(
360            Diagnostic::new(Code::E4101)
361                .with_context("atoms", atoms.to_string())
362                .with_context("field holds", "99999"),
363        );
364    }
365    refusals
366}
367
368fn check_residue(
369    residue: &ResidueRef<'_>,
370    options: &PdbOptions,
371    select: &impl Select,
372    required: RequiredAtomFields,
373    refusals: &mut Vec<Diagnostic>,
374) {
375    if let Some(seq) = residue_sequence(*residue, options.namespace)
376        && !options.hybrid36
377        && hybrid36::needs_hybrid36(i64::from(seq), 4)
378    {
379        refusals.push(
380            Diagnostic::new(Code::E4103)
381                .with_context("residue", seq.to_string())
382                .with_context("field holds", "9999"),
383        );
384    }
385    for atom in residue.atoms() {
386        if !select.accept_atom(atom.index()) {
387            continue;
388        }
389        check_required_atom_fields(atom, *residue, options, required, refusals);
390        let Some(position) = atom.position() else {
391            continue;
392        };
393        if position.iter().any(|value| {
394            let value = f64::from(*value);
395            !value.is_finite() || !(MIN_COORDINATE..MAX_COORDINATE).contains(&value)
396        }) {
397            refusals.push(
398                Diagnostic::new(Code::E4104)
399                    .with_context("atom", atom.index().to_string())
400                    .with_context("position", format!("{position:?}")),
401            );
402        }
403    }
404}
405
406fn check_required_atom_fields(
407    atom: AtomRef<'_>,
408    residue: ResidueRef<'_>,
409    options: &PdbOptions,
410    required: RequiredAtomFields,
411    refusals: &mut Vec<Diagnostic>,
412) {
413    let common = [
414        (atom.position().is_some(), "coordinates"),
415        (
416            atom_name(atom, options.namespace).is_some(),
417            "atom identifier",
418        ),
419        (
420            component_name(atom, residue, options.namespace).is_some(),
421            "component identifier",
422        ),
423        (
424            residue_sequence(residue, options.namespace).is_some(),
425            "residue sequence identifier",
426        ),
427    ];
428    for (present, field) in common {
429        if !present {
430            refusals.push(missing_field(field, atom, options));
431        }
432    }
433    if matches!(
434        required,
435        RequiredAtomFields::Pdb | RequiredAtomFields::Pdbqt
436    ) {
437        for (present, field) in [
438            (atom.occupancy().is_some(), "occupancy"),
439            (atom.b_factor().is_some(), "B factor"),
440        ] {
441            if !present {
442                refusals.push(missing_field(field, atom, options));
443            }
444        }
445    }
446}
447
448fn required_text<'a>(
449    value: Option<&'a str>,
450    field: &'static str,
451    atom: AtomRef<'_>,
452    options: &PdbOptions,
453) -> Result<&'a str, Diagnostic> {
454    value.ok_or_else(|| missing_field(field, atom, options))
455}
456
457fn required_value<T>(
458    value: Option<T>,
459    field: &'static str,
460    atom: AtomRef<'_>,
461    options: &PdbOptions,
462) -> Result<T, Diagnostic> {
463    value.ok_or_else(|| missing_field(field, atom, options))
464}
465
466fn missing_field(field: &'static str, atom: AtomRef<'_>, options: &PdbOptions) -> Diagnostic {
467    Diagnostic::new(Code::E4105)
468        .with_context("required field", field)
469        .with_context("atom", atom.index().to_string())
470        .with_context("namespace", options.namespace.as_str())
471}
472
473#[cfg(test)]
474#[path = "pdb_tests.rs"]
475mod tests;