molframe-pdb 0.6.1

Legacy PDB reading and writing for molframe
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
//! MMTF writer: validates a structure and encodes it as a `MessagePack` file.
//!
//! Complexity: O(atoms + bonds + residues) time and space; the group dictionary is
//! built with hashed lookups.

use super::codec::{encode_f32, encode_i32, encode_strings};
use super::schema::{Entity, File, Group};
use super::{MmtfMetadata, MmtfOptionalField};
use molframe_core::diagnostic::{Code, Diagnostic};
use molframe_core::index::{EntityIndex, ModelIndex};
use molframe_core::structure::Structure;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::io::Write;

mod encoding;
use self::encoding::{
    chars_with_presence, encode_bond_columns, encode_optional_f32, encode_optional_i32,
    encoded_cell, encoded_order, experimental_methods, i32_of, refusal, required_presence,
    required_text, single_ascii, source_metadata, validate_elements,
};

const VERSION: &str = "1.0.0";
const PRODUCER: &str = "molframe 0.1.2";
const CHAIN_WIDTH: usize = 4;

/// Writes a deterministic standards-conforming MMTF `MessagePack` document.
///
/// # Errors
///
/// Refuses ragged models, absent coordinates, identifiers beyond MMTF's byte
/// limits, non-ASCII character fields, and counts outside signed 32-bit range.
pub fn write_mmtf(structure: &Structure) -> Result<Vec<u8>, Vec<Diagnostic>> {
    let mut output = Vec::new();
    write_mmtf_to(structure, &mut output)?;
    Ok(output)
}

/// Streams deterministic MMTF `MessagePack` to a byte destination.
///
/// # Errors
///
/// Returns structure, encoding, or destination diagnostics.
pub fn write_mmtf_to<W: Write>(
    structure: &Structure,
    output: &mut W,
) -> Result<(), Vec<Diagnostic>> {
    validate_structure(structure).map_err(|finding| vec![finding])?;
    let file = build(structure).map_err(|finding| vec![finding])?;
    rmp_serde::encode::write_named(output, &file).map_err(|error| {
        vec![
            Diagnostic::new(Code::E1102)
                .with_message("MMTF MessagePack could not be encoded")
                .with_context("encoder", error.to_string()),
        ]
    })
}

fn validate_structure(structure: &Structure) -> Result<(), Diagnostic> {
    if structure.ragged_models().is_some() {
        Err(refusal(
            "MMTF writer requires shared topology across models",
        ))
    } else {
        Ok(())
    }
}

