• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

cgevans / equiconc / 24932010937

25 Apr 2026 01:29PM UTC coverage: 93.28% (-1.4%) from 94.685%
24932010937

push

github

web-flow
Merge pull request #2 from cgevans/add-log-objective

Add log objective

532 of 591 new or added lines in 2 files covered. (90.02%)

10 existing lines in 2 files now uncovered.

2429 of 2604 relevant lines covered (93.28%)

6542.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.53
/src/python.rs
1
use pyo3::exceptions::{PyKeyError, PyRuntimeError, PyValueError};
2
use pyo3::prelude::*;
3
use pyo3::types::PyDict;
4
use std::collections::HashMap;
5

6
use crate::{Equilibrium, EquilibriumError, SolverObjective, SolverOptions};
7

8
// ---------------------------------------------------------------------------
9
// Error mapping
10
// ---------------------------------------------------------------------------
11

12
fn map_err(e: EquilibriumError) -> PyErr {
19✔
13
    match e {
19✔
14
        // Defensive: the convex dual always converges for valid inputs.
15
        EquilibriumError::ConvergenceFailure { .. } => PyRuntimeError::new_err(e.to_string()),
1✔
16
        _ => PyValueError::new_err(e.to_string()),
18✔
17
    }
18
}
19✔
19

20
// ---------------------------------------------------------------------------
21
// Energy specification for complexes
22
// ---------------------------------------------------------------------------
23

24
#[derive(Debug, Clone)]
25
enum EnergySpec {
26
    /// ΔG in kcal/mol
27
    DeltaG(f64),
28
    /// ΔG/RT (unitless)
29
    DeltaGOverRT(f64),
30
    /// ΔH (kcal/mol) and ΔS (kcal/(mol·K))
31
    DeltaHS { delta_h: f64, delta_s: f64 },
32
}
33

34
/// Accepts either a plain float or a (value, temperature_C) tuple from Python.
35
#[derive(Debug, Clone, FromPyObject)]
36
enum DeltaGInput {
37
    Scalar(f64),
38
    AtTemp((f64, f64)),
39
}
40

41
fn resolve_energy_spec(
1,187✔
42
    dg_st: Option<DeltaGInput>,
1,187✔
43
    delta_g_over_rt: Option<f64>,
1,187✔
44
    dh_st: Option<f64>,
1,187✔
45
    ds_st: Option<f64>,
1,187✔
46
) -> PyResult<EnergySpec> {
1,187✔
47
    // Split dg_st into its two forms for cleaner matching.
48
    let (dg_stcalar, dg_at_temp) = match dg_st {
1,187✔
49
        Some(DeltaGInput::Scalar(v)) => (Some(v), None),
1,174✔
50
        Some(DeltaGInput::AtTemp(pair)) => (None, Some(pair)),
3✔
51
        None => (None, None),
10✔
52
    };
53

54
    match (dg_stcalar, dg_at_temp, delta_g_over_rt, dh_st, ds_st) {
1,187✔
55
        // dg_st=<float>
56
        (Some(dg), None, None, None, None) => Ok(EnergySpec::DeltaG(dg)),
1,172✔
57
        // delta_g_over_rt=<float>
58
        (None, None, Some(dgrt), None, None) => Ok(EnergySpec::DeltaGOverRT(dgrt)),
3✔
59
        // dh_st=<float>, ds_st=<float>
60
        (None, None, None, Some(dh), Some(ds)) => Ok(EnergySpec::DeltaHS {
4✔
61
            delta_h: dh,
4✔
62
            delta_s: ds,
4✔
63
        }),
4✔
64
        // dg_st=(<float>, <temp_C>), ds_st=<float>  →  derive ΔH
65
        (None, Some((dg, temp_c)), None, None, Some(ds)) => {
2✔
66
            let temp_k = temp_c + 273.15;
2✔
67
            let dh = dg + temp_k * ds;
2✔
68
            Ok(EnergySpec::DeltaHS {
2✔
69
                delta_h: dh,
2✔
70
                delta_s: ds,
2✔
71
            })
2✔
72
        }
73
        // dg_st=(<float>, <temp_C>) without ds_st
74
        (None, Some(_), None, None, None) => Err(PyValueError::new_err(
1✔
75
            "dg_st as (value, temperature_C) requires ds_st",
1✔
76
        )),
1✔
77
        // dg_st=<float> with ds_st but no dh_st
78
        (Some(_), None, None, None, Some(_)) => Err(PyValueError::new_err(
1✔
79
            "dg_st as a scalar cannot be combined with ds_st; \
1✔
80
             use dg_st=(value, temperature_C) tuple form, or dh_st + ds_st",
1✔
81
        )),
1✔
82
        // dh_st without ds_st, or vice versa
83
        (None, None, None, Some(_), None) | (None, None, None, None, Some(_)) => Err(
2✔
84
            PyValueError::new_err("dh_st and ds_st must both be specified"),
2✔
85
        ),
2✔
86
        // Nothing specified
87
        (None, None, None, None, None) => Err(PyValueError::new_err(
1✔
88
            "must specify energy: dg_st, delta_g_over_rt, or (dh_st and ds_st)",
1✔
89
        )),
1✔
90
        // Conflicting specifications
91
        _ => Err(PyValueError::new_err(
1✔
92
            "specify only one of: dg_st, delta_g_over_rt, or (dh_st and ds_st)",
1✔
93
        )),
1✔
94
    }
95
}
1,187✔
96

