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} {:<11}",
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            // CRYST1 names P 1 when the entry records no space group.
196            match data.entry.space_group.as_deref() {
197                Some(symbol) => symbol,
198                None => "P 1",
199            },
200        );
201    }
202}
203
204fn write_atom(
205    out: &mut impl fmt::Write,
206    structure: &Structure,
207    atom: &AtomRef<'_>,
208    residue: &ResidueRef<'_>,
209    chain: &str,
210    serial: i64,
211    options: &PdbOptions,
212) -> Result<(), Diagnostic> {
213    let record = if residue.is_het() { "HETATM" } else { "ATOM  " };
214    let name = required_text(
215        atom_name(*atom, options.namespace),
216        "atom identifier",
217        *atom,
218        options,
219    )?;
220    let element = match atom.element() {
221        Some(element) => element.symbol(),
222        None => "",
223    };
224    // An atom whose position was never recorded still has a row here, but the
225    // format has no way to say "unrecorded" — so it is left out of the file
226    // rather than written as an origin the experiment never observed.
227    let position = required_value(atom.position(), "coordinates", *atom, options)?;
228    let seq = i64::from(required_value(
229        residue_sequence(*residue, options.namespace),
230        "residue sequence identifier",
231        *atom,
232        options,
233    )?);
234    let component = required_text(
235        component_name(*atom, *residue, options.namespace),
236        "component identifier",
237        *atom,
238        options,
239    )?;
240    let occupancy = required_value(atom.occupancy(), "occupancy", *atom, options)?;
241    let b_factor = required_value(atom.b_factor(), "B factor", *atom, options)?;
242
243    let _ = writeln!(
244        out,
245        "{record}{serial:>5} {name:<4}{alt:1}{comp:>3} {chain:>1}{seq}{ins:1}   \
246         {x:8.3}{y:8.3}{z:8.3}{occ:6.2}{b:6.2}          {element:>2}",
247        serial = serial_field(serial, options),
248        name = atom_name_field(name),
249        alt = alt_field(structure, atom),
250        comp = component,
251        seq = residue_field(seq, options),
252        ins = insertion_code(*residue),
253        x = f64::from(position[0]),
254        y = f64::from(position[1]),
255        z = f64::from(position[2]),
256        occ = f64::from(occupancy),
257        b = f64::from(b_factor),
258    );
259    // A tensor recorded for this atom follows under the same serial and the
260    // same identity columns, as the record pairs by serial.
261    if let Some(u) = structure.data().anisotropy.for_atom(atom.index()) {
262        super::aniso::Record {
263            atom,
264            structure,
265            residue,
266            name,
267            component,
268            chain,
269            seq: &residue_field(seq, options),
270            serial: &serial_field(serial, options),
271            u: &u,
272        }
273        .write(out);
274    }
275    Ok(())
276}
277
278/// The four-column name field.
279///
280/// A name shorter than four characters starts in the second column, which is the
281/// convention that lets a reader tell a one-letter element from a two-letter one.
282pub(crate) fn atom_name_field(name: &str) -> String {
283    if name.len() >= 4 {
284        name.to_owned()
285    } else {
286        format!(" {name:<3}")
287    }
288}
289
290/// The one-column alternate-location field.
291///
292/// A label longer than one character has already been refused by the capacity
293/// check, so anything reaching here fits.
294pub(crate) fn alt_field<'a>(structure: &'a Structure, atom: &AtomRef<'a>) -> &'a str {
295    let Some(alt) = atom.alt_id() else { return " " };
296    let Some(symbol) = alt.symbol() else {
297        return " ";
298    };
299    match structure.resolve(symbol) {
300        Some(label) => label,
301        None => " ",
302    }
303}
304
305pub(crate) fn serial_field(serial: i64, options: &PdbOptions) -> String {
306    match options
307        .hybrid36
308        .then(|| hybrid36::encode(serial, 5))
309        .flatten()
310    {
311        Some(field) => field,
312        None => format!("{serial:>5}"),
313    }
314}
315
316pub(crate) fn residue_field(seq: i64, options: &PdbOptions) -> String {
317    match options.hybrid36.then(|| hybrid36::encode(seq, 4)).flatten() {
318        Some(field) => field,
319        None => format!("{seq:>4}"),
320    }
321}
322
323pub(crate) fn chain_label<'a>(
324    chain: &'a ChainRef<'a>,
325    namespace: PdbIdentifierNamespace,
326) -> &'a str {
327    let Some(name) = chain_name(*chain, namespace) else {
328        return "";
329    };
330    name
331}
332
333pub(crate) fn insertion_code(residue: ResidueRef<'_>) -> &str {
334    let Some(code) = residue.ins_code() else {
335        return " ";
336    };
337    code
338}
339
340#[derive(Clone, Copy)]
341pub(crate) enum RequiredAtomFields {
342    Pdb,
343    Pqr,
344    Pdbqt,
345}
346
347/// Everything about the structure that the format cannot hold.
348pub(crate) fn check_capacity(
349    structure: &Structure,
350    options: &PdbOptions,
351    select: &impl Select,
352    required: RequiredAtomFields,
353) -> Vec<Diagnostic> {
354    let data = structure.data();
355    let mut refusals = Vec::new();
356
357    for chain in data.chains() {
358        if !select.accept_chain(chain.index()) {
359            continue;
360        }
361        let original = chain_label(&chain, options.namespace);
362        let written = options.label_for(original);
363        if written.chars().count() > 1 {
364            refusals.push(
365                Diagnostic::new(Code::E4102)
366                    .with_context("chain", original.to_string())
367                    .with_context("written as", written.to_string()),
368            );
369        }
370        for residue in chain.residues() {
371            if !select.accept_residue(residue.index()) {
372                continue;
373            }
374            check_residue(&residue, options, select, required, &mut refusals);
375        }
376    }
377
378    let atoms = i64::from(structure.atom_count());
379    if !options.hybrid36 && hybrid36::needs_hybrid36(atoms, 5) {
380        refusals.push(
381            Diagnostic::new(Code::E4101)
382                .with_context("atoms", atoms.to_string())
383                .with_context("field holds", "99999"),
384        );
385    }
386    refusals
387}
388
389fn check_residue(
390    residue: &ResidueRef<'_>,
391    options: &PdbOptions,
392    select: &impl Select,
393    required: RequiredAtomFields,
394    refusals: &mut Vec<Diagnostic>,
395) {
396    if let Some(seq) = residue_sequence(*residue, options.namespace)
397        && !options.hybrid36
398        && hybrid36::needs_hybrid36(i64::from(seq), 4)
399    {
400        refusals.push(
401            Diagnostic::new(Code::E4103)
402                .with_context("residue", seq.to_string())
403                .with_context("field holds", "9999"),
404        );
405    }
406    for atom in residue.atoms() {
407        if !select.accept_atom(atom.index()) {
408            continue;
409        }
410        check_required_atom_fields(atom, *residue, options, required, refusals);
411        let Some(position) = atom.position() else {
412            continue;
413        };
414        if position.iter().any(|value| {
415            let value = f64::from(*value);
416            !value.is_finite() || !(MIN_COORDINATE..MAX_COORDINATE).contains(&value)
417        }) {
418            refusals.push(
419                Diagnostic::new(Code::E4104)
420                    .with_context("atom", atom.index().to_string())
421                    .with_context("position", format!("{position:?}")),
422            );
423        }
424    }
425}
426
427fn check_required_atom_fields(
428    atom: AtomRef<'_>,
429    residue: ResidueRef<'_>,
430    options: &PdbOptions,
431    required: RequiredAtomFields,
432    refusals: &mut Vec<Diagnostic>,
433) {
434    let common = [
435        (atom.position().is_some(), "coordinates"),
436        (
437            atom_name(atom, options.namespace).is_some(),
438            "atom identifier",
439        ),
440        (
441            component_name(atom, residue, options.namespace).is_some(),
442            "component identifier",
443        ),
444        (
445            residue_sequence(residue, options.namespace).is_some(),
446            "residue sequence identifier",
447        ),
448    ];
449    for (present, field) in common {
450        if !present {
451            refusals.push(missing_field(field, atom, options));
452        }
453    }
454    if matches!(
455        required,
456        RequiredAtomFields::Pdb | RequiredAtomFields::Pdbqt
457    ) {
458        for (present, field) in [
459            (atom.occupancy().is_some(), "occupancy"),
460            (atom.b_factor().is_some(), "B factor"),
461        ] {
462            if !present {
463                refusals.push(missing_field(field, atom, options));
464            }
465        }
466    }
467}
468
469fn required_text<'a>(
470    value: Option<&'a str>,
471    field: &'static str,
472    atom: AtomRef<'_>,
473    options: &PdbOptions,
474) -> Result<&'a str, Diagnostic> {
475    value.ok_or_else(|| missing_field(field, atom, options))
476}
477
478fn required_value<T>(
479    value: Option<T>,
480    field: &'static str,
481    atom: AtomRef<'_>,
482    options: &PdbOptions,
483) -> Result<T, Diagnostic> {
484    value.ok_or_else(|| missing_field(field, atom, options))
485}
486
487fn missing_field(field: &'static str, atom: AtomRef<'_>, options: &PdbOptions) -> Diagnostic {
488    Diagnostic::new(Code::E4105)
489        .with_context("required field", field)
490        .with_context("atom", atom.index().to_string())
491        .with_context("namespace", options.namespace.as_str())
492}
493
494#[cfg(test)]
495#[path = "pdb_tests.rs"]
496mod tests;