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

cgevans / equiconc / 24932686026

25 Apr 2026 02:06PM UTC coverage: 92.435% (-0.8%) from 93.28%
24932686026

push

github

cgevans
Clean up clippy lints and wire clippy into the lint CI gate

`cargo clippy --all-features --all-targets -- -D warnings` now passes
and is enforced in the existing `lint` job alongside fmt and rustdoc.

Mechanical rewrites:
- derive `Default` on `SolverObjective` (drop the manual impl).
- `field_reassign_with_default` in `SolverOptions::validate` tests:
  collapse `let mut o = X::default(); o.f = v` into struct literals.
- `iter_cloned_collect` in proptest specs: `iter().copied().collect()`
  → `to_vec()`.
- `neg_cmp_op_on_partial_ord` in `evaluate_into` / `evaluate_log_into`
  guards: rewrite `!(f > 0.0) || !f.is_finite()` as
  `f <= 0.0 || !f.is_finite()`. NaN-equivalent (rejected via the
  existing `!is_finite` clause), clippy-clean.
- `collapsible_if` in coffee_cli_compat: nested `if let`s → Rust 2024
  let-chain.
- `needless_range_loop` in vs_coffee_large bench: `for i in 0..n` →
  `for (i, row) in rows.iter().take(n).enumerate()`.
- `doc_lazy_continuation` / `doc_overindented_list_items` in benches:
  reflow doc comments so a `+` mid-sentence isn't read as a markdown
  list marker, and drop excess list indentation.

Type aliases (real readability win, not just clippy-silencing):
- `ComplexSpec = (String, Vec<(String, usize)>, f64)` for `SystemBuilder`
  and the proptest specs.
- `PyComplexSpec = (String, Vec<(String, usize)>, EnergySpec)` for
  `PySystem`. Aliases must precede the doc-commented item they replace
  so the surrounding docstring stays attached to its struct.

`#[allow(...)]` with one-line rationale where the lint can't tell the
code is intentional:
- `clippy::neg_cmp_op_on_partial_ord` on `SolverOptions::validate`:
  the `!(shrink_rho < grow_rho)` form is NaN-safe rejection; rewriting
  as `shrink >= grow` would let NaN through and the rho fields aren't
  separately finite-checked.
- `clippy::too_many_arguments` on `evaluate_into` (9 args) and
  `evaluate_log_into` (10 args): hot-path inner functions taking
  pre-allocated buffer re... (continued)

32 of 32 new or added lines in 1 file covered. (100.0%)

162 existing lines in 2 files now uncovered.

2505 of 2710 relevant lines covered (92.44%)

6024.77 hits per line

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

85.6
/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"``.
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 {
15✔
192
            opts.max_iterations = v;
4✔
193
        }
11✔
194
        if let Some(v) = gradient_abs_tol {
15✔
UNCOV
195
            opts.gradient_abs_tol = v;
×
196
        }
15✔
197
        if let Some(v) = gradient_rel_tol {
15✔
198
            opts.gradient_rel_tol = v;
2✔
199
        }
13✔
200
        if let Some(v) = relaxed_gradient_abs_tol {
15✔
UNCOV
201
            opts.relaxed_gradient_abs_tol = v;
×
202
        }
15✔
203
        if let Some(v) = relaxed_gradient_rel_tol {
15✔
UNCOV
204
            opts.relaxed_gradient_rel_tol = v;
×
205
        }
15✔
206
        if let Some(v) = stagnation_threshold {
15✔
UNCOV
207
            opts.stagnation_threshold = v;
×
208
        }
15✔
209
        if let Some(v) = initial_trust_region_radius {
15✔
UNCOV
210
            opts.initial_trust_region_radius = v;
×
211
        }
15✔
212
        if let Some(v) = max_trust_region_radius {
15✔
UNCOV
213
            opts.max_trust_region_radius = v;
×
214
        }
15✔
215
        if let Some(v) = step_accept_threshold {
15✔
UNCOV
216
            opts.step_accept_threshold = v;
×
217
        }
15✔
218
        if let Some(v) = trust_region_shrink_rho {
15✔
219
            opts.trust_region_shrink_rho = v;
1✔
220
        }