97
// ---------------------------------------------------------------------------
98
// PySolverOptions — wrapper around the Rust SolverOptions struct
99
// ---------------------------------------------------------------------------
100

101
/// Solver configuration: tolerances, iteration caps, trust-region
102
/// parameters, and numerical clamps.
103
///
104
/// All fields have sensible defaults matching the built-in solver
105
/// behavior. Construct with keyword arguments and pass to
106
/// ``System(options=...)``.
107
///
108
/// Parameters
109
/// ----------
110
/// max_iterations : int, optional
111
///     Maximum outer Newton iterations (default: 1000).
112
/// gradient_abs_tol, gradient_rel_tol : float, optional
113
///     Full-convergence gradient tolerances (default: 1e-22, 1e-7).
114
/// relaxed_gradient_abs_tol, relaxed_gradient_rel_tol : float, optional
115
///     Relaxed tolerances used by the stagnation recovery path
116
///     (default: 1e-14, 1e-4).
117
/// stagnation_threshold : int, optional
118
///     Consecutive non-reducing iterations before stagnation recovery
119
///     fires (default: 3).
120
/// initial_trust_region_radius, max_trust_region_radius : float, optional
121
///     Trust-region radius bounds (default: 1.0, 1e10).
122
/// step_accept_threshold : float, optional
123
///     Minimum ρ for a step to be accepted (default: 1e-4).
124
/// trust_region_shrink_rho, trust_region_grow_rho : float, optional
125
///     ρ thresholds for shrinking / growing the trust region
126
///     (default: 0.25, 0.75).
127
/// trust_region_shrink_scale, trust_region_grow_scale : float, optional
128
///     Multipliers applied to δ on shrink / grow (default: 0.25, 2.0).
129
/// log_c_clamp : float, optional
130
///     Upper bound on ``log_q + Aᵀλ`` before exp() (default: 700.0).
131
/// log_q_clamp : float or None, optional
132
///     Optional upper bound on ``log_q = -ΔG/RT`` applied at
133
///     construction time (default: None).
134
/// objective : str, optional
135
///     Trust-region objective surface: ``"linear"`` (default) minimizes
136
///     the convex Dirks dual ``f(λ)`` directly; ``"log"`` minimizes
137
///     ``g(λ) = ln f(λ)``. The log path can converge in many fewer
138
///     iterations on stiff systems (very strong binding, asymmetric
139
///     ``c⁰``) but is non-convex; equiconc handles the resulting
140
///     indefinite Hessians via on-the-fly modified-Cholesky
141
///     regularization. The mass-conservation convergence test is
142
///     identical for both. Default: ``"linear"``.
UNCOV
143
#[pyclass(name = "SolverOptions", module = "equiconc", from_py_object)]
×
144
#[derive(Clone)]
145
struct PySolverOptions {
146
    inner: SolverOptions,
147
}
148

