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 super::capacity::{RequiredAtomFields, check_capacity, required_text, required_value};
14use crate::hybrid36;
15use crate::{PDB_HEADERS_EXTENSION, PdbHeaders};
16use molframe_core::diagnostic::Diagnostic;
17use molframe_core::io::{Select, SelectAll};
18use molframe_core::structure::{AtomRef, ChainRef, ResidueRef, Structure};
19use std::collections::HashMap;
20use std::fmt;
21
22mod identity;
23use super::columns::terminus_fields;
24pub(crate) use super::columns::{atom_name_field, element_column};
25pub use identity::PdbIdentifierNamespace;
26pub(crate) use identity::{atom_name, chain_name, component_name, residue_sequence};
27
28/// How to write, and what to do about a structure that does not fit.
29#[derive(Clone, Debug)]
30pub struct PdbOptions {
31    /// Rename chains on the way out, so a structure with labels the format
32    /// cannot hold can still be written deliberately.
33    chain_map: HashMap<Box<str>, Box<str>>,
34    /// Write serials and residue numbers past the decimal range in hybrid-36.
35    ///
36    /// Off by default: a consumer that does not implement the scheme would read
37    /// a wrong number without noticing, so it is offered rather than assumed.
38    hybrid36: bool,
39    /// Identifier namespace used consistently for every deposited identity.
40    namespace: PdbIdentifierNamespace,
41    /// Space group to name on `CRYST1` when the entry records none, for
42    /// structures whose symbol lives in a typed extension rather than the entry.
43    space_group: Option<Box<str>>,
44    /// Whole records written after the generated header, such as the `MTRIX`
45    /// lines of a non-crystallographic operator set the format crate cannot see.
46    extra_records: Option<Box<str>>,
47}
48
49impl Default for PdbOptions {
50    fn default() -> Self {
51        Self {
52            chain_map: HashMap::new(),
53            hybrid36: false,
54            namespace: PdbIdentifierNamespace::Label,
55            space_group: None,
56            extra_records: None,
57        }
58    }
59}
60
61impl PdbOptions {
62    /// Options that refuse anything the format cannot hold.
63    #[must_use]
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Renames one chain on the way out.
69    #[must_use]
70    pub fn chain_map(mut self, from: impl Into<Box<str>>, to: impl Into<Box<str>>) -> Self {
71        self.chain_map.insert(from.into(), to.into());
72        self
73    }
74
75    /// Writes counts past the decimal range in hybrid-36 rather than refusing.
76    #[must_use]
77    pub const fn hybrid36(mut self, enabled: bool) -> Self {
78        self.hybrid36 = enabled;
79        self
80    }
81
82    /// Selects one identifier namespace for chains, residues and atoms.
83    #[must_use]
84    pub const fn namespace(mut self, namespace: PdbIdentifierNamespace) -> Self {
85        self.namespace = namespace;
86        self
87    }
88
89    /// Names the space group `CRYST1` carries when the entry records none.
90    #[must_use]
91    pub fn with_space_group(mut self, symbol: impl Into<Box<str>>) -> Self {
92        self.space_group = Some(symbol.into());
93        self
94    }
95
96    /// Adds whole records after the generated header, one per line.
97    #[must_use]
98    pub fn with_extra_records(mut self, records: impl Into<Box<str>>) -> Self {
99        self.extra_records = Some(records.into());
100        self
101    }
102
103    /// The extra records, when the caller supplied any.
104    #[must_use]
105    pub fn extra_records(&self) -> Option<&str> {
106        self.extra_records.as_deref()
107    }
108
109    pub(crate) const fn identifier_namespace(&self) -> PdbIdentifierNamespace {
110        self.namespace
111    }
112
113    pub(crate) fn space_group_fallback(&self) -> Option<&str> {
114        self.space_group.as_deref()
115    }
116
117    pub(crate) const fn uses_hybrid36(&self) -> bool {
118        self.hybrid36
119    }
120
121    /// The label a chain is written under.
122    pub(crate) fn label_for<'a>(&'a self, label: &'a str) -> &'a str {
123        match self.chain_map.get(label) {
124            Some(replacement) => replacement,
125            None => label,
126        }
127    }
128}
129
130/// Writes a structure, or explains why it cannot be written.
131///
132/// # Errors
133///
134/// Returns every capacity the structure exceeds, so a caller sees the whole
135/// problem rather than fixing one field at a time.
136pub fn write(structure: &Structure, options: &PdbOptions) -> Result<String, Vec<Diagnostic>> {
137    write_selected(structure, options, &SelectAll)
138}
139
140/// Writes the parts of a structure a filter accepts.
141///
142/// # Errors
143///
144/// Returns every capacity the accepted parts exceed.
145pub fn write_selected(
146    structure: &Structure,
147    options: &PdbOptions,
148    select: &impl Select,
149) -> Result<String, Vec<Diagnostic>> {
150    let mut out = String::with_capacity(structure.atom_count() as usize * 81);
151    render_selected(&mut out, structure, options, select)?;
152    Ok(out)
153}
154
155pub(super) fn render_selected(
156    out: &mut impl fmt::Write,
157    structure: &Structure,
158    options: &PdbOptions,
159    select: &impl Select,
160) -> Result<(), Vec<Diagnostic>> {
161    let mut refusals = check_capacity(structure, options, select, RequiredAtomFields::Pdb);
162    refusals.extend(super::secondary::preflight(structure, options, select));
163    if !refusals.is_empty() {
164        return Err(refusals);
165    }
166
167    let data = structure.data();
168
169    let mut has_ssbond = false;
170    let mut has_link = false;
171    if let Some(headers) = data.extensions.get::<PdbHeaders>(PDB_HEADERS_EXTENSION) {
172        has_ssbond = headers
173            .records()
174            .iter()
175            .any(|record| record.name() == "SSBOND");
176        has_link = headers
177            .records()
178            .iter()
179            .any(|record| record.name() == "LINK");
180        for record in headers.records() {
181            if matches!(record.name(), "HELIX" | "SHEET")
182                && structure
183                    .secondary_structure()
184                    .iter()
185                    .any(|state| state.is_helix() || state.is_strand())
186            {
187                continue;
188            }
189            let _ = writeln!(out, "{}", record.line());
190        }
191    } else {
192        super::metadata::write_generated(out, structure, options);
193    }
194    super::secondary::write(out, structure, options, select);
195    super::connections::write_links(out, structure, options, select, has_ssbond, has_link);
196
197    let track = super::connections::has_connect(structure);
198    let mut serials: HashMap<u32, i64> = HashMap::new();
199    let count = structure.model_count();
200    if count <= 1 {
201        write_model(
202            out,
203            structure,
204            None,
205            options,
206            select,
207            track.then_some(&mut serials),
208        )?;
209    } else {
210        let mut written = 0usize;
211        for number in 0..count {
212            let Ok(index) = u32::try_from(number) else {
213                break;
214            };
215            let model = molframe_core::index::ModelIndex::new(index);
216            if !select.accept_model(model) {
217                continue;
218            }
219            let Some((snapshot, local)) = structure.model_snapshot(model) else {
220                continue;
221            };
222            // Indices in a filter are those of the first model. Ragged models
223            // are independent topologies, so applying them to a later model
224            // would select unrelated atoms; refuse rather than guess.
225            if structure.is_ragged_ensemble() && written > 0 {
226                super::models::refuse_foreign_filter(structure, select)?;
227            }
228            written += 1;
229            // Dense models share the topology and differ only in positions;
230            // ragged ones are structures of their own and are checked as such.
231            let frame = if structure.is_ragged_ensemble() {
232                let refusals = check_capacity(&snapshot, options, select, RequiredAtomFields::Pdb);
233                if !refusals.is_empty() {
234                    return Err(refusals);
235                }
236                None
237            } else {
238                let frame = structure.model_positions(local);
239                if let Some(frame) = frame {
240                    super::models::check_frame(&snapshot, frame, number, select)?;
241                }
242                frame
243            };
244            let _ = writeln!(out, "MODEL     {:>4}", number + 1);
245            let tracked = (number == 0 && track).then_some(&mut serials);
246            write_model(out, &snapshot, frame, options, select, tracked)?;
247            let _ = writeln!(out, "ENDMDL");
248        }
249    }
250    super::connections::write_connect(out, structure, options, select, &serials);
251    let _ = out.write_str("END\n");
252    Ok(())
253}
254
255/// One model's atoms with their `TER` records.
256fn write_model(
257    out: &mut impl fmt::Write,
258    structure: &Structure,
259    frame: Option<&[[f32; 3]]>,
260    options: &PdbOptions,
261    select: &impl Select,
262    mut serials: Option<&mut HashMap<u32, i64>>,
263) -> Result<(), Vec<Diagnostic>> {
264    let mut serial = 1i64;
265    for chain in structure.data().chains() {
266        if !select.accept_chain(chain.index()) {
267            continue;
268        }
269        let label = chain_label(&chain, options.namespace);
270        let label = options.label_for(label);
271        let mut polymer = false;
272        let mut terminus: Option<String> = None;
273
274        for residue in chain.residues() {
275            if !select.accept_residue(residue.index()) {
276                continue;
277            }
278            if !residue.is_het() {
279                terminus = terminus_fields(&residue, label, options);
280                polymer = true;
281            }
282            for atom in residue.atoms() {
283                if !select.accept_atom(atom.index()) {
284                    continue;
285                }
286                let position = match frame {
287                    Some(frame) => atom
288                        .position()
289                        .and(frame.get(atom.index().as_usize()).copied()),
290                    None => atom.position(),
291                };
292                let placement = Placement {
293                    chain: label,
294                    serial,
295                    position,
296                };
297                if let Err(finding) =
298                    write_atom(out, structure, &atom, &residue, &placement, options)
299                {
300                    return Err(vec![finding]);
301                }
302                if let Some(serials) = serials.as_deref_mut() {
303                    serials.insert(atom.index().get(), serial);
304                }
305                serial += 1;
306            }
307        }
308        // Only a polymer chain is terminated; waters and ligands follow without.
309        if polymer {
310            let terminus = match terminus.as_deref() {
311                Some(text) => text,
312                None => "",
313            };
314            let _ = writeln!(out, "TER   {}{}", serial_field(serial, options), terminus);
315            serial += 1;
316        }
317    }
318    Ok(())
319}
320
321/// Where one atom row lands: chain column, serial and position.
322struct Placement<'a> {
323    chain: &'a str,
324    serial: i64,
325    position: Option<[f32; 3]>,
326}
327/// Columns 79-80: magnitude then sign, blank for a neutral or unrecorded atom.
328fn charge_field(charge: Option<i8>) -> String {
329    match charge {
330        Some(value) if value > 0 => format!("{value}+"),
331        Some(value) if value < 0 => format!("{}-", value.unsigned_abs()),
332        _ => String::new(),
333    }
334}
335
336fn write_atom(
337    out: &mut impl fmt::Write,
338    structure: &Structure,
339    atom: &AtomRef<'_>,
340    residue: &ResidueRef<'_>,
341    placement: &Placement<'_>,
342    options: &PdbOptions,
343) -> Result<(), Diagnostic> {
344    let Placement {
345        chain,
346        serial,
347        position,
348    } = *placement;
349    let record = if residue.is_het() { "HETATM" } else { "ATOM  " };
350    let name = required_text(
351        atom_name(*atom, options.namespace),
352        "atom identifier",
353        *atom,
354        options,
355    )?;
356    let element = element_column(atom);
357    // An atom whose position was never recorded still has a row here, but the
358    // format has no way to say "unrecorded" — so it is left out of the file
359    // rather than written as an origin the experiment never observed.
360    let position = required_value(position, "coordinates", *atom, options)?;
361    let seq = i64::from(required_value(
362        residue_sequence(*residue, options.namespace),
363        "residue sequence identifier",
364        *atom,
365        options,
366    )?);
367    let component = required_text(
368        component_name(*atom, *residue, options.namespace),
369        "component identifier",
370        *atom,
371        options,
372    )?;
373    let occupancy = required_value(atom.occupancy(), "occupancy", *atom, options)?;
374    let b_factor = required_value(atom.b_factor(), "B factor", *atom, options)?;
375
376    let _ = writeln!(
377        out,
378        "{record}{serial:>5} {name:<4}{alt:1}{comp:>3} {chain:>1}{seq}{ins:1}   \
379         {x:8.3}{y:8.3}{z:8.3}{occ:6.2}{b:6.2}          {element:>2}{charge}",
380        serial = serial_field(serial, options),
381        name = atom_name_field(name, &element),
382        alt = alt_field(structure, atom),
383        comp = component,
384        seq = residue_field(seq, options),
385        ins = insertion_code(*residue),
386        x = f64::from(position[0]),
387        y = f64::from(position[1]),
388        z = f64::from(position[2]),
389        occ = f64::from(occupancy),
390        b = f64::from(b_factor),
391        charge = charge_field(atom.formal_charge()),
392    );
393    // A tensor recorded for this atom follows under the same serial and the
394    // same identity columns, as the record pairs by serial.
395    if let Some(u) = structure.data().anisotropy.for_atom(atom.index()) {
396        super::aniso::Record {
397            atom,
398            structure,
399            residue,
400            name,
401            component,
402            chain,
403            seq: &residue_field(seq, options),
404            serial: &serial_field(serial, options),
405            u: &u,
406        }
407        .write(out);
408    }
409    Ok(())
410}
411
412/// The one-column alternate-location field.
413///
414/// A label longer than one character has already been refused by the capacity
415/// check, so anything reaching here fits.
416pub(crate) fn alt_field<'a>(structure: &'a Structure, atom: &AtomRef<'a>) -> &'a str {
417    let Some(alt) = atom.alt_id() else { return " " };
418    let Some(symbol) = alt.symbol() else {
419        return " ";
420    };
421    match structure.resolve(symbol) {
422        Some(label) => label,
423        None => " ",
424    }
425}
426
427pub(crate) fn serial_field(serial: i64, options: &PdbOptions) -> String {
428    match options
429        .hybrid36
430        .then(|| hybrid36::encode(serial, 5))
431        .flatten()
432    {
433        Some(field) => field,
434        None => format!("{serial:>5}"),
435    }
436}
437
438pub(crate) fn residue_field(seq: i64, options: &PdbOptions) -> String {
439    match options.hybrid36.then(|| hybrid36::encode(seq, 4)).flatten() {
440        Some(field) => field,
441        None => format!("{seq:>4}"),
442    }
443}
444
445pub(crate) fn chain_label<'a>(
446    chain: &'a ChainRef<'a>,
447    namespace: PdbIdentifierNamespace,
448) -> &'a str {
449    let Some(name) = chain_name(*chain, namespace) else {
450        return "";
451    };
452    name
453}
454
455pub(crate) fn insertion_code(residue: ResidueRef<'_>) -> &str {
456    let Some(code) = residue.ins_code() else {
457        return " ";
458    };
459    code
460}
461
462#[cfg(test)]
463#[path = "pdb_tests.rs"]
464mod tests;