14✔
221
        if let Some(v) = trust_region_grow_rho {
15✔
222
            opts.trust_region_grow_rho = v;
1✔
223
        }
14✔
224
        if let Some(v) = trust_region_shrink_scale {
15✔
UNCOV
225
            opts.trust_region_shrink_scale = v;
×
226
        }
15✔
227
        if let Some(v) = trust_region_grow_scale {
15✔
228
            opts.trust_region_grow_scale = v;
×
229
        }
15✔
230
        if let Some(v) = log_c_clamp {
15✔
231
            opts.log_c_clamp = v;
×
232
        }
15✔
233
        // log_q_clamp: None from Python means "unset"; we store None
234
        // internally too since Python cannot distinguish "not passed"
235
        // from "passed as None" in this pyo3 form.
236
        opts.log_q_clamp = log_q_clamp;
15✔
237
        if let Some(s) = objective {
15✔
238
            opts.objective = match s {
5✔
239
                "linear" => SolverObjective::Linear,
5✔
240
                "log" => SolverObjective::Log,
5✔
241
                other => {
1✔
242
                    return Err(PyValueError::new_err(format!(
1✔
243
                        "objective must be \"linear\" or \"log\", got {other:?}"
1✔
244
                    )));
1✔
245
                }
246
            };
247
        }
10✔
248
        opts.validate().map_err(map_err)?;
14✔
249
        Ok(PySolverOptions { inner: opts })
11✔
250
    }
15✔
251

252
    // Getters for every field (so Python users can inspect).
253
    #[getter]
254
    fn max_iterations(&self) -> usize {
2✔
255
        self.inner.max_iterations
2✔
256
    }
2✔
257
    #[getter]
UNCOV
258
    fn gradient_abs_tol(&self) -> f64 {
×
UNCOV
259
        self.inner.gradient_abs_tol
×
UNCOV
260
    }
×
261
    #[getter]
262
    fn gradient_rel_tol(&self) -> f64 {
2✔
263
        self.inner.gradient_rel_tol
2✔
264
    }
2✔
265
    #[getter]
UNCOV
266
    fn relaxed_gradient_abs_tol(&self) -> f64 {
×
UNCOV
267
        self.inner.relaxed_gradient_abs_tol
×
UNCOV
268
    }
×
269
    #[getter]
UNCOV
270
    fn relaxed_gradient_rel_tol(&self) -> f64 {
×
UNCOV
271
        self.inner.relaxed_gradient_rel_tol
×
UNCOV
272
    }
×
273
    #[getter]
UNCOV
274
    fn stagnation_threshold(&self) -> u32 {
×
UNCOV
275
        self.inner.stagnation_threshold
×
UNCOV
276
    }
×
277
    #[getter]
UNCOV
278
    fn initial_trust_region_radius(&self) -> f64 {
×
UNCOV
279
        self.inner.initial_trust_region_radius
×
UNCOV
280
    }
×
281
    #[getter]
UNCOV
282
    fn max_trust_region_radius(&self) -> f64 {
×
UNCOV
283
        self.inner.max_trust_region_radius
×
UNCOV
284
    }
×
285
    #[getter]
286
    fn step_accept_threshold(&self) -> f64 {
×
UNCOV
287
        self.inner.step_accept_threshold
×
UNCOV
288
    }
×
289
    #[getter]
UNCOV
290
    fn trust_region_shrink_rho(&self) -> f64 {
×
UNCOV
291
        self.inner.trust_region_shrink_rho
×
UNCOV
292
    }
×
293
    #[getter]
UNCOV
294
    fn trust_region_grow_rho(&self) -> f64 {
×
UNCOV
295
        self.inner.trust_region_grow_rho
×
UNCOV
296
    }
×
297
    #[getter]
UNCOV
298
    fn trust_region_shrink_scale(&self) -> f64 {
×
UNCOV
299
        self.inner.trust_region_shrink_scale
×
UNCOV
300
    }
×
301
    #[getter]
UNCOV
302
    fn trust_region_grow_scale(&self) -> f64 {
×
UNCOV
303
        self.inner.trust_region_grow_scale
×
UNCOV
304
    }