149
#[pymethods]
×
150
impl PySolverOptions {
151
    #[new]
152
    #[pyo3(signature = (
153
        *,
154
        max_iterations=None,
155
        gradient_abs_tol=None,
156
        gradient_rel_tol=None,
157
        relaxed_gradient_abs_tol=None,
158
        relaxed_gradient_rel_tol=None,
159
        stagnation_threshold=None,
160
        initial_trust_region_radius=None,
161
        max_trust_region_radius=None,
162
        step_accept_threshold=None,
163
        trust_region_shrink_rho=None,
164
        trust_region_grow_rho=None,
165
        trust_region_shrink_scale=None,
166
        trust_region_grow_scale=None,
167
        log_c_clamp=None,
168
        log_q_clamp=None,
169
        objective=None,
170
    ))]
171
    #[allow(clippy::too_many_arguments)]
172
    fn new(
15✔
173
        max_iterations: Option<usize>,
15✔
174
        gradient_abs_tol: Option<f64>,
15✔
175
        gradient_rel_tol: Option<f64>,
15✔
176
        relaxed_gradient_abs_tol: Option<f64>,
15✔
177
        relaxed_gradient_rel_tol: Option<f64>,
15✔
178
        stagnation_threshold: Option<u32>,
15✔
179
        initial_trust_region_radius: Option<f64>,
15✔
180
        max_trust_region_radius: Option<f64>,
15✔
181
        step_accept_threshold: Option<f64>,
15✔
182
        trust_region_shrink_rho: Option<f64>,
15✔
183
        trust_region_grow_rho: Option<f64>,
15✔
184
        trust_region_shrink_scale: Option<f64>,
15✔
185
        trust_region_grow_scale: Option<f64>,
15✔
186
        log_c_clamp: Option<f64>,
15✔
187
        log_q_clamp: Option<f64>,
15✔
188
        objective: Option<&str>,
15✔
189
    ) -> PyResult<Self> {
15✔
190
        let mut opts = SolverOptions::default();
15✔
191
        if let Some(v) = max_iterations { opts.max_iterations = v; }
15✔
192
        if let Some(v) = gradient_abs_tol { opts.gradient_abs_tol = v; }
15✔
193
        if let Some(v) = gradient_rel_tol { opts.gradient_rel_tol = v; }
15✔
194
        if let Some(v) = relaxed_gradient_abs_tol { opts.relaxed_gradient_abs_tol = v; }
15✔
195
        if let Some(v) = relaxed_gradient_rel_tol { opts.relaxed_gradient_rel_tol = v; }
15✔
196
        if let Some(v) = stagnation_threshold { opts.stagnation_threshold = v; }
15✔
197
        if let Some(v) = initial_trust_region_radius { opts.initial_trust_region_radius = v; }
15✔
198
        if let Some(v) = max_trust_region_radius { opts.max_trust_region_radius = v; }
15✔
199
        if let Some(v) = step_accept_threshold { opts.step_accept_threshold = v; }
15✔
200
        if let Some(v) = trust_region_shrink_rho { opts.trust_region_shrink_rho = v; }
15✔
201
        if let Some(v) = trust_region_grow_rho { opts.trust_region_grow_rho = v; }
15✔
202
        if let Some(v) = trust_region_shrink_scale { opts.trust_region_shrink_scale = v; }
15✔
203
        if let Some(v) = trust_region_grow_scale { opts.trust_region_grow_scale = v; }
15✔
204
        if let Some(v) = log_c_clamp { opts.log_c_clamp = v; }
15✔
205
        // log_q_clamp: None from Python means "unset"; we store None
206
        // internally too since Python cannot distinguish "not passed"
207
        // from "passed as None" in this pyo3 form.
208
        opts.log_q_clamp = log_q_clamp;
15✔
209
        if let Some(s) = objective {
15✔
210
            opts.objective = match s {
5✔
211
                "linear" => SolverObjective::Linear,
5✔
212
                "log" => SolverObjective::Log,
5✔
213
                other => {
1✔
214
                    return Err(PyValueError::new_err(format!(
1✔
215
                        "objective must be \"linear\" or \"log\", got {other:?}"
1✔
216
                    )));
1✔
217
                }
218
            };
219
        }
10✔
220
        opts.validate().map_err(map_err)?;
14✔
221
        Ok(PySolverOptions { inner: opts })
11✔
222
    }
15✔
223

224
    // Getters for every field (so Python users can inspect).
225
    #[getter] fn max_iterations(&self) -> usize { self.inner.max_iterations }
