Skip to main content

polysim_core/builder/
branched.rs

1use bigsmiles::BigSmiles;
2use rand::prelude::*;
3use rand::rngs::StdRng;
4
5use crate::{
6    error::PolySimError,
7    polymer::{Architecture, MonomerUnit, PolymerChain},
8    properties::molecular_weight::{average_mass, monoisotopic_mass},
9};
10
11use super::linear::{
12    build_linear_smiles, collect_smiles_segments, max_ring_number, renumber_ring_closures,
13    resolve_n_by_mass,
14};
15use super::strategy::BuildStrategy;
16
17/// Builder for non-linear polymer architectures (comb, graft, star, dendrimer).
18///
19/// Unlike [`LinearBuilder`](super::linear::LinearBuilder), this builder takes
20/// two BigSMILES strings: one for the **backbone** and one for the **branch**.
21pub struct BranchedBuilder {
22    /// BigSMILES of the backbone chain.
23    backbone: BigSmiles,
24    /// BigSMILES of the branch / side chain.
25    branch: BigSmiles,
26    /// Strategy that controls backbone chain length.
27    strategy: BuildStrategy,
28    /// Optional seed for reproducible random placement.
29    seed: Option<u64>,
30}
31
32impl BranchedBuilder {
33    /// Creates a new builder from backbone and branch BigSMILES strings plus a
34    /// build strategy that governs the backbone length.
35    pub fn new(backbone: BigSmiles, branch: BigSmiles, strategy: BuildStrategy) -> Self {
36        Self {
37            backbone,
38            branch,
39            strategy,
40            seed: None,
41        }
42    }
43
44    /// Set a random seed for reproducible generation.
45    pub fn seed(mut self, seed: u64) -> Self {
46        self.seed = Some(seed);
47        self
48    }
49
50    /// Generates a comb (regularly branched) polymer.
51    ///
52    /// `branch_every` -- attach one branch every N backbone repeat units.
53    pub fn comb_polymer(&self, branch_every: usize) -> Result<PolymerChain, PolySimError> {
54        let backbone_raw = self.first_repeat_unit(&self.backbone, "comb_polymer backbone")?;
55        let branch_raw = self.first_repeat_unit(&self.branch, "comb_polymer branch")?;
56        let n = self.resolve_n(&backbone_raw)?;
57
58        if n == 0 {
59            return Err(PolySimError::BuildStrategy(
60                "repeat count must be >= 1".to_string(),
61            ));
62        }
63
64        let smiles = build_comb_smiles(&backbone_raw, &branch_raw, n, branch_every)?;
65        let smiles = self.with_backbone_end_groups(&smiles);
66
67        let branch_count = (1..=n).filter(|i| i % branch_every == 0).count();
68        let total_units = n + branch_count;
69
70        let chain = PolymerChain::new(smiles, total_units, 0.0);
71        let mn = average_mass(&chain);
72
73        let backbone_frac = n as f64 / total_units as f64;
74        let branch_frac = branch_count as f64 / total_units as f64;
75        let composition = vec![
76            MonomerUnit::new(&backbone_raw, backbone_frac),
77            MonomerUnit::new(&branch_raw, branch_frac),
78        ];
79
80        Ok(PolymerChain::new(chain.smiles, total_units, mn)
81            .with_composition(composition)
82            .with_architecture(Architecture::Comb {
83                branch_spacing: branch_every,
84            }))
85    }
86
87    /// Generates a graft copolymer (random branch-point placement).
88    ///
89    /// `graft_fraction` -- fraction of backbone repeat units that carry a branch
90    /// (0.0 = no grafting, 1.0 = every backbone unit is grafted).
91    ///
92    /// `seed` -- optional random seed for reproducibility (overrides builder seed).
93    pub fn graft_copolymer(
94        &self,
95        graft_fraction: f64,
96        seed: Option<u64>,
97    ) -> Result<PolymerChain, PolySimError> {
98        let backbone_raw = self.first_repeat_unit(&self.backbone, "graft_copolymer backbone")?;
99        let branch_raw = self.first_repeat_unit(&self.branch, "graft_copolymer branch")?;
100        let n = self.resolve_n(&backbone_raw)?;
101
102        if n == 0 {
103            return Err(PolySimError::BuildStrategy(
104                "repeat count must be >= 1".to_string(),
105            ));
106        }
107
108        let effective_seed = seed.or(self.seed);
109        let mut rng: Box<dyn RngCore> = match effective_seed {
110            Some(s) => Box::new(StdRng::seed_from_u64(s)),
111            None => Box::new(rand::rng()),
112        };
113
114        let max_ring_bb = max_ring_number(&backbone_raw);
115        if max_ring_bb > 99 {
116            return Err(PolySimError::RingNumberOverflow {
117                max_ring: max_ring_bb,
118                max_supported: 99,
119            });
120        }
121        let cycle_length: usize = if max_ring_bb == 0 {
122            usize::MAX
123        } else {
124            99 / max_ring_bb as usize
125        };
126
127        let mut result = String::new();
128        let mut branch_count = 0usize;
129
130        for i in 0..n {
131            let slot = i % cycle_length;
132            let offset = slot as u32 * max_ring_bb;
133            let unit = renumber_ring_closures(&backbone_raw, offset);
134            result.push_str(&unit);
135
136            let roll: f64 = rng.random();
137            if roll < graft_fraction {
138                result.push('(');
139                result.push_str(&branch_raw);
140                result.push(')');
141                branch_count += 1;
142            }
143        }
144
145        let smiles = self.with_backbone_end_groups(&result);
146        let total_units = n + branch_count;
147
148        let chain = PolymerChain::new(smiles, total_units, 0.0);
149        let mn = average_mass(&chain);
150
151        let backbone_frac = n as f64 / total_units as f64;
152        let branch_frac = branch_count as f64 / total_units as f64;
153        let composition = vec![
154            MonomerUnit::new(&backbone_raw, backbone_frac),
155            MonomerUnit::new(&branch_raw, branch_frac),
156        ];
157
158        Ok(PolymerChain::new(chain.smiles, total_units, mn)
159            .with_composition(composition)
160            .with_architecture(Architecture::Graft { graft_fraction }))
161    }
162
163    /// Generates a star polymer with `arms` arms radiating from a central atom.
164    ///
165    /// Each arm is a homopolymer of the backbone BigSMILES repeat unit.
166    /// The arm length is determined by the build strategy.
167    ///
168    /// Supports 3 to 12 arms.
169    pub fn star_polymer(&self, arms: usize) -> Result<PolymerChain, PolySimError> {
170        if !(3..=12).contains(&arms) {
171            return Err(PolySimError::BuildStrategy(format!(
172                "star polymer requires 3..=12 arms, got {arms}"
173            )));
174        }
175
176        let unit_raw = self.first_repeat_unit(&self.backbone, "star_polymer")?;
177        let arm_length = self.resolve_n(&unit_raw)?;
178
179        if arm_length == 0 {
180            return Err(PolySimError::BuildStrategy(
181                "arm length must be >= 1".to_string(),
182            ));
183        }
184
185        // Build each arm SMILES
186        let arm_smiles = build_linear_smiles(&unit_raw, arm_length)?;
187
188        // Star SMILES: C(ARM1)(ARM2)...(ARM_{n-1})ARM_n
189        // The central "C" is the hub atom.
190        let mut result = String::from("C");
191        for i in 0..arms {
192            if i < arms - 1 {
193                result.push('(');
194                result.push_str(&arm_smiles);
195                result.push(')');
196            } else {
197                result.push_str(&arm_smiles);
198            }
199        }
200
201        let total_units = arms * arm_length;
202        let chain = PolymerChain::new(result, total_units, 0.0);
203        let mn = average_mass(&chain);
204
205        Ok(PolymerChain::new(chain.smiles, total_units, mn)
206            .with_architecture(Architecture::Star { arms }))
207    }
208
209    /// Generates a dendrimer of the given `generation` with `branching_factor`
210    /// sub-branches per node.
211    ///
212    /// - `generation`: 1..=6
213    /// - `branching_factor`: number of sub-branches per terminal node (typically 2)
214    pub fn dendrimer(
215        &self,
216        generation: usize,
217        branching_factor: usize,
218    ) -> Result<PolymerChain, PolySimError> {
219        if generation == 0 || generation > 6 {
220            return Err(PolySimError::BuildStrategy(format!(
221                "dendrimer generation must be 1..=6, got {generation}"
222            )));
223        }
224
225        let unit_raw = self.first_repeat_unit(&self.backbone, "dendrimer")?;
226
227        let smiles = build_dendrimer_smiles(&unit_raw, generation, branching_factor);
228
229        // Total units in a dendrimer:
230        // generation g with branching_factor b has:
231        //   1 (core) + b + b^2 + ... + b^g = (b^(g+1) - 1) / (b - 1) if b > 1
232        //   or g+1 if b == 1
233        let total_units = if branching_factor <= 1 {
234            generation + 1
235        } else {
236            (branching_factor.pow(generation as u32 + 1) - 1) / (branching_factor - 1)
237        };
238
239        let chain = PolymerChain::new(smiles, total_units, 0.0);
240        let mn = average_mass(&chain);
241
242        Ok(PolymerChain::new(chain.smiles, total_units, mn)
243            .with_architecture(Architecture::Dendrimer { generation }))
244    }
245
246    // --- private helpers -------------------------------------------------------
247
248    /// Extracts the first repeat unit SMILES from a BigSMILES string.
249    fn first_repeat_unit(&self, bs: &BigSmiles, context: &str) -> Result<String, PolySimError> {
250        let stoch = bs
251            .first_stochastic()
252            .ok_or(PolySimError::NoStochasticObject)?;
253
254        if stoch.repeat_units.is_empty() {
255            return Err(PolySimError::RepeatUnitCount {
256                architecture: "branched polymer",
257                got: 0,
258                need_min: 1,
259            });
260        }
261
262        let _ = context;
263        Ok(stoch.repeat_units[0].smiles_raw.clone())
264    }
265
266    /// Resolves repeat count from the build strategy.
267    fn resolve_n(&self, smiles_raw: &str) -> Result<usize, PolySimError> {
268        match &self.strategy {
269            BuildStrategy::ByRepeatCount(n) => Ok(*n),
270            BuildStrategy::ByTargetMn(target) => {
271                resolve_n_by_mass(smiles_raw, *target, average_mass)
272            }
273            BuildStrategy::ByExactMass(target) => {
274                resolve_n_by_mass(smiles_raw, *target, monoisotopic_mass)
275            }
276        }
277    }
278
279    /// Prepends prefix and appends suffix SMILES segments from the backbone BigSMILES.
280    fn with_backbone_end_groups(&self, body: &str) -> String {
281        let prefix = collect_smiles_segments(self.backbone.prefix_segments());
282        let suffix = collect_smiles_segments(self.backbone.suffix_segments());
283        let mut result = String::with_capacity(prefix.len() + body.len() + suffix.len());
284        result.push_str(&prefix);
285        result.push_str(body);
286        result.push_str(&suffix);
287        result
288    }
289}
290
291// --- free functions -----------------------------------------------------------
292
293/// Builds a comb polymer SMILES by inserting `(branch)` after every
294/// `branch_every`-th backbone unit.
295fn build_comb_smiles(
296    backbone_raw: &str,
297    branch_raw: &str,
298    n: usize,
299    branch_every: usize,
300) -> Result<String, PolySimError> {
301    let max_ring_bb = max_ring_number(backbone_raw);
302    if max_ring_bb > 99 {
303        return Err(PolySimError::RingNumberOverflow {
304            max_ring: max_ring_bb,
305            max_supported: 99,
306        });
307    }
308
309    let cycle_length: usize = if max_ring_bb == 0 {
310        usize::MAX
311    } else {
312        99 / max_ring_bb as usize
313    };
314
315    let mut result = String::new();
316    for i in 0..n {
317        let slot = i % cycle_length;
318        let offset = slot as u32 * max_ring_bb;
319        let unit = renumber_ring_closures(backbone_raw, offset);
320        result.push_str(&unit);
321
322        if branch_every > 0 && (i + 1) % branch_every == 0 {
323            result.push('(');
324            result.push_str(branch_raw);
325            result.push(')');
326        }
327    }
328    Ok(result)
329}
330
331/// Recursively builds the SMILES for a dendrimer.
332///
333/// At generation 0, returns a single copy of `unit`.
334/// At generation g, returns `unit` followed by `branching_factor` branches,
335/// each being a sub-dendrimer of generation g-1.
336fn build_dendrimer_smiles(unit: &str, generation: usize, branching_factor: usize) -> String {
337    if generation == 0 {
338        return unit.to_string();
339    }
340
341    let sub = build_dendrimer_smiles(unit, generation - 1, branching_factor);
342    let mut result = unit.to_string();
343
344    for i in 0..branching_factor {
345        if i < branching_factor - 1 {
346            result.push('(');
347            result.push_str(&sub);
348            result.push(')');
349        } else {
350            // Last branch without wrapping parens (SMILES convention)
351            result.push_str(&sub);
352        }
353    }
354
355    result
356}