×
305
    #[getter]
UNCOV
306
    fn log_c_clamp(&self) -> f64 {
×
UNCOV
307
        self.inner.log_c_clamp
×
UNCOV
308
    }
×
309
    #[getter]
310
    fn log_q_clamp(&self) -> Option<f64> {
1✔
311
        self.inner.log_q_clamp
1✔
312
    }
1✔
313
    #[getter]
314
    fn objective(&self) -> &'static str {
2✔
315
        match self.inner.objective {
2✔
316
            SolverObjective::Linear => "linear",
1✔
317
            SolverObjective::Log => "log",
1✔
318
        }
319
    }
2✔
320

321
    fn __repr__(&self) -> String {
1✔
322
        format!("SolverOptions({:?})", self.inner)
1✔
323
    }
1✔
324
}
325

326
// ---------------------------------------------------------------------------
327
// PySystem — deferred-construction wrapper
328
// ---------------------------------------------------------------------------
329

330
/// Spec for one complex on the Python side: `(name, [(monomer_name, count), ...], EnergySpec)`.
331
type PyComplexSpec = (String, Vec<(String, usize)>, EnergySpec);
332

333
/// Equilibrium concentration solver for nucleic acid strand systems.
334
///
335
/// Build a system by chaining ``monomer()`` and ``complex()`` calls,
336
/// then call ``equilibrium()`` to solve for equilibrium concentrations.
337
///
338
/// Parameters
339
/// ----------
340
/// temperature_C : float, optional
341
///     Temperature in degrees Celsius (default: 25.0).
342
/// temperature_K : float, optional
343
///     Temperature in kelvin. Cannot be combined with ``temperature_C``.
344
///
345
/// Examples
346
/// --------
347
/// >>> import equiconc
348
/// >>> eq = (equiconc.System()
349
/// ...     .monomer("A", 100e-9)
350
/// ...     .monomer("B", 100e-9)
351
/// ...     .complex("AB", [("A", 1), ("B", 1)], dg_st=-10.0)
352
/// ...     .equilibrium())
353
/// >>> eq["AB"] > 0
354
/// True
355
#[pyclass(name = "System", module = "equiconc")]
356
struct PySystem {
357
    temperature_k: Option<f64>,
358
    monomers: Vec<(String, f64)>,
359
    complexes: Vec<PyComplexSpec>,
360
    options: SolverOptions,
361
}
362