2✔
226
    #[getter] fn gradient_abs_tol(&self) -> f64 { self.inner.gradient_abs_tol }
×
227
    #[getter] fn gradient_rel_tol(&self) -> f64 { self.inner.gradient_rel_tol }
2✔
228
    #[getter] fn relaxed_gradient_abs_tol(&self) -> f64 { self.inner.relaxed_gradient_abs_tol }
×
229
    #[getter] fn relaxed_gradient_rel_tol(&self) -> f64 { self.inner.relaxed_gradient_rel_tol }
×
230
    #[getter] fn stagnation_threshold(&self) -> u32 { self.inner.stagnation_threshold }
×
231
    #[getter] fn initial_trust_region_radius(&self) -> f64 { self.inner.initial_trust_region_radius }
×
232
    #[getter] fn max_trust_region_radius(&self) -> f64 { self.inner.max_trust_region_radius }
×
233
    #[getter] fn step_accept_threshold(&self) -> f64 { self.inner.step_accept_threshold }
×
234
    #[getter] fn trust_region_shrink_rho(&self) -> f64 { self.inner.trust_region_shrink_rho }
×
235
    #[getter] fn trust_region_grow_rho(&self) -> f64 { self.inner.trust_region_grow_rho }
×
236
    #[getter] fn trust_region_shrink_scale(&self) -> f64 { self.inner.trust_region_shrink_scale }
×
237
    #[getter] fn trust_region_grow_scale(&self) -> f64 { self.inner.trust_region_grow_scale }
×
238
    #[getter] fn log_c_clamp(&self) -> f64 { self.inner.log_c_clamp }
×
239
    #[getter] fn log_q_clamp(&self) -> Option<f64> { self.inner.log_q_clamp }
1✔
240
    #[getter] fn objective(&self) -> &'static str {
2✔
241
        match self.inner.objective {
2✔
242
            SolverObjective::Linear => "linear",
1✔
243
            SolverObjective::Log => "log",
1✔
244
        }
245
    }
2✔
246

247
    fn __repr__(&self) -> String {
1✔
248
        format!("SolverOptions({:?})", self.inner)
1✔
249
    }
1✔
250
}
251

252
// ---------------------------------------------------------------------------
253
// PySystem — deferred-construction wrapper
254
// ---------------------------------------------------------------------------
255

256
/// Equilibrium concentration solver for nucleic acid strand systems.
257
///
258
/// Build a system by chaining ``monomer()`` and ``complex()`` calls,
259
/// then call ``equilibrium()`` to solve for equilibrium concentrations.
260
///
261
/// Parameters
262
/// ----------
263
/// temperature_C : float, optional
264
///     Temperature in degrees Celsius (default: 25.0).
265
/// temperature_K : float, optional
266
///     Temperature in kelvin. Cannot be combined with ``temperature_C``.
267
///
268
/// Examples
269
/// --------
270
/// >>> import equiconc
271
/// >>> eq = (equiconc.System()
272
/// ...     .monomer("A", 100e-9)
273
/// ...     .monomer("B", 100e-9)
274
/// ...     .complex("AB", [("A", 1), ("B", 1)], dg_st=-10.0)
275
/// ...     .equilibrium())
276
/// >>> eq["AB"] > 0
277
/// True
278
#[pyclass(name = "System", module = "equiconc")]
279
struct PySystem {
280
    temperature_k: Option<f64>,
281
    monomers: Vec<(String, f64)>,
282
    complexes: Vec<(String, Vec<(String, usize)>, EnergySpec)>,
283
    options: SolverOptions,
284
}
285

