Skip to main content

molframe_core/structure/
merge.rs

1//! Deterministic concatenation of compatible immutable structures.
2
3use super::merge_annotations::merge_annotations;
4use super::{CoordinateStore, EntryMetadata, Structure, StructureData, UnitCell, validate};
5use crate::anisotropy::{AnisotropicDisplacement, AnisotropyTableBuilder};
6use crate::bond::{BondRecord, BondTableBuilder};
7use crate::chunk::{AtomRecord, ChunkBuilder};
8use crate::coords::{CoordinateBlock, CoordinateGeneration};
9use crate::diagnostic::{Code, Diagnostic};
10use crate::index::{AtomIndex, EntityIndex, ModelIndex, ResidueIndex};
11use crate::optional::OptionalSymbol;
12use crate::symbol::{AltId, Interner, SymbolId};
13use crate::topology::{ChainRecord, ResidueRecord, Topology};
14
15impl Structure {
16    /// Concatenates compatible structures into one immutable system.
17    ///
18    /// Atom, residue, chain and entity order follows input order. Dense inputs
19    /// must have equal frame counts; their corresponding frames are
20    /// concatenated. Dictionary identifiers, bonds and symbolic annotations are
21    /// remapped rather than compared by structure-local integer value.
22    ///
23    /// # Errors
24    ///
25    /// Returns diagnostics for invalid inputs, ragged or unequal model axes,
26    /// incompatible same-named annotation types, or representational overflow.
27    pub fn merge(structures: &[Self]) -> Result<Self, Vec<Diagnostic>> {
28        if structures.is_empty() {
29            return Ok(Self::new(StructureData::empty()));
30        }
31        if structures.len() == 1 {
32            return Ok(structures[0].clone());
33        }
34        validate_inputs(structures)?;
35        let frame_count = compatible_frame_count(structures)?;
36        let mut merger = Merger::new(structures, frame_count);
37        merger.append_all()?;
38        merger.finish()
39    }
40}
41
42struct Merger<'a> {
43    sources: &'a [Structure],
44    frame_count: usize,
45    data: StructureData,
46    chunks: ChunkBuilder,
47    bonds: BondTableBuilder,
48    anisotropy: AnisotropyTableBuilder,
49    atom_offset: u32,
50    residue_offset: u32,
51    entity_offset: u32,
52    chain_count: u32,
53}
54
55impl<'a> Merger<'a> {
56    fn new(sources: &'a [Structure], frame_count: usize) -> Self {
57        Self {
58            sources,
59            frame_count,
60            data: StructureData::empty(),
61            chunks: ChunkBuilder::new(),
62            bonds: BondTableBuilder::new(),
63            anisotropy: AnisotropyTableBuilder::new(),
64            atom_offset: 0,
65            residue_offset: 0,
66            entity_offset: 0,
67            chain_count: 0,
68        }
69    }
70
71    fn append_all(&mut self) -> Result<(), Vec<Diagnostic>> {
72        self.chunks.start_model(0);
73        for source in self.sources {
74            self.append_entities(source).map_err(single)?;
75            self.append_residues(source).map_err(single)?;
76            self.append_chains(source).map_err(single)?;
77            self.append_atoms(source).map_err(single)?;
78            self.append_bonds(source).map_err(single)?;
79            self.append_anisotropy(source).map_err(single)?;
80            self.advance_offsets(source).map_err(single)?;
81        }
82        Ok(())
83    }
84
85    fn append_entities(&mut self, source: &Structure) -> Result<(), Diagnostic> {
86        for entity in source.data().topology.entities.iter() {
87            let id = remap_symbol(
88                source,
89                entity_id(source, entity)?,
90                &mut self.data.dictionary,
91            )?;
92            let description = remap_optional(
93                source,
94                source.data().topology.entities.description(entity),
95                &mut self.data.dictionary,
96            )?;
97            let sequence = source
98                .data()
99                .topology
100                .entities
101                .canonical_sequence(entity)
102                .iter()
103                .map(|symbol| remap_symbol(source, *symbol, &mut self.data.dictionary))
104                .collect::<Result<Vec<_>, _>>()?;
105            let kind = source
106                .data()
107                .topology
108                .entities
109                .kind(entity)
110                .ok_or_else(invariant)?;
111            self.data
112                .topology
113                .entities
114                .push(id, kind, description, &sequence)
115                .map_err(|_| invariant())?;
116        }
117        Ok(())
118    }
119
120    fn append_residues(&mut self, source: &Structure) -> Result<(), Diagnostic> {
121        for residue in 0..u32_of(source.residue_count())? {
122            let residue = ResidueIndex::new(residue);
123            let table = &source.data().topology.residues;
124            let atoms = table.atoms(residue).ok_or_else(invariant)?;
125            let record = ResidueRecord {
126                label_comp_id: remap_symbol(
127                    source,
128                    table.label_comp_id(residue).ok_or_else(invariant)?,
129                    &mut self.data.dictionary,
130                )?,
131                auth_comp_id: remap_optional(
132                    source,
133                    table.auth_comp_id(residue),
134                    &mut self.data.dictionary,
135                )?,
136                label_seq_id: table.label_seq_id(residue).into(),
137                auth_seq_id: table.auth_seq_id(residue).into(),
138                ins_code: remap_optional(
139                    source,
140                    table.ins_code(residue),
141                    &mut self.data.dictionary,
142                )?,
143                het: table.is_het(residue),
144            };
145            self.data
146                .topology
147                .residues
148                .push(
149                    record,
150                    checked_add(atoms.start, self.atom_offset)?
151                        ..checked_add(atoms.end, self.atom_offset)?,
152                )
153                .map_err(|_| invariant())?;
154        }
155        Ok(())
156    }
157
158    fn append_chains(&mut self, source: &Structure) -> Result<(), Diagnostic> {
159        for chain in source.data().topology.chains.iter() {
160            let table = &source.data().topology.chains;
161            let residues = table.residues(chain).ok_or_else(invariant)?;
162            let entity = table.entity(chain).ok_or_else(invariant)?;
163            let record = ChainRecord {
164                label_asym_id: remap_symbol(
165                    source,
166                    table.label_asym_id(chain).ok_or_else(invariant)?,
167                    &mut self.data.dictionary,
168                )?,
169                auth_asym_id: remap_optional(
170                    source,
171                    table.auth_asym_id(chain),
172                    &mut self.data.dictionary,
173                )?,
174                entity: EntityIndex::new(checked_add(entity.get(), self.entity_offset)?),
175                polymer_kind: table.polymer_kind(chain).ok_or_else(invariant)?,
176            };
177            self.data
178                .topology
179                .chains
180                .push(
181                    record,
182                    checked_add(residues.start, self.residue_offset)?
183                        ..checked_add(residues.end, self.residue_offset)?,
184                )
185                .map_err(|_| invariant())?;
186            self.chain_count = checked_add(self.chain_count, 1)?;
187        }
188        Ok(())
189    }
190
191    fn append_atoms(&mut self, source: &Structure) -> Result<(), Diagnostic> {
192        let positions = source.positions();
193        for chunk in source.data().chunks.iter() {
194            for old in chunk.atoms() {
195                let local = old.checked_sub(chunk.atoms().start).ok_or_else(invariant)?;
196                let position = positions.get(old as usize).copied();
197                let record = chunk
198                    .record(local, &source.data().topology.residues, position)
199                    .ok_or_else(invariant)?;
200                let remapped = self.remap_atom(source, record)?;
201                self.chunks.push(remapped);
202            }
203        }
204        Ok(())
205    }
206
207    fn remap_atom(
208        &mut self,
209        source: &Structure,
210        record: AtomRecord,
211    ) -> Result<AtomRecord, Diagnostic> {
212        Ok(AtomRecord {
213            atom_name: remap_symbol(source, record.atom_name, &mut self.data.dictionary)?,
214            auth_atom_name: remap_optional(
215                source,
216                record.auth_atom_name.get(),
217                &mut self.data.dictionary,
218            )?,
219            alternate_component_id: remap_optional(
220                source,
221                record.alternate_component_id.get(),
222                &mut self.data.dictionary,
223            )?,
224            alt_id: match record.alt_id.symbol() {
225                Some(symbol) => {
226                    AltId::labelled(remap_symbol(source, symbol, &mut self.data.dictionary)?)
227                        .ok_or_else(invariant)?
228                }
229                None => AltId::BLANK,
230            },
231            residue: ResidueIndex::new(checked_add(record.residue.get(), self.residue_offset)?),
232            ..record
233        })
234    }
235
236    fn append_bonds(&mut self, source: &Structure) -> Result<(), Diagnostic> {
237        for bond in source.data().bonds.iter() {
238            // The kind is a dictionary identifier, and this merger builds its own
239            // dictionary: copying the numeric value across would make it name
240            // whatever string happens to sit at that index here. So the text is
241            // resolved against the source and interned into the destination.
242            let kind = match bond.kind.get() {
243                Some(symbol) => remap_optional(source, Some(symbol), &mut self.data.dictionary)?,
244                None => OptionalSymbol::NONE,
245            };
246            self.bonds.push(BondRecord {
247                atom_a: AtomIndex::new(checked_add(bond.atom_a.get(), self.atom_offset)?),
248                atom_b: AtomIndex::new(checked_add(bond.atom_b.get(), self.atom_offset)?),
249                order: bond.order,
250                provenance: bond.provenance,
251                kind,
252            });
253        }
254        Ok(())
255    }
256
257    fn append_anisotropy(&mut self, source: &Structure) -> Result<(), Diagnostic> {
258        for record in source.data().anisotropy.iter() {
259            self.anisotropy.push(AnisotropicDisplacement {
260                atom: AtomIndex::new(checked_add(record.atom.get(), self.atom_offset)?),
261                ..record
262            });
263        }
264        Ok(())
265    }
266
267    fn advance_offsets(&mut self, source: &Structure) -> Result<(), Diagnostic> {
268        self.atom_offset = checked_add(self.atom_offset, source.atom_count())?;
269        self.residue_offset = checked_add(self.residue_offset, u32_of(source.residue_count())?)?;
270        self.entity_offset = checked_add(self.entity_offset, u32_of(source.entity_count())?)?;
271        Ok(())
272    }
273
274    fn finish(mut self) -> Result<Structure, Vec<Diagnostic>> {
275        let annotations = merge_annotations(self.sources, &mut self.data.dictionary)?;
276        let (chunks, first) = self.chunks.finish();
277        self.data.entry = EntryMetadata::default();
278        self.data.chunks = chunks.into();
279        self.data.bonds = self.bonds.finish_with_availability(
280            self.sources
281                .iter()
282                .all(|source| source.data().bonds.is_available()),
283        );
284        self.data.anisotropy = self.anisotropy.finish_with_availability(
285            self.sources
286                .iter()
287                .all(|source| source.data().anisotropy.is_available()),
288        );
289        self.data.annotations = annotations;
290        self.data.coords = merged_coordinates(self.sources, self.frame_count, first)?;
291        self.data.cell = common_cell(self.sources);
292        self.data.generation = CoordinateGeneration::INITIAL;
293        add_models(
294            &mut self.data.topology,
295            self.sources,
296            self.frame_count,
297            self.chain_count,
298        )?;
299        let findings = validate(&self.data);
300        if findings.is_empty() {
301            Ok(Structure::new(self.data))
302        } else {
303            Err(findings)
304        }
305    }
306}
307
308fn validate_inputs(structures: &[Structure]) -> Result<(), Vec<Diagnostic>> {
309    let mut findings: Vec<_> = structures
310        .iter()
311        .enumerate()
312        .flat_map(|(source, structure)| {
313            validate(structure.data())
314                .into_iter()
315                .map(move |finding| finding.with_context("merge source", source.to_string()))
316        })
317        .collect();
318    findings.extend(
319        structures
320            .iter()
321            .enumerate()
322            .filter(|(_, structure)| !structure.extensions().is_empty())
323            .map(|(source, structure)| {
324                Diagnostic::new(Code::E3014)
325                    .with_context("merge source", source.to_string())
326                    .with_context(
327                        "extensions",
328                        structure.extensions().keys().collect::<Vec<_>>().join(","),
329                    )
330            }),
331    );
332    if findings.is_empty() {
333        Ok(())
334    } else {
335        Err(findings)
336    }
337}
338
339fn compatible_frame_count(structures: &[Structure]) -> Result<usize, Vec<Diagnostic>> {
340    let expected = structures[0].model_count();
341    let compatible = structures.iter().all(|structure| {
342        !matches!(structure.data().coords, CoordinateStore::Ragged { .. })
343            && structure.model_count() == expected
344    });
345    if compatible {
346        Ok(expected)
347    } else {
348        Err(single(Diagnostic::new(Code::E3012)))
349    }
350}
351
352fn add_models(
353    topology: &mut Topology,
354    sources: &[Structure],
355    count: usize,
356    chains: u32,
357) -> Result<(), Vec<Diagnostic>> {
358    for model in 0..count {
359        let index = ModelIndex::new(u32_of(model).map_err(single)?);
360        let stored = sources[0].data().topology.models.model_num(index);
361        let Some(number) = stored else {
362            return Err(single(invariant()));
363        };
364        topology
365            .models
366            .push(number, 0..chains)
367            .map_err(|_| single(invariant()))?;
368    }
369    Ok(())
370}
371
372fn merged_coordinates(
373    sources: &[Structure],
374    frame_count: usize,
375    first: CoordinateBlock,
376) -> Result<CoordinateStore, Vec<Diagnostic>> {
377    if frame_count == 1 {
378        return Ok(CoordinateStore::Single(first));
379    }
380    let mut frames = Vec::with_capacity(frame_count);
381    frames.push(first);
382    for frame in 1..frame_count {
383        let mut merged = CoordinateBlock::new();
384        for source in sources {
385            let index = ModelIndex::new(u32_of(frame).map_err(single)?);
386            if let Some(positions) = source.model_positions(index) {
387                for position in positions {
388                    merged.push(*position);
389                }
390            }
391        }
392        frames.push(merged);
393    }
394    Ok(CoordinateStore::Dense { frames })
395}
396
397fn u32_of(value: usize) -> Result<u32, Diagnostic> {
398    u32::try_from(value).map_err(|_| invariant())
399}
400
401fn common_cell(sources: &[Structure]) -> Option<UnitCell> {
402    let first = sources.first()?.data().cell?;
403    sources
404        .iter()
405        .all(|source| source.data().cell == Some(first))
406        .then_some(first)
407}
408
409fn entity_id(source: &Structure, entity: EntityIndex) -> Result<SymbolId, Diagnostic> {
410    source
411        .data()
412        .topology
413        .entities
414        .id(entity)
415        .ok_or_else(invariant)
416}
417
418fn remap_optional(
419    source: &Structure,
420    symbol: Option<SymbolId>,
421    dictionary: &mut Interner,
422) -> Result<OptionalSymbol, Diagnostic> {
423    symbol
424        .map(|symbol| remap_symbol(source, symbol, dictionary))
425        .transpose()
426        .map(OptionalSymbol::from)
427}
428
429pub(super) fn remap_symbol(
430    source: &Structure,
431    symbol: SymbolId,
432    dictionary: &mut Interner,
433) -> Result<SymbolId, Diagnostic> {
434    let text = source.resolve(symbol).ok_or_else(invariant)?;
435    dictionary.intern(text).map_err(|_| {
436        Diagnostic::new(Code::E1901)
437            .with_context("limit", "dictionary entries")
438            .with_context("identifier", text)
439    })
440}
441
442fn checked_add(left: u32, right: u32) -> Result<u32, Diagnostic> {
443    left.checked_add(right)
444        .ok_or_else(|| Diagnostic::new(Code::E1901).with_context("limit", "u32 table rows"))
445}
446
447fn invariant() -> Diagnostic {
448    Diagnostic::new(Code::E9001)
449}
450
451pub(super) fn single(finding: Diagnostic) -> Vec<Diagnostic> {
452    vec![finding]
453}
454
455#[cfg(test)]
456#[path = "merge_tests.rs"]
457mod tests;