UNCOV
363
#[pymethods]
×
364
#[allow(non_snake_case)]
365
impl PySystem {
366
    #[new]
367
    #[pyo3(signature = (*, temperature_C=None, temperature_K=None, options=None))]
368
    fn new(
710✔
369
        temperature_C: Option<f64>,
710✔
370
        temperature_K: Option<f64>,
710✔
371
        options: Option<PySolverOptions>,
710✔
372
    ) -> PyResult<Self> {
710✔
373
        let temp_k = match (temperature_C, temperature_K) {
710✔
374
            (None, None) => None,
39✔
375
            (Some(c), None) => Some(c + 273.15),
8✔
376
            (None, Some(k)) => Some(k),
662✔
377
            (Some(_), Some(_)) => {
378
                return Err(PyValueError::new_err(
1✔
379
                    "cannot specify both temperature_C and temperature_K",
1✔
380
                ));
1✔
381
            }
382
        };
383
        let options = options.map(|o| o.inner).unwrap_or_default();
709✔
384
        Ok(PySystem {
709✔
385
            temperature_k: temp_k,
709✔
386
            monomers: Vec::new(),
709✔
387
            complexes: Vec::new(),
709✔
388
            options,
709✔
389
        })
709✔
390
    }
710✔
391

392
    /// Add a monomer species with a given total concentration.
393
    ///
394
    /// Parameters
395
    /// ----------
396
    /// name : str
397
    ///     Name of the monomer species. Must be unique and non-empty.
398
    /// total_concentration : float
399
    ///     Total concentration in molar (mol/L). Must be finite and
400
    ///     positive.
401
    ///
402
    /// Returns
403
    /// -------
404
    /// System
405
    ///     The same system instance, for method chaining.
406
    fn monomer(slf: Py<Self>, py: Python<'_>, name: &str, total_concentration: f64) -> Py<Self> {
1,570✔
407
        let mut inner = slf.borrow_mut(py);
1,570✔
408
        inner.monomers.push((name.to_string(), total_concentration));
1,570✔
409
        drop(inner);
1,570✔
410
        slf
1,570✔
411
    }
1,570✔
412

413
    /// Add a complex species with a given stoichiometry and energy.
414
    ///
415
    /// Exactly one energy specification must be provided:
416
    ///
417
    /// - ``dg_st``: standard free energy of formation in kcal/mol
418
    /// - ``dg_st=(value, temperature_C)`` + ``ds_st``: ΔG at a
419
    ///   known temperature plus ΔS; ΔH is derived as
420
    ///   ΔH = ΔG + T·ΔS and ΔG at the system temperature is
421
    ///   computed as ΔH − T_sys·ΔS
422
    /// - ``delta_g_over_rt``: dimensionless ΔG/RT (no temperature needed)
423
    /// - ``dh_st`` + ``ds_st``: enthalpy (kcal/mol) and entropy
424
    ///   (kcal/(mol·K)); ΔG is computed as ΔH − TΔS at solve time
425
    ///
426
    /// Parameters
427
    /// ----------
428
    /// name : str
429
    ///     Name of the complex. Must be unique across all species.
430
    /// composition : list of (str, int)
431
    ///     Monomer composition as ``[(monomer_name, count), ...]``.
432
    ///     Each monomer must have been previously added and count
433
    ///     must be >= 1.
434
    /// dg_st : float or (float, float), optional
435
    ///     Standard free energy of formation in kcal/mol at 1 M
436
    ///     standard state. Either a scalar (must be finite), or a
437
    ///     tuple ``(dg_st, temperature_C)`` giving ΔG at a known
438
    ///     temperature in °C; the latter form requires ``ds_st``.
439
    /// delta_g_over_rt : float, optional
440
    ///     Dimensionless free energy ΔG/(RT). When all complexes use
441
    ///     this form, temperature is not required.
442
    /// dh_st : float, optional
443
    ///     Enthalpy of formation in kcal/mol. Must be paired with
444
    ///     ``ds_st``.
445
    /// ds_st : float, optional
446
    ///     Entropy of formation in kcal/(mol·K). Must be paired with
447
    ///     ``dh_st``, or with the tuple form of ``dg_st``.
448
    ///
449
    /// Returns
450
    /// -------
451
    /// System
452
    ///     The same system instance, for method chaining.
453
    // Signature is the Python-facing API; refactoring would change the binding.
454
    #[allow(clippy::too_many_arguments)]
455
    #[pyo3(signature = (name, composition, *, dg_st=None, delta_g_over_rt=None, dh_st=None, ds_st=None))]
456
    fn complex(
1,187✔
457
        slf: Py<Self>,
1,187✔
458
        py: Python<'_>,
1,187✔
459
        name: &str,
1,187✔
460
        composition: Vec<(String, usize)>,
1,187✔
461
        dg_st: Option<DeltaGInput>,
1,187✔
462
        delta_g_over_rt: Option<f64>,
1,187✔
463
        dh_st: Option<f64>,
1,187✔
464
        ds_st: Option<f64>,
1,187✔
465
    ) -> PyResult<Py<Self>> {
1,187✔
466
        let energy = resolve_energy_spec(dg_st, delta_g_over_rt, dh_st, ds_st)?;
1,187✔
467
        let mut inner = slf.borrow_mut(py);
1,181✔
468
        inner
1,181✔
469
            .complexes
1,181✔
470
            .push((name.to_string(), composition, energy));
1,181✔
471
        drop(inner);
1,181✔
472
        Ok(slf)
1,181✔
473
    }
1,187✔
474

475
    /// Solve for equilibrium concentrations.
476
    ///
477
    /// Returns
478
    /// -------
479
    /// Equilibrium
480
    ///     The result containing concentrations of all species.
481
    ///
482
    /// Raises
483
    /// ------
484
    /// ValueError
485
    ///     If the system specification is invalid (no monomers, unknown
486
    ///     monomers in complexes, invalid concentrations, etc.).
487
    /// RuntimeError
488
    ///     If the solver fails to converge.
489
    fn equilibrium(&self) -> PyResult<PyEquilibrium> {
653✔
490
        // Default to 25 °C if no temperature was given.
491
        let temp_k = self.temperature_k.unwrap_or(298.15);
653✔
492

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

495
        for (name, conc) in &self.monomers {
1,449✔
496
            builder = builder.monomer(name, *conc);
1,449✔
497
        }
1,449✔
498

499
        let rt = crate::R * temp_k;
653✔
500
        for (name, comp, energy) in &self.complexes {
1,133✔
501
            let dg = match energy {
1,133✔
502
                EnergySpec::DeltaG(dg) => *dg,
1,124✔
503
                EnergySpec::DeltaGOverRT(dgrt) => *dgrt * rt,
3✔
504
                EnergySpec::DeltaHS { delta_h, delta_s } => *delta_h - temp_k * *delta_s,
6✔
505
            };
506
            let comp_refs: Vec<(&str, usize)> =
1,133✔
507
                comp.iter().map(|(n, c)| (n.as_str(), *c)).collect();
2,095✔
508
            builder = builder.complex(name, &comp_refs, dg);
1,133✔
509
        }
510

511
        let mut sys = builder
653✔
512
            .options(self.options.clone())
653✔
513
            .build()
653✔
514
            .map_err(map_err)?;
653✔
515
        let n_mon = sys.n_monomers();
638✔
516
        let n_species = sys.n_species();
638✔
517
        let monomer_names: Vec<String> = (0..n_mon)
638✔
518
            .map(|i| sys.monomer_name(i).unwrap_or_default().to_string())
1,429✔
519
            .collect();
638✔
520
        let complex_names: Vec<String> = (n_mon..n_species)
638✔
521
            .map(|i| sys.species_name(i).unwrap_or_default().to_string())
1,124✔
522
            .collect();
638✔
523

524
        let eq = sys.solve().map_err(map_err)?;
638✔
525
        Ok(PyEquilibrium::from_equilibrium(
637✔
526
            monomer_names,
637✔
527
            complex_names,
637✔
528
            &eq,
637✔
529
        ))
637✔
530
    }
653✔
531

532
    fn __repr__(&self) -> String {
1✔
533
        let temp_c = self.temperature_k.unwrap_or(298.15) - 273.15;
1✔
534
        format!(
1✔
535
            "System(temperature={temp_c:.2}°C, monomers={}, complexes={})",
536
            self.monomers.len(),
1✔
537
            self.complexes.len()
1✔
538
        )
539
    }
1✔
540
}
541