fn build(structure: &Structure) -> Result<File, Diagnostic> {
    let models = structure.model_count();
    let atom_count = structure.atom_count() as usize;
    let residue_count = structure.residue_count();
    let chain_count = structure.chain_count();
    let metadata = source_metadata(structure)?;
    let mut group_list = Vec::with_capacity(residue_count);
    let mut topology = TopologyColumns::with_capacity(residue_count, chain_count, models);
    let atom_capacity = atom_count
        .checked_mul(models)
        .ok_or_else(|| refusal("MMTF atom capacity overflows"))?;
    let mut atoms = AtomColumns::with_capacity(atom_capacity);
    let group_type_of = build_group_dictionary(structure, metadata, &mut group_list)?;
    append_models(structure, models, &group_type_of, &mut topology, &mut atoms)?;
    let (bond_atoms, bond_orders, bond_resonance) = bonds(structure, models)?;
    let num_bonds = i32_of(bond_orders.len(), "bond count")?;
    let (bond_atom_list, bond_order_list, bond_resonance_list) =
        encode_bond_columns(&bond_atoms, &bond_orders, &bond_resonance)?;
    Ok(File {
        mmtf_version: VERSION.to_string(),
        mmtf_producer: PRODUCER.to_string(),
        structure_id: structure.data().entry.id.as_deref().map(str::to_owned),
        title: structure.data().entry.title.as_deref().map(str::to_owned),
        unit_cell: encoded_cell(structure)?,
        space_group: metadata.space_group.as_deref().map(str::to_owned),
        experimental_methods: experimental_methods(structure),
        resolution: structure.data().entry.resolution,
        num_bonds,
        num_atoms: i32_of(atoms.x.len(), "atom count")?,
        num_groups: i32_of(topology.group_ids.len(), "group count")?,
        num_chains: i32_of(topology.chain_ids.len(), "chain count")?,
        num_models: i32_of(models, "model count")?,
        group_list,
        bond_atom_list,
        bond_order_list,
        bond_resonance_list,
        x_coord_list: encode_f32(&atoms.x)?,
        y_coord_list: encode_f32(&atoms.y)?,
        z_coord_list: encode_f32(&atoms.z)?,
        b_factor_list: encode_optional_f32(
            atoms.b_factor.values(),
            metadata
                .optional_fields
                .contains(&MmtfOptionalField::BFactor),
            "B-factor list",
        )?,
        atom_id_list: encode_optional_i32(
            atoms.atom_id.values(),
            metadata
                .optional_fields
                .contains(&MmtfOptionalField::AtomId),
            "atom identifier list",
        )?,
        alt_loc_list: chars_with_presence(
            &atoms.alt_loc,
            metadata
                .optional_fields
                .contains(&MmtfOptionalField::AltLoc),
            "alternate-location list",
        )?,
        occupancy_list: encode_optional_f32(
            atoms.occupancy.values(),
            metadata
                .optional_fields
                .contains(&MmtfOptionalField::Occupancy),
            "occupancy list",
        )?,
        group_id_list: encode_i32(&topology.group_ids)?,
        group_type_list: encode_i32(&topology.group_types)?,
        ins_code_list: chars_with_presence(
            &topology.insertion_codes,
            metadata
                .optional_fields
                .contains(&MmtfOptionalField::InsCode),
            "insertion-code list",
        )?,
        sequence_index_list: metadata
            .optional_fields
            .contains(&MmtfOptionalField::SequenceIndex)
            .then(|| encode_i32(&topology.sequence_indices))
            .transpose()?,
        chain_id_list: encode_strings(&topology.chain_ids, CHAIN_WIDTH)?,
        chain_name_list: required_presence(
            topology.chain_names.values(),
            metadata
                .optional_fields
                .contains(&MmtfOptionalField::ChainName),
            "author chain name list",
        )?
        .map(|names| encode_strings(names, CHAIN_WIDTH))
        .transpose()?,
        groups_per_chain: topology.groups_per_chain,
        chains_per_model: vec![i32_of(chain_count, "chains per model")?; models],
        entity_list: metadata
            .optional_fields
            .contains(&MmtfOptionalField::EntityList)
            .then(|| entities(structure, metadata, models))
            .transpose()?,
    })
}

fn append_models(
    structure: &Structure,
    models: usize,
    group_type_of: &[i32],
    topology: &mut TopologyColumns,
    atoms: &mut AtomColumns,
) -> Result<(), Diagnostic> {
    for model in 0..models {
        topology.append(structure, group_type_of)?;
        let model = u32::try_from(model).map_err(|_| refusal("MMTF model index exceeds u32"))?;
        atoms.append(structure, ModelIndex::new(model))?;
    }
    Ok(())
}

/// Builds the group dictionary and the residue-to-group-type mapping.
///
/// MMTF stores each distinct chemistry once and refers to it by index, so a
/// structure with a million glycines carries one glycine entry. Emitting one
/// entry per residue instead made the dictionary as large as the structure and
/// repeated every name and element list with it.
fn build_group_dictionary(
    structure: &Structure,
    metadata: &MmtfMetadata,
    groups: &mut Vec<Group>,
) -> Result<Vec<i32>, Diagnostic> {
    // The metadata chemistry list is searched once per residue, so it is keyed
    // by component name first rather than scanned end to end each time.
    let mut chemistry_by_name: HashMap<&str, Vec<usize>> = HashMap::new();
    for (position, candidate) in metadata.groups.iter().enumerate() {
        chemistry_by_name
            .entry(candidate.name.as_ref())
            .or_default()
            .push(position);
    }

    let mut group_type_of = vec![0i32; structure.residue_count()];
    let mut seen: HashMap<Group, i32> = HashMap::new();

    for residue in structure.data().residues() {
        let atoms: Vec<_> = residue.atoms().collect();
        let formal_charge_list = atoms
            .iter()
            .map(|atom| {
                atom.formal_charge()
                    .map(i32::from)
                    .ok_or_else(|| refusal("MMTF requires a formal charge for every atom"))
            })
            .collect::<Result<Vec<_>, _>>()?;
        let atom_name_list = atoms
            .iter()
            .map(|atom| required_text(atom.name(), "atom name"))
            .collect::<Result<Vec<_>, _>>()?;
        let group_name = required_text(residue.name(), "component name")?;
        let chemistry = chemistry_by_name
            .get(group_name.as_str())
            .and_then(|candidates| {
                candidates.iter().find_map(|position| {
                    let candidate = metadata.groups.get(*position)?;
                    (candidate.atom_names.len() == atom_name_list.len()
                        && candidate
                            .atom_names
                            .iter()
                            .zip(&atom_name_list)
                            .all(|(expected, actual)| expected.as_ref() == actual))
                    .then_some(candidate)
                })
            })
            .ok_or_else(|| refusal("MMTF group chemistry no longer matches the structure"))?;
        let element_list = chemistry
            .elements
            .as_ref()
            .map(|elements| validate_elements(&atoms, elements))
            .transpose()?;
        if atom_name_list.iter().any(|name| name.len() > 5) || group_name.len() > 5 {
            return Err(refusal(
                "MMTF atom and component names are limited to five bytes",
            ));
        }
        let group = Group {
            formal_charge_list,
            atom_name_list,
            element_list,
            bond_atom_list: Vec::new(),
            bond_order_list: Vec::new(),
            bond_resonance_list: Vec::new(),
            name: group_name,
            single_letter_code: chemistry.single_letter_code.to_string(),
            chem_comp_type: chemistry.chem_comp_type.to_string(),
        };

        // First appearance order decides the identifiers, so the dictionary is
        // the same for the same structure on every run.
        let next = i32_of(groups.len(), "group type")?;
        let group_type = match seen.entry(group) {
            Entry::Occupied(entry) => *entry.get(),
            Entry::Vacant(entry) => {
                groups.push(entry.key().clone());
                *entry.insert(next)
            }
        };

        if let Some(slot) = group_type_of.get_mut(residue.index().as_usize()) {
            *slot = group_type;
        }
    }
    Ok(group_type_of)
}