286
#[pymethods]
×
287
#[allow(non_snake_case)]
288
impl PySystem {
289
    #[new]
290
    #[pyo3(signature = (*, temperature_C=None, temperature_K=None, options=None))]
291
    fn new(
710✔
292
        temperature_C: Option<f64>,
710✔
293
        temperature_K: Option<f64>,
710✔
294
        options: Option<PySolverOptions>,
710✔
295
    ) -> PyResult<Self> {
710✔
296
        let temp_k = match (temperature_C, temperature_K) {
710✔
297
            (None, None) => None,
39✔
298
            (Some(c), None) => Some(c + 273.15),
8✔
299
            (None, Some(k)) => Some(k),
662✔
300
            (Some(_), Some(_)) => {
301
                return Err(PyValueError::new_err(
1✔
302
                    "cannot specify both temperature_C and temperature_K",
1✔
303
                ))
1✔
304
            }
305
        };
306
        let options = options.map(|o| o.inner).unwrap_or_default();
709✔
307
        Ok(PySystem {
709✔
308
            temperature_k: temp_k,
709✔
309
            monomers: Vec::new(),
709✔
310
            complexes: Vec::new(),
709✔
311
            options,
709✔
312
        })
709✔
313
    }
710✔
314

315
    /// Add a monomer species with a given total concentration.
316
    ///
317
    /// Parameters
318
    /// ----------
319
    /// name : str
320
    ///     Name of the monomer species. Must be unique and non-empty.
321
    /// total_concentration : float
322
    ///     Total concentration in molar (mol/L). Must be finite and
323
    ///     positive.
324
    ///
325
    /// Returns
326
    /// -------
327
    /// System
328
    ///     The same system instance, for method chaining.
329
    fn monomer(slf: Py<Self>, py: Python<'_>, name: &str, total_concentration: f64) -> Py<Self> {
1,570✔
330
        let mut inner = slf.borrow_mut(py);
1,570✔
331
        inner.monomers.push((name.to_string(), total_concentration));
1,570✔
332
        drop(inner);
1,570✔
333
        slf
1,570✔
334
    }
1,570✔
335

336
    /// Add a complex species with a given stoichiometry and energy.
337
    ///
338
    /// Exactly one energy specification must be provided:
339
    ///
340
    /// - ``dg_st``: standard free energy of formation in kcal/mol
341
    /// - ``dg_st=(value, temperature_C)`` + ``ds_st``: ΔG at a
342
    ///   known temperature plus ΔS; ΔH is derived as
343
    ///   ΔH = ΔG + T·ΔS and ΔG at the system temperature is
344
    ///   computed as ΔH − T_sys·ΔS
345
    /// - ``delta_g_over_rt``: dimensionless ΔG/RT (no temperature needed)
346
    /// - ``dh_st`` + ``ds_st``: enthalpy (kcal/mol) and entropy
347
    ///   (kcal/(mol·K)); ΔG is computed as ΔH − TΔS at solve time
348
    ///
349
    /// Parameters
350
    /// ----------
351
    /// name : str
352
    ///     Name of the complex. Must be unique across all species.
353
    /// composition : list of (str, int)
354
    ///     Monomer composition as ``[(monomer_name, count), ...]``.
355
    ///     Each monomer must have been previously added and count
356
    ///     must be >= 1.
357
    /// dg_st : float or (float, float), optional
358
    ///     Standard free energy of formation in kcal/mol at 1 M
359
    ///     standard state. Either a scalar (must be finite), or a
360
    ///     tuple ``(dg_st, temperature_C)`` giving ΔG at a known
361
    ///     temperature in °C; the latter form requires ``ds_st``.
362
    /// delta_g_over_rt : float, optional
363
    ///     Dimensionless free energy ΔG/(RT). When all complexes use
364
    ///     this form, temperature is not required.
365
    /// dh_st : float, optional
366
    ///     Enthalpy of formation in kcal/mol. Must be paired with
367
    ///     ``ds_st``.
368
    /// ds_st : float, optional
369
    ///     Entropy of formation in kcal/(mol·K). Must be paired with
370
    ///     ``dh_st``, or with the tuple form of ``dg_st``.
371
    ///
372
    /// Returns
373
    /// -------
374
    /// System
375
    ///     The same system instance, for method chaining.
376
    #[pyo3(signature = (name, composition, *, dg_st=None, delta_g_over_rt=None, dh_st=None, ds_st=None))]
377
    fn complex(
1,187✔
378
        slf: Py<Self>,
1,187✔
379
        py: Python<'_>,
1,187✔
380
        name: &str,
1,187✔
381
        composition: Vec<(String, usize)>,
1,187✔
382
        dg_st: Option<DeltaGInput>,
1,187✔
383
        delta_g_over_rt: Option<f64>,
1,187✔
384
        dh_st: Option<f64>,
1,187✔
385
        ds_st: Option<f64>,
1,187✔
386
    ) -> PyResult<Py<Self>> {
1,187✔
387
        let energy = resolve_energy_spec(dg_st, delta_g_over_rt, dh_st, ds_st)?;
1,187✔
388
        let mut inner = slf.borrow_mut(py);
1,181✔
389
        inner.complexes.push((name.to_string(), composition, energy));
1,181✔
390
        drop(inner);
1,181✔
391
        Ok(slf)
1,181✔
392
    }
1,187✔
393

394
    /// Solve for equilibrium concentrations.
395
    ///
396
    /// Returns
397
    /// -------
398
    /// Equilibrium
399
    ///     The result containing concentrations of all species.
400
    ///
401
    /// Raises
402
    /// ------
403
    /// ValueError
404
    ///     If the system specification is invalid (no monomers, unknown
405
    ///     monomers in complexes, invalid concentrations, etc.).
406
    /// RuntimeError
407
    ///     If the solver fails to converge.
408
    fn equilibrium(&self) -> PyResult<PyEquilibrium> {
653✔
409
        // Default to 25 °C if no temperature was given.
410
        let temp_k = self.temperature_k.unwrap_or(298.15);
653✔
411

412
        let mut builder = crate::SystemBuilder::new().temperature(temp_k);
653✔
413

414
        for (name, conc) in &self.monomers {
1,449✔
415
            builder = builder.monomer(name, *conc);
1,449✔
416
        }
1,449✔
417

418
        let rt = crate::R * temp_k;
653✔
419
        for (name, comp, energy) in &self.complexes {
1,133✔
420
            let dg = match energy {
1,133✔
421
                EnergySpec::DeltaG(dg) => *dg,
1,124✔
422
                EnergySpec::DeltaGOverRT(dgrt) => *dgrt * rt,
3✔
423
                EnergySpec::DeltaHS { delta_h, delta_s } => *delta_h - temp_k * *delta_s,
6✔
424
            };
425
            let comp_refs: Vec<(&str, usize)> =
1,133✔
426
                comp.iter().map(|(n, c)| (n.as_str(), *c)).collect();
2,095✔
427
            builder = builder.complex(name, &comp_refs, dg);
1,133✔
428
        }
429

430
        let mut sys = builder.options(self.options.clone()).build().map_err(map_err)?;
653✔
431
        let n_mon = sys.n_monomers();
638✔
432
        let n_species = sys.n_species();
638✔
433
        let monomer_names: Vec<String> = (0..n_mon)
638✔
434
            .map(|i| sys.monomer_name(i).unwrap_or_default().to_string())
1,429✔
435
            .collect();
638✔
436
        let complex_names: Vec<String> = (n_mon..n_species)
638✔
437
            .map(|i| sys.species_name(i).unwrap_or_default().to_string())
1,124✔
438
            .collect();
638✔
439

440
        let eq = sys.solve().map_err(map_err)?;
638✔
441
        Ok(PyEquilibrium::from_equilibrium(
637✔
442
            monomer_names,
637✔
443
            complex_names,
637✔
444
            &eq,
637✔
445
        ))
637✔
446
    }
653✔
447

448
    fn __repr__(&self) -> String {
1✔
449
        let temp_c = self.temperature_k.unwrap_or(298.15) - 273.15;
1✔
450
        format!(
1✔
451
            "System(temperature={temp_c:.2}°C, monomers={}, complexes={})",
452
            self.monomers.len(),
1✔
453
            self.complexes.len()
1✔
454
        )
455
    }
1✔
456
}
457