542
// ---------------------------------------------------------------------------
543
// PyEquilibrium — wraps the result with Pythonic access
544
// ---------------------------------------------------------------------------
545

546
/// Result of an equilibrium concentration calculation.
547
///
548
/// Supports dict-like access: ``eq["A"]``, ``"A" in eq``, ``len(eq)``,
549
/// and iteration over species names with ``for name in eq``.
550
///
551
/// Attributes
552
/// ----------
553
/// monomer_names : list of str
554
///     Monomer species names, in addition order.
555
/// complex_names : list of str
556
///     Complex species names, in addition order.
557
/// free_monomer_concentrations : list of float
558
///     Free monomer concentrations in molar, in addition order.
559
/// complex_concentrations : list of float
560
///     Complex concentrations in molar, in addition order.
561
/// converged_fully : bool
562
///     Whether the solver achieved full convergence. ``False`` if the
563
///     solver accepted results at a relaxed tolerance.
564
#[pyclass(name = "Equilibrium", module = "equiconc")]
565
struct PyEquilibrium {
566
    concentrations: HashMap<String, f64>,
567
    monomer_names: Vec<String>,
568
    complex_names: Vec<String>,
569
    free_monomer_concentrations: Vec<f64>,
570
    complex_concentrations: Vec<f64>,
571
    converged_fully: bool,
572
}
573

