dreid_forge/forge/config/
charge.rs

1//! Charge calculation method configurations.
2//!
3//! This module defines the charge assignment strategies available in the
4//! DREIDING parameterization pipeline.
5
6pub use ffcharge::NucleicScheme;
7pub use ffcharge::ProteinScheme;
8pub use ffcharge::WaterScheme;
9
10pub use cheq::{BasisType, DampingStrategy, SolverOptions};
11
12/// Method for calculating partial atomic charges.
13///
14/// Determines how partial charges are assigned to atoms during
15/// parameterization. The choice affects both the accuracy of
16/// electrostatic interactions and hydrogen bond parameters.
17#[derive(Debug, Clone, Default)]
18pub enum ChargeMethod {
19    /// No charge calculation; all charges remain zero.
20    ///
21    /// Use this for gas-phase calculations where electrostatics
22    /// are not critical, or when charges will be assigned externally.
23    #[default]
24    None,
25
26    /// Charge equilibration (QEq) method for all atoms.
27    ///
28    /// Calculates electronegativity-equalized charges based on
29    /// atomic positions and electronegativity parameters. Best suited
30    /// for small molecules or systems without biological metadata.
31    Qeq(QeqConfig),
32
33    /// Hybrid charge assignment for biological systems.
34    ///
35    /// Combines classical force field charges for biomolecules with
36    /// QEq for ligands and hetero groups. Requires biological metadata
37    /// to be present in the input system.
38    Hybrid(HybridConfig),
39}
40
41/// Configuration for QEq charge equilibration.
42///
43/// Controls the behavior of the charge equilibration solver,
44/// including the target total charge and numerical solver options.
45///
46/// # Examples
47///
48/// ```
49/// use dreid_forge::QeqConfig;
50///
51/// // Neutral molecule (default)
52/// let neutral = QeqConfig::default();
53/// assert_eq!(neutral.total_charge, 0.0);
54///
55/// // Negatively charged system
56/// let anion = QeqConfig {
57///     total_charge: -1.0,
58///     ..Default::default()
59/// };
60/// ```
61#[derive(Debug, Clone)]
62pub struct QeqConfig {
63    /// Target total charge of the system in elementary charge units.
64    ///
65    /// The QEq solver will constrain the sum of all partial charges
66    /// to equal this value. Default is `0.0` (neutral).
67    pub total_charge: f64,
68
69    /// QEq solver options.
70    ///
71    /// Controls convergence tolerance, iteration limits, and
72    /// hydrogen SCF treatment.
73    pub solver_options: SolverOptions,
74}
75
76impl Default for QeqConfig {
77    fn default() -> Self {
78        Self {
79            total_charge: 0.0,
80            solver_options: SolverOptions::default(),
81        }
82    }
83}
84
85/// Configuration for hybrid biological/QEq charge assignment.
86///
87/// Specifies how charges are assigned to different molecule types
88/// in a biological system. Standard residues (proteins, nucleic acids,
89/// water, ions) receive classical force field charges, while hetero
90/// groups (ligands) use QEq methods (vacuum or embedded).
91///
92/// # Molecule Classification
93///
94/// The hybrid method classifies atoms based on [`ResidueCategory`](crate::ResidueCategory):
95///
96/// | Category | Charge Source |
97/// |----------|---------------|
98/// | Standard amino acid | [`protein_scheme`](Self::protein_scheme) |
99/// | Standard nucleotide | [`nucleic_scheme`](Self::nucleic_scheme) |
100/// | Water (HOH) | [`water_scheme`](Self::water_scheme) |
101/// | Ion | Formal charges (classical) |
102/// | Hetero | Per-ligand or [`default_ligand_method`](Self::default_ligand_method) |
103#[derive(Debug, Clone)]
104pub struct HybridConfig {
105    /// Force field scheme for protein residue charges.
106    ///
107    /// Default is [`ProteinScheme::AmberFFSB`] (AMBER ff99SB/ff14SB/ff19SB).
108    pub protein_scheme: ProteinScheme,
109
110    /// Force field scheme for nucleic acid residue charges.
111    ///
112    /// Default is [`NucleicScheme::Amber`] (AMBER OL15/OL21/OL24/bsc1/OL3).
113    pub nucleic_scheme: NucleicScheme,
114
115    /// Water model for solvent charges.
116    ///
117    /// Default is [`WaterScheme::Tip3p`].
118    pub water_scheme: WaterScheme,
119
120    /// Per-ligand charge configuration overrides.
121    ///
122    /// Each entry specifies a residue selector and the QEq method to use
123    /// for that specific ligand. Ligands not listed here will use
124    /// [`default_ligand_method`](Self::default_ligand_method).
125    pub ligand_configs: Vec<LigandChargeConfig>,
126
127    /// Default QEq method for ligands not in [`ligand_configs`](Self::ligand_configs).
128    ///
129    /// Default is [`LigandQeqMethod::Embedded`] with 10 Å cutoff and neutral
130    /// total charge.
131    pub default_ligand_method: LigandQeqMethod,
132}
133
134impl Default for HybridConfig {
135    fn default() -> Self {
136        Self {
137            protein_scheme: ProteinScheme::default(),
138            nucleic_scheme: NucleicScheme::default(),
139            water_scheme: WaterScheme::default(),
140            ligand_configs: Vec::new(),
141            default_ligand_method: LigandQeqMethod::Embedded(EmbeddedQeqConfig::default()),
142        }
143    }
144}
145
146/// Residue selector for identifying specific residues.
147///
148/// Used to target specific ligands or hetero groups for custom
149/// charge assignment in hybrid mode.
150///
151/// # Examples
152///
153/// ```
154/// use dreid_forge::ResidueSelector;
155///
156/// // Select residue 500 in chain A
157/// let selector = ResidueSelector::new("A", 500, None);
158/// assert!(selector.matches("A", 500, None));
159/// assert!(!selector.matches("A", 500, Some('A')));
160///
161/// // Select residue 100 with insertion code 'B' in chain L
162/// let with_icode = ResidueSelector::new("L", 100, Some('B'));
163/// assert!(with_icode.matches("L", 100, Some('B')));
164/// assert!(!with_icode.matches("L", 100, None));
165/// ```
166#[derive(Debug, Clone, PartialEq, Eq, Hash)]
167pub struct ResidueSelector {
168    /// Chain identifier.
169    pub chain_id: String,
170    /// Residue sequence number.
171    pub residue_id: i32,
172    /// Optional insertion code for distinguishing residues with same ID.
173    pub insertion_code: Option<char>,
174}
175
176impl ResidueSelector {
177    /// Creates a new residue selector.
178    ///
179    /// # Arguments
180    ///
181    /// * `chain_id` — Chain identifier
182    /// * `residue_id` — Residue sequence number
183    /// * `insertion_code` — Optional insertion code
184    pub fn new(chain_id: impl Into<String>, residue_id: i32, insertion_code: Option<char>) -> Self {
185        Self {
186            chain_id: chain_id.into(),
187            residue_id,
188            insertion_code,
189        }
190    }
191
192    /// Checks if this selector matches an atom's residue info.
193    ///
194    /// # Arguments
195    ///
196    /// * `chain_id` — Chain ID to match
197    /// * `residue_id` — Residue ID to match
198    /// * `insertion_code` — Insertion code to match
199    ///
200    /// # Returns
201    ///
202    /// `true` if all fields match exactly, `false` otherwise.
203    pub fn matches(&self, chain_id: &str, residue_id: i32, insertion_code: Option<char>) -> bool {
204        self.chain_id == chain_id
205            && self.residue_id == residue_id
206            && self.insertion_code == insertion_code
207    }
208}
209
210/// Charge configuration for a specific ligand residue.
211///
212/// Combines a residue selector with the QEq method to use for
213/// that specific ligand.
214#[derive(Debug, Clone)]
215pub struct LigandChargeConfig {
216    /// Selector identifying the target residue.
217    pub selector: ResidueSelector,
218    /// QEq method to use for this ligand.
219    pub method: LigandQeqMethod,
220}
221
222/// QEq method variant for ligand charge assignment.
223///
224/// Ligands can use either vacuum QEq (isolated) or embedded QEq
225/// (polarized by surrounding fixed charges).
226#[derive(Debug, Clone)]
227pub enum LigandQeqMethod {
228    /// Vacuum QEq: ligand treated as isolated molecule.
229    ///
230    /// Best for ligands in solution or far from biomolecular surfaces.
231    Vacuum(QeqConfig),
232
233    /// Embedded QEq: ligand polarized by surrounding fixed charges.
234    ///
235    /// The ligand's charge distribution is influenced by nearby
236    /// protein/nucleic acid atoms within the cutoff radius.
237    Embedded(EmbeddedQeqConfig),
238}
239
240impl Default for LigandQeqMethod {
241    fn default() -> Self {
242        Self::Vacuum(QeqConfig::default())
243    }
244}
245
246/// Configuration for embedded QEq calculations.
247///
248/// In embedded QEq, atoms from surrounding biomolecules (with fixed charges)
249/// contribute an external electrostatic potential that polarizes the
250/// ligand's charge distribution.
251#[derive(Debug, Clone)]
252pub struct EmbeddedQeqConfig {
253    /// Cutoff radius for including environment atoms (Ångströms).
254    ///
255    /// Atoms within this distance from any ligand atom contribute
256    /// to the external electrostatic potential. Default is `10.0` Å.
257    pub cutoff_radius: f64,
258
259    /// QEq solver configuration for the ligand.
260    pub qeq: QeqConfig,
261}
262
263impl Default for EmbeddedQeqConfig {
264    fn default() -> Self {
265        Self {
266            cutoff_radius: 10.0,
267            qeq: QeqConfig::default(),
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn charge_method_default_is_none() {
278        assert!(matches!(ChargeMethod::default(), ChargeMethod::None));
279    }
280
281    #[test]
282    fn qeq_config_default_values() {
283        let config = QeqConfig::default();
284        assert_eq!(config.total_charge, 0.0);
285        assert!(config.solver_options.hydrogen_scf);
286    }
287
288    #[test]
289    fn hybrid_config_default_schemes() {
290        let config = HybridConfig::default();
291        assert_eq!(config.protein_scheme, ProteinScheme::AmberFFSB);
292        assert_eq!(config.nucleic_scheme, NucleicScheme::Amber);
293        assert_eq!(config.water_scheme, WaterScheme::Tip3p);
294        assert!(config.ligand_configs.is_empty());
295        assert!(matches!(
296            config.default_ligand_method,
297            LigandQeqMethod::Embedded(_)
298        ));
299        if let LigandQeqMethod::Embedded(embedded) = &config.default_ligand_method {
300            assert_eq!(embedded.cutoff_radius, 10.0);
301        }
302    }
303
304    #[test]
305    fn residue_selector_matches() {
306        let selector = ResidueSelector::new("A", 100, None);
307        assert!(selector.matches("A", 100, None));
308        assert!(!selector.matches("A", 100, Some('B')));
309        assert!(!selector.matches("B", 100, None));
310        assert!(!selector.matches("A", 101, None));
311
312        let with_icode = ResidueSelector::new("A", 100, Some('X'));
313        assert!(with_icode.matches("A", 100, Some('X')));
314        assert!(!with_icode.matches("A", 100, None));
315        assert!(!with_icode.matches("A", 100, Some('Y')));
316    }
317
318    #[test]
319    fn ligand_qeq_method_default_is_vacuum() {
320        assert!(matches!(
321            LigandQeqMethod::default(),
322            LigandQeqMethod::Vacuum(_)
323        ));
324    }
325
326    #[test]
327    fn embedded_qeq_config_default_cutoff() {
328        let config = EmbeddedQeqConfig::default();
329        assert_eq!(config.cutoff_radius, 10.0);
330    }
331
332    #[test]
333    fn hybrid_config_with_custom_ligand() {
334        let config = HybridConfig {
335            ligand_configs: vec![LigandChargeConfig {
336                selector: ResidueSelector::new("A", 500, None),
337                method: LigandQeqMethod::Embedded(EmbeddedQeqConfig {
338                    cutoff_radius: 8.0,
339                    qeq: QeqConfig {
340                        total_charge: -1.0,
341                        ..Default::default()
342                    },
343                }),
344            }],
345            ..Default::default()
346        };
347
348        assert_eq!(config.ligand_configs.len(), 1);
349        assert!(config.ligand_configs[0].selector.matches("A", 500, None));
350
351        if let LigandQeqMethod::Embedded(embedded) = &config.ligand_configs[0].method {
352            assert_eq!(embedded.cutoff_radius, 8.0);
353            assert_eq!(embedded.qeq.total_charge, -1.0);
354        } else {
355            panic!("Expected Embedded variant");
356        }
357    }
358}