458
// ---------------------------------------------------------------------------
459
// PyEquilibrium — wraps the result with Pythonic access
460
// ---------------------------------------------------------------------------
461

462
/// Result of an equilibrium concentration calculation.
463
///
464
/// Supports dict-like access: ``eq["A"]``, ``"A" in eq``, ``len(eq)``,
465
/// and iteration over species names with ``for name in eq``.
466
///
467
/// Attributes
468
/// ----------
469
/// monomer_names : list of str
470
///     Monomer species names, in addition order.
471
/// complex_names : list of str
472
///     Complex species names, in addition order.
473
/// free_monomer_concentrations : list of float
474
///     Free monomer concentrations in molar, in addition order.
475
/// complex_concentrations : list of float
476
///     Complex concentrations in molar, in addition order.
477
/// converged_fully : bool
478
///     Whether the solver achieved full convergence. ``False`` if the
479
///     solver accepted results at a relaxed tolerance.
480
#[pyclass(name = "Equilibrium", module = "equiconc")]
481
struct PyEquilibrium {
482
    concentrations: HashMap<String, f64>,
483
    monomer_names: Vec<String>,
484
    complex_names: Vec<String>,
485
    free_monomer_concentrations: Vec<f64>,
486
    complex_concentrations: Vec<f64>,
487
    converged_fully: bool,
488
}
489