574
impl PyEquilibrium {
575
    fn from_equilibrium(
637✔
576
        monomer_names: Vec<String>,
637✔
577
        complex_names: Vec<String>,
637✔
578
        eq: &Equilibrium<'_>,
637✔
579
    ) -> Self {
637✔
580
        let free_monomer_concentrations: Vec<f64> = eq.free_monomers().to_vec();
637✔
581
        let complex_concentrations: Vec<f64> = eq.complexes().to_vec();
637✔
582

583
        let mut concentrations = HashMap::with_capacity(monomer_names.len() + complex_names.len());
637✔
584
        for (name, &conc) in monomer_names.iter().zip(&free_monomer_concentrations) {
1,427✔
585
            concentrations.insert(name.clone(), conc);
1,427✔
586
        }
1,427✔
587
        for (name, &conc) in complex_names.iter().zip(&complex_concentrations) {
1,123✔
588
            concentrations.insert(name.clone(), conc);
1,123✔
589
        }
1,123✔
590

591
        PyEquilibrium {
637✔
592
            concentrations,
637✔
593
            monomer_names,
637✔
594
            complex_names,
637✔
595
            free_monomer_concentrations,
637✔
596
            complex_concentrations,
637✔
597
            converged_fully: eq.converged_fully(),
637✔
598
        }
637✔
599
    }
637✔
600

601
    /// All species names in deterministic order (monomers first, then complexes).
602
    fn ordered_names(&self) -> impl Iterator<Item = &String> {
208✔
603
        self.monomer_names.iter().chain(self.complex_names.iter())
208✔
604
    }
208✔
605
}
606

UNCOV
607
#[pymethods]
×
608
impl PyEquilibrium {
609
    /// Look up a concentration by species name.
610
    ///
611
    /// Parameters
612
    /// ----------
613
    /// name : str
614
    ///     Species name (monomer or complex).
615
    ///
616
    /// Returns
617
    /// -------
618
    /// float
619
    ///     Concentration in molar.
620
    ///
621
    /// Raises
622
    /// ------
623
    /// KeyError
624
    ///     If the species name is not found.
625
    fn concentration(&self, name: &str) -> PyResult<f64> {
501✔
626
        self.concentrations
501✔
627
            .get(name)
501✔
628
            .copied()
501✔
629
            .ok_or_else(|| PyKeyError::new_err(name.to_string()))
501✔
630
    }
501✔
631

632
    /// Dict-like access: `eq["AB"]`. Raises `KeyError` if unknown.
633
    fn __getitem__(&self, name: &str) -> PyResult<f64> {
2,590✔
634
        self.concentrations
2,590✔
635
            .get(name)
2,590✔
636
            .copied()
2,590✔
637
            .ok_or_else(|| PyKeyError::new_err(name.to_string()))
2,590✔
638
    }
2,590✔
639

640
    /// Supports `"AB" in eq`.
641
    fn __contains__(&self, name: &str) -> bool {
501✔
642
        self.concentrations.contains_key(name)
501✔
643
    }
501✔
644

645
    /// Number of species.
646
    fn __len__(&self) -> usize {
102✔
647
        self.concentrations.len()
102✔
648
    }
102✔
649

650
    /// Convert to a dict mapping species names to concentrations.
651
    ///
652
    /// Returns
653
    /// -------
654
    /// dict
655
    ///     ``{name: concentration}`` in deterministic order (monomers
656
    ///     first, then complexes, in addition order).
657
    fn to_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
202✔
658
        let dict = PyDict::new(py);
202✔
659
        for name in self.ordered_names() {
949✔
660
            dict.set_item(name, self.concentrations[name])?;
949✔
661
        }
662
        Ok(dict)
202✔
663
    }
202✔
664

665
    /// Species names in deterministic order.
666
    ///
667
    /// Returns
668
    /// -------
669
    /// list of str
670
    ///     Names in order: monomers first, then complexes.
671
    fn keys(&self) -> Vec<String> {
2✔
672
        self.ordered_names().cloned().collect()
2✔
673
    }
2✔
674

675
    /// Concentrations in deterministic order.
676
    ///
677
    /// Returns
678
    /// -------
679
    /// list of float
680
    ///     Concentrations in molar, monomers first, then complexes.