struct TopologyColumns {
    group_ids: Vec<i32>,
    group_types: Vec<i32>,
    insertion_codes: Vec<u8>,
    sequence_indices: Vec<i32>,
    groups_per_chain: Vec<i32>,
    chain_ids: Vec<String>,
    chain_names: OptionalColumn<String>,
}

impl TopologyColumns {
    fn with_capacity(residues: usize, chains: usize, models: usize) -> Self {
        Self {
            group_ids: Vec::with_capacity(residues * models),
            group_types: Vec::with_capacity(residues * models),
            insertion_codes: Vec::with_capacity(residues * models),
            sequence_indices: Vec::with_capacity(residues * models),
            groups_per_chain: Vec::with_capacity(chains * models),
            chain_ids: Vec::with_capacity(chains * models),
            chain_names: OptionalColumn::Undecided,
        }
    }

    fn append(&mut self, structure: &Structure, group_type_of: &[i32]) -> Result<(), Diagnostic> {
        for chain in structure.data().chains() {
            self.chain_ids
                .push(required_text(chain.label(), "chain label")?);
            self.chain_names.push(
                chain.auth_label().map(str::to_owned),
                "MMTF cannot represent partially absent author chain names",
            )?;
            let residues: Vec<_> = chain.residues().collect();
            self.groups_per_chain
                .push(i32_of(residues.len(), "groups per chain")?);
            for residue in residues {
                self.group_ids.push(residue.auth_seq_id().ok_or_else(|| {
                    refusal("MMTF requires an author residue sequence identifier")
                })?);
                // The dictionary index, not the residue index: identical
                // chemistries share one entry.
                let group_type = group_type_of
                    .get(residue.index().as_usize())
                    .copied()
                    .ok_or_else(|| refusal("MMTF group type is missing for a residue"))?;
                self.group_types.push(group_type);
                self.insertion_codes
                    .push(single_ascii(residue.ins_code(), "insertion code")?);
                self.sequence_indices
                    .push(residue.label_seq_id().map_or(-1, |value| value - 1));
            }
        }
        Ok(())
    }
}

struct AtomColumns {
    x: Vec<f32>,
    y: Vec<f32>,
    z: Vec<f32>,
    b_factor: OptionalColumn<f32>,
    occupancy: OptionalColumn<f32>,
    atom_id: OptionalColumn<i32>,
    alt_loc: Vec<u8>,
}

impl AtomColumns {
    fn with_capacity(capacity: usize) -> Self {
        Self {
            x: Vec::with_capacity(capacity),
            y: Vec::with_capacity(capacity),
            z: Vec::with_capacity(capacity),
            b_factor: OptionalColumn::Undecided,
            occupancy: OptionalColumn::Undecided,
            atom_id: OptionalColumn::Undecided,
            alt_loc: Vec::with_capacity(capacity),
        }
    }