490
impl PyEquilibrium {
491
    fn from_equilibrium(
637✔
492
        monomer_names: Vec<String>,
637✔
493
        complex_names: Vec<String>,
637✔
494
        eq: &Equilibrium<'_>,
637✔
495
    ) -> Self {
637✔
496
        let free_monomer_concentrations: Vec<f64> = eq.free_monomers().to_vec();
637✔
497
        let complex_concentrations: Vec<f64> = eq.complexes().to_vec();
637✔
498

499
        let mut concentrations =
637✔
500
            HashMap::with_capacity(monomer_names.len() + complex_names.len());
637✔
501
        for (name, &conc) in monomer_names.iter().zip(&free_monomer_concentrations) {
1,427✔
502
            concentrations.insert(name.clone(), conc);
1,427✔
503
        }
1,427✔
504
        for (name, &conc) in complex_names.iter().zip(&complex_concentrations) {
1,123✔
505
            concentrations.insert(name.clone(), conc);
1,123✔
506
        }
1,123✔
507

508
        PyEquilibrium {
637✔
509
            concentrations,
637✔
510
            monomer_names,
637✔
511
            complex_names,
637✔
512
            free_monomer_concentrations,
637✔
513
            complex_concentrations,
637✔
514
            converged_fully: eq.converged_fully(),
637✔
515
        }
637✔
516
    }
637✔
517

518
    /// All species names in deterministic order (monomers first, then complexes).
519
    fn ordered_names(&self) -> impl Iterator<Item = &String> {
208✔
520
        self.monomer_names.iter().chain(self.complex_names.iter())
208✔
521
    }
208✔
522
}
523

524
#[pymethods]
×
525
impl PyEquilibrium {
526
    /// Look up a concentration by species name.
527
    ///
528
    /// Parameters
529
    /// ----------
530
    /// name : str
531
    ///     Species name (monomer or complex).
532
    ///
533
    /// Returns
534
    /// -------
535
    /// float
536
    ///     Concentration in molar.
537
    ///
538
    /// Raises
539
    /// ------
540
    /// KeyError
541
    ///     If the species name is not found.
542
    fn concentration(&self, name: &str) -> PyResult<f64> {
501✔
543
        self.concentrations
501✔
544
            .get(name)
501✔
545
            .copied()
501✔
546
            .ok_or_else(|| PyKeyError::new_err(name.to_string()))
501✔
547
    }
501✔
548

549
    /// Dict-like access: `eq["AB"]`. Raises `KeyError` if unknown.
550
    fn __getitem__(&self, name: &str) -> PyResult<f64> {
2,590✔
551
        self.concentrations
2,590✔
552
            .get(name)
2,590✔
553
            .copied()
2,590✔
554
            .ok_or_else(|| PyKeyError::new_err(name.to_string()))
2,590✔
555
    }
2,590✔
556

557
    /// Supports `"AB" in eq`.
558
    fn __contains__(&self, name: &str) -> bool {
501✔
559
        self.concentrations.contains_key(name)
501✔
560
    }
501✔
561

562
    /// Number of species.
563
    fn __len__(&self) -> usize {
102✔
564
        self.concentrations.len()
102✔
565
    }
102✔
566

567
    /// Convert to a dict mapping species names to concentrations.
568
    ///
569
    /// Returns
570
    /// -------
571
    /// dict
572
    ///     ``{name: concentration}`` in deterministic order (monomers
573
    ///     first, then complexes, in addition order).
574
    fn to_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
202✔
575
        let dict = PyDict::new(py);
202✔
576
        for name in self.ordered_names() {
949✔
577
            dict.set_item(name, self.concentrations[name])?;
949✔
578
        }
579
        Ok(dict)
202✔
580
    }
202✔
581

582
    /// Species names in deterministic order.
583
    ///
584
    /// Returns
585
    /// -------
586
    /// list of str
587
    ///     Names in order: monomers first, then complexes.