681
    fn values(&self) -> Vec<f64> {
2✔
682
        self.ordered_names()
2✔
683
            .map(|n| self.concentrations[n])
6✔
684
            .collect()
2✔
685
    }
2✔
686

687
    /// ``(name, concentration)`` pairs in deterministic order.
688
    ///
689
    /// Returns
690
    /// -------
691
    /// list of (str, float)
692
    ///     Pairs in order: monomers first, then complexes.
693
    fn items(&self) -> Vec<(String, f64)> {
1✔
694
        self.ordered_names()
1✔
695
            .map(|n| (n.clone(), self.concentrations[n]))
3✔
696
            .collect()
1✔
697
    }
1✔
698

699
    /// Supports `for name in eq:`.
700
    fn __iter__(slf: PyRef<'_, Self>) -> PyResult<Py<EquilibriumKeyIter>> {
1✔
701
        let py = slf.py();
1✔
702
        Py::new(
1✔
703
            py,
1✔
704
            EquilibriumKeyIter {
1✔
705
                keys: slf.keys(),
1✔
706
                index: 0,
1✔
707
            },
1✔
708
        )
709
    }
1✔
710

711
    /// Free monomer concentrations as a list.
712
    #[getter]
713
    fn free_monomer_concentrations(&self) -> Vec<f64> {
1✔
714
        self.free_monomer_concentrations.clone()
1✔
715
    }
1✔
716

717
    /// Complex concentrations as a list.
718
    #[getter]
719
    fn complex_concentrations(&self) -> Vec<f64> {
2✔
720
        self.complex_concentrations.clone()
2✔
721
    }
2✔
722

723
    /// Monomer names as a list.
724
    #[getter]
725
    fn monomer_names(&self) -> Vec<String> {
1✔
726
        self.monomer_names.clone()
1✔
727
    }
1✔
728

729
    /// Complex names as a list.
730
    #[getter]
731
    fn complex_names(&self) -> Vec<String> {
1✔
732
        self.complex_names.clone()
1✔
733
    }
1✔
734

735
    /// Whether the solver achieved full convergence.
736
    ///
737
    /// `False` if the solver accepted the result at a relaxed tolerance
738
    /// due to stagnation at f64 precision limits.
739
    #[getter]
740
    fn converged_fully(&self) -> bool {
1✔
741
        self.converged_fully
1✔
742
    }
1✔
743

744
    fn __repr__(&self) -> String {
1✔
745
        let entries: Vec<String> = self
1✔
746
            .ordered_names()
1✔
747
            .map(|name| {
1✔
748
                let conc = self.concentrations[name];
1✔
749
                format!("  {name}: {conc:.6e} M")
1✔
750
            })
1✔
751
            .collect();
1✔
752
        format!("Equilibrium(\n{}\n)", entries.join("\n"))
1✔
753
    }
1✔
754
}
755

756
// ---------------------------------------------------------------------------
757
// Iterator for `for name in eq:`
758
// ---------------------------------------------------------------------------
759

760
#[pyclass]
761
struct EquilibriumKeyIter {
762
    keys: Vec<String>,
763
    index: usize,
764
}
765

UNCOV
766
#[pymethods]
×
767
impl EquilibriumKeyIter {
UNCOV
768
    fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
×
UNCOV
769
        slf
×
UNCOV
770
    }
×
771

772
    fn __next__(&mut self) -> Option<String> {
4✔
773
        if self.index < self.keys.len() {
4✔
774
            let key = self.keys[self.index].clone();
3✔
775
            self.index += 1;
3✔
776
            Some(key)
3✔
777
        } else {
778
            None
1✔
779
        }
780
    }
4✔
781
}
782

783
// ---------------------------------------------------------------------------
784
// Module definition
785
// ---------------------------------------------------------------------------
786

787
#[pymodule]
788
fn equiconc(m: &Bound<'_, PyModule>) -> PyResult<()> {
1✔
789
    m.add_class::<PySystem>()?;
1✔
790
    m.add_class::<PyEquilibrium>()?;
1✔
791
    m.add_class::<PySolverOptions>()?;
1✔
792
    Ok(())
1✔
793
}
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