    fn append(&mut self, structure: &Structure, model: ModelIndex) -> Result<(), Diagnostic> {
        let positions = structure
            .model_positions(model)
            .ok_or_else(|| refusal("MMTF requires a dense coordinate frame"))?;
        if positions.len() != structure.atom_count() as usize {
            return Err(refusal(
                "MMTF frame length differs from the shared topology",
            ));
        }
        for (atom, position) in structure.data().atoms().zip(positions) {
            self.x.push(position[0]);
            self.y.push(position[1]);
            self.z.push(position[2]);
            self.b_factor.push(
                atom.b_factor(),
                "MMTF cannot represent partially absent B factors",
            )?;
            self.occupancy.push(
                atom.occupancy(),
                "MMTF cannot represent partially absent occupancies",
            )?;
            let atom_id = atom
                .atom_site_id()
                .map(|value| {
                    i32::try_from(value)
                        .map_err(|_| refusal("MMTF atom IDs are signed 32-bit integers"))
                })
                .transpose()?;
            self.atom_id.push(
                atom_id,
                "MMTF cannot represent partially absent atom identifiers",
            )?;
            self.alt_loc
                .push(single_ascii(atom.alt_label(), "alternate location")?);
        }
        Ok(())
    }
}

enum OptionalColumn<T> {
    Undecided,
    Present(Vec<T>),
    Absent,
}

impl<T> OptionalColumn<T> {
    fn push(&mut self, value: Option<T>, mismatch: &'static str) -> Result<(), Diagnostic> {
        match (&mut *self, value) {
            (Self::Undecided, Some(value)) => *self = Self::Present(vec![value]),
            (Self::Undecided, None) => *self = Self::Absent,
            (Self::Present(values), Some(value)) => values.push(value),
            (Self::Absent, None) => {}
            (Self::Present(_), None) | (Self::Absent, Some(_)) => return Err(refusal(mismatch)),
        }
        Ok(())
    }

    fn values(&self) -> Option<&[T]> {
        match self {
            Self::Present(values) => Some(values),
            Self::Undecided | Self::Absent => None,
        }
    }
}

type EncodedBonds = (Vec<i32>, Vec<i32>, Vec<i32>);

fn bonds(structure: &Structure, models: usize) -> Result<EncodedBonds, Diagnostic> {
    let count = structure.atom_count() as usize;
    let mut atoms = Vec::with_capacity(structure.data().bonds.len() * models * 2);
    let mut orders = Vec::with_capacity(structure.data().bonds.len() * models);
    let mut resonance = Vec::with_capacity(structure.data().bonds.len() * models);
    for model in 0..models {
        let offset = model
            .checked_mul(count)
            .ok_or_else(|| refusal("MMTF bond offset overflows"))?;
        for bond in structure.data().bonds.iter() {
            let endpoint = |atom: usize| {
                offset
                    .checked_add(atom)
                    .ok_or_else(|| refusal("MMTF bond endpoint overflows"))
                    .and_then(|value| i32_of(value, "bond endpoint"))
            };
            atoms.push(endpoint(bond.atom_a.as_usize())?);
            atoms.push(endpoint(bond.atom_b.as_usize())?);
            let (order, resonant) = encoded_order(bond.order);
            orders.push(order);
            resonance.push(resonant);
        }
    }
    Ok((atoms, orders, resonance))
}

fn entities(
    structure: &Structure,
    metadata: &MmtfMetadata,
    models: usize,
) -> Result<Vec<Entity>, Diagnostic> {
    let mut chains: BTreeMap<EntityIndex, Vec<i32>> = BTreeMap::new();
    let chain_count = structure.chain_count();
    for model in 0..models {
        for chain in structure.data().chains() {
            let entity = chain
                .entity()
                .ok_or_else(|| refusal("chain has no entity"))?;
            let position = model
                .checked_mul(chain_count)
                .and_then(|offset| offset.checked_add(chain.index().as_usize()))
                .ok_or_else(|| refusal("MMTF entity chain index overflows"))?;
            chains
                .entry(entity)
                .or_default()
                .push(i32_of(position, "entity chain index")?);
        }
    }
    structure
        .data()
        .topology
        .entities
        .iter()
        .enumerate()
        .map(|(position, entity)| {
            let source = metadata
                .entities
                .get(position)
                .ok_or_else(|| refusal("MMTF entity metadata is incomplete"))?;
            Ok(Entity {
                chain_index_list: chains
                    .remove(&entity)
                    .ok_or_else(|| refusal("MMTF entity is not instantiated by any chain"))?,
                description: source.description.to_string(),
                kind: source.kind.to_string(),
                sequence: source.sequence.to_string(),
            })
        })
        .collect()
}