588
    fn keys(&self) -> Vec<String> {
2✔
589
        self.ordered_names().cloned().collect()
2✔
590
    }
2✔
591

592
    /// Concentrations in deterministic order.
593
    ///
594
    /// Returns
595
    /// -------
596
    /// list of float
597
    ///     Concentrations in molar, monomers first, then complexes.
598
    fn values(&self) -> Vec<f64> {
2✔
599
        self.ordered_names().map(|n| self.concentrations[n]).collect()
6✔
600
    }
2✔
601

602
    /// ``(name, concentration)`` pairs in deterministic order.
603
    ///
604
    /// Returns
605
    /// -------
606
    /// list of (str, float)
607
    ///     Pairs in order: monomers first, then complexes.
608
    fn items(&self) -> Vec<(String, f64)> {
1✔
609
        self.ordered_names()
1✔
610
            .map(|n| (n.clone(), self.concentrations[n]))
3✔
611
            .collect()
1✔
612
    }
1✔
613

614
    /// Supports `for name in eq:`.
615
    fn __iter__(slf: PyRef<'_, Self>) -> PyResult<Py<EquilibriumKeyIter>> {
1✔
616
        let py = slf.py();
1✔
617
        Py::new(py, EquilibriumKeyIter {
1✔
618
            keys: slf.keys(),
1✔
619
            index: 0,
1✔
620
        })
1✔
621
    }
1✔
622

623
    /// Free monomer concentrations as a list.
624
    #[getter]
625
    fn free_monomer_concentrations(&self) -> Vec<f64> {
1✔
626
        self.free_monomer_concentrations.clone()
1✔
627
    }
1✔
628

629
    /// Complex concentrations as a list.
630
    #[getter]
631
    fn complex_concentrations(&self) -> Vec<f64> {
2✔
632
        self.complex_concentrations.clone()
2✔
633
    }
2✔
634

635
    /// Monomer names as a list.
636
    #[getter]
637
    fn monomer_names(&self) -> Vec<String> {
1✔
638
        self.monomer_names.clone()
1✔
639
    }
1✔
640

641
    /// Complex names as a list.
642
    #[getter]
643
    fn complex_names(&self) -> Vec<String> {
1✔
644
        self.complex_names.clone()
1✔
645
    }
1✔
646

647
    /// Whether the solver achieved full convergence.
648
    ///
649
    /// `False` if the solver accepted the result at a relaxed tolerance
650
    /// due to stagnation at f64 precision limits.
651
    #[getter]
652
    fn converged_fully(&self) -> bool {
1✔
653
        self.converged_fully
1✔
654
    }
1✔
655

656
    fn __repr__(&self) -> String {
1✔
657
        let entries: Vec<String> = self
1✔
658
            .ordered_names()
1✔
659
            .map(|name| {
1✔
660
                let conc = self.concentrations[name];
1✔
661
                format!("  {name}: {conc:.6e} M")
1✔
662
            })
1✔
663
            .collect();
1✔
664
        format!("Equilibrium(\n{}\n)", entries.join("\n"))
1✔
665
    }
1✔
666
}
667

668
// ---------------------------------------------------------------------------
669
// Iterator for `for name in eq:`
670
// ---------------------------------------------------------------------------
671

672
#[pyclass]
673
struct EquilibriumKeyIter {
674
    keys: Vec<String>,
675
    index: usize,
676
}
677

678
#[pymethods]
×
679
impl EquilibriumKeyIter {
680
    fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
×
681
        slf
×
682
    }
×
683

684
    fn __next__(&mut self) -> Option<String> {
4✔
685
        if self.index < self.keys.len() {
4✔
686
            let key = self.keys[self.index].clone();
3✔
687
            self.index += 1;
3✔
688
            Some(key)
3✔
689
        } else {
690
            None
1✔
691
        }
692
    }
4✔
693
}
694

695
// ---------------------------------------------------------------------------
696
// Module definition
697
// ---------------------------------------------------------------------------
698

699
#[pymodule]
700
fn equiconc(m: &Bound<'_, PyModule>) -> PyResult<()> {
1✔
701
    m.add_class::<PySystem>()?;
1✔
702
    m.add_class::<PyEquilibrium>()?;
1✔
703
    m.add_class::<PySolverOptions>()?;
1✔
704
    Ok(())
1✔
705
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc