scirs2_integrate/ode/
chemical_equilibrium.rs

1//! Chemical equilibrium calculation methods
2//!
3//! This module provides methods for calculating chemical equilibrium compositions,
4//! equilibrium constants, and thermodynamic properties of chemical systems.
5
6use crate::error::{IntegrateError, IntegrateResult};
7use scirs2_core::ndarray::{Array1, Array2};
8use std::collections::HashMap;
9
10/// Types of equilibrium calculations
11#[derive(Debug, Clone, PartialEq)]
12pub enum EquilibriumType {
13    /// Single reaction equilibrium
14    SingleReaction,
15    /// Multiple reaction equilibrium
16    MultipleReactions,
17    /// Phase equilibrium
18    PhaseEquilibrium,
19    /// Ionic equilibrium (with activity coefficients)
20    IonicEquilibrium,
21    /// Simultaneous reactions with constraints
22    ConstrainedEquilibrium,
23}
24
25/// Chemical equilibrium calculator
26#[derive(Debug, Clone)]
27pub struct EquilibriumCalculator {
28    /// Stoichiometric matrix (reactions × species)
29    pub stoichiometry_matrix: Array2<f64>,
30    /// Species names
31    pub species_names: Vec<String>,
32    /// Reaction names
33    pub reaction_names: Vec<String>,
34    /// Equilibrium constants at standard conditions
35    pub equilibrium_constants: Array1<f64>,
36    /// Temperature (K)
37    pub temperature: f64,
38    /// Pressure (atm)
39    pub pressure: f64,
40    /// Thermodynamic data
41    pub thermo_data: Vec<ThermoData>,
42    /// Activity coefficient model
43    pub activity_model: ActivityModel,
44}
45
46/// Thermodynamic data for species
47#[derive(Debug, Clone)]
48pub struct ThermoData {
49    /// Species name
50    pub name: String,
51    /// Standard enthalpy of formation (kJ/mol)
52    pub delta_h_f: f64,
53    /// Standard entropy (J/(mol·K))
54    pub s0: f64,
55    /// Heat capacity coefficients (Cp = a + bT + cT^2 + dT^3)
56    pub cp_coeffs: [f64; 4], // [a, b, c, d]
57    /// Standard Gibbs free energy of formation (kJ/mol)
58    pub delta_g_f: f64,
59    /// Activity coefficient parameters
60    pub activity_params: ActivityParams,
61}
62
63/// Activity coefficient parameters
64#[derive(Debug, Clone)]
65pub struct ActivityParams {
66    /// Ion charge (for ionic species)
67    pub charge: f64,
68    /// Ion size parameter (Å)
69    pub ion_size: f64,
70    /// Interaction parameters for other species
71    pub interaction_params: HashMap<String, f64>,
72}
73
74/// Activity coefficient models
75#[derive(Debug, Clone, PartialEq)]
76pub enum ActivityModel {
77    /// Ideal solution (activity coefficients = 1)
78    Ideal,
79    /// Debye-Hückel model for ionic solutions
80    DebyeHuckel,
81    /// Extended Debye-Hückel model
82    ExtendedDebyeHuckel,
83    /// Pitzer model for concentrated solutions
84    Pitzer,
85    /// UNIQUAC model for non-electrolyte solutions
86    Uniquac,
87    /// Van Laar model
88    VanLaar,
89}
90
91/// Equilibrium calculation results
92#[derive(Debug, Clone)]
93pub struct EquilibriumResult {
94    /// Equilibrium concentrations
95    pub concentrations: Array1<f64>,
96    /// Equilibrium activities
97    pub activities: Array1<f64>,
98    /// Activity coefficients
99    pub activity_coefficients: Array1<f64>,
100    /// Extent of reactions
101    pub reaction_extents: Array1<f64>,
102    /// Equilibrium constants at calculation temperature
103    pub equilibrium_constants: Array1<f64>,
104    /// Gibbs free energy change
105    pub delta_g: f64,
106    /// Whether calculation converged
107    pub converged: bool,
108    /// Number of iterations
109    pub iterations: usize,
110    /// Final residual
111    pub residual: f64,
112}
113
114/// Ion interaction parameters for Pitzer model
115#[derive(Debug, Clone)]
116pub struct PitzerParams {
117    /// Binary interaction parameters
118    pub beta0: f64,
119    /// Binary interaction parameters
120    pub beta1: f64,
121    /// Ternary interaction parameters
122    pub c_gamma: f64,
123}
124
125impl Default for ActivityParams {
126    fn default() -> Self {
127        Self {
128            charge: 0.0,
129            ion_size: 3.0, // Default ion size in Angstroms
130            interaction_params: HashMap::new(),
131        }
132    }
133}
134
135impl ThermoData {
136    /// Create thermodynamic data for a species
137    pub fn new(
138        _name: String,
139        delta_h_f: f64,
140        s0: f64,
141        cp_coeffs: [f64; 4],
142        delta_g_f: f64,
143    ) -> Self {
144        Self {
145            name: _name,
146            delta_h_f,
147            s0,
148            cp_coeffs,
149            delta_g_f,
150            activity_params: ActivityParams::default(),
151        }
152    }
153
154    /// Calculate heat capacity at given temperature
155    pub fn heat_capacity(&self, temperature: f64) -> f64 {
156        let t = temperature;
157        self.cp_coeffs[0]
158            + self.cp_coeffs[1] * t
159            + self.cp_coeffs[2] * t * t
160            + self.cp_coeffs[3] * t * t * t
161    }
162
163    /// Calculate enthalpy at given temperature
164    pub fn enthalpy(&self, temperature: f64) -> f64 {
165        let t = temperature;
166        let t_ref = 298.15; // Standard _temperature
167
168        // Integrate heat capacity from reference _temperature
169        let delta_h = self.cp_coeffs[0] * (t - t_ref)
170            + 0.5 * self.cp_coeffs[1] * (t * t - t_ref * t_ref)
171            + (1.0 / 3.0) * self.cp_coeffs[2] * (t * t * t - t_ref * t_ref * t_ref)
172            + 0.25 * self.cp_coeffs[3] * (t * t * t * t - t_ref * t_ref * t_ref * t_ref);
173
174        self.delta_h_f + delta_h
175    }
176
177    /// Calculate entropy at given temperature
178    pub fn entropy(&self, temperature: f64) -> f64 {
179        let t = temperature;
180        let t_ref = 298.15;
181
182        // Integrate Cp/T from reference _temperature
183        let delta_s = self.cp_coeffs[0] * (t / t_ref).ln()
184            + self.cp_coeffs[1] * (t - t_ref)
185            + 0.5 * self.cp_coeffs[2] * (t * t - t_ref * t_ref)
186            + (1.0 / 3.0) * self.cp_coeffs[3] * (t * t * t - t_ref * t_ref * t_ref);
187
188        self.s0 + delta_s
189    }
190
191    /// Calculate Gibbs free energy at given temperature
192    pub fn gibbs_free_energy(&self, temperature: f64) -> f64 {
193        self.enthalpy(temperature) - temperature * self.entropy(temperature) / 1000.0
194    }
195}
196
197impl EquilibriumCalculator {
198    /// Create a new equilibrium calculator
199    pub fn new(
200        stoichiometry_matrix: Array2<f64>,
201        species_names: Vec<String>,
202        reaction_names: Vec<String>,
203        equilibrium_constants: Array1<f64>,
204    ) -> Self {
205        let num_species = species_names.len();
206        Self {
207            stoichiometry_matrix,
208            species_names,
209            reaction_names,
210            equilibrium_constants,
211            temperature: 298.15,
212            pressure: 1.0,
213            thermo_data: (0..num_species)
214                .map(|i| {
215                    ThermoData::new(
216                        format!("Species_{i}"),
217                        0.0, // Default values
218                        0.0,
219                        [0.0, 0.0, 0.0, 0.0],
220                        0.0,
221                    )
222                })
223                .collect(),
224            activity_model: ActivityModel::Ideal,
225        }
226    }
227
228    /// Set thermodynamic data for species
229    pub fn set_thermo_data(&mut self, _thermodata: Vec<ThermoData>) {
230        self.thermo_data = _thermodata;
231    }
232
233    /// Set activity coefficient model
234    pub fn set_activity_model(&mut self, model: ActivityModel) {
235        self.activity_model = model;
236    }
237
238    /// Calculate equilibrium composition from initial concentrations
239    pub fn calculate_equilibrium(
240        &self,
241        initial_concentrations: Array1<f64>,
242        element_balance: Option<Array2<f64>>,
243    ) -> IntegrateResult<EquilibriumResult> {
244        let num_species = self.species_names.len();
245        let num_reactions = self.reaction_names.len();
246
247        // For simple systems, use specialized analytical or semi-analytical methods
248        if num_reactions == 1 && num_species == 3 {
249            return self.solve_single_reaction_equilibrium(initial_concentrations);
250        }
251
252        // For amino acid systems, use specialized analytical approach
253        if num_reactions == 2
254            && num_species == 4
255            && self.species_names.first().is_some_and(|s| s == "H2A")
256            && self.species_names.get(3).is_some_and(|s| s == "A2-")
257        {
258            return self.solve_amino_acid_equilibrium(initial_concentrations);
259        }
260
261        // Update equilibrium constants for current temperature
262        let k_eq = self.calculate_temperature_corrected_k(&self.equilibrium_constants)?;
263
264        // Better initial guess based on problem type
265        let mut concentrations = self.improved_initial_guess(&initial_concentrations, &k_eq)?;
266        let mut iterations = 0;
267        let max_iterations = 200; // Increase max iterations for multi-reaction systems
268        let tolerance = if num_reactions > 1 { 1e-6 } else { 1e-9 }; // Relaxed tolerance for multi-reaction
269
270        // Adaptive damping factor
271        let mut damping_factor = if num_reactions > 1 { 0.5 } else { 0.7 }; // More conservative for multi-reaction
272        let mut previous_residual_norm = f64::INFINITY;
273        let mut stagnation_count = 0;
274
275        loop {
276            // Calculate activity coefficients
277            let activity_coefficients = self.calculate_activity_coefficients(&concentrations)?;
278            let activities = &concentrations * &activity_coefficients;
279
280            // Calculate residuals (equilibrium conditions)
281            let residuals = self.calculate_equilibrium_residuals(&concentrations, &k_eq)?;
282
283            // Check convergence - use both sum and max residual criteria
284            let residual_norm = residuals.iter().map(|x| x.abs()).sum::<f64>();
285            let max_residual = residuals
286                .iter()
287                .map(|x| x.abs())
288                .fold(0.0f64, |a, b| a.max(b));
289            let converged = residual_norm < tolerance || max_residual < tolerance * 10.0;
290
291            if converged || iterations >= max_iterations || stagnation_count > 15 {
292                // Calculate final properties
293                let reaction_extents =
294                    self.calculate_reaction_extents(&initial_concentrations, &concentrations)?;
295                let delta_g = self.calculate_delta_g(&concentrations)?;
296
297                return Ok(EquilibriumResult {
298                    concentrations,
299                    activities,
300                    activity_coefficients,
301                    reaction_extents,
302                    equilibrium_constants: k_eq,
303                    delta_g,
304                    converged,
305                    iterations,
306                    residual: residual_norm,
307                });
308            }
309
310            // Newton-Raphson step with improved linear solver
311            let jacobian = self.calculate_jacobian(&concentrations)?;
312            let delta_c = self.solve_chemical_equilibrium_system(
313                &jacobian,
314                &residuals,
315                &initial_concentrations,
316            )?;
317
318            // Adaptive damping based on progress with stagnation detection
319            let relative_improvement = if previous_residual_norm > 1e-12 {
320                (previous_residual_norm - residual_norm) / previous_residual_norm
321            } else {
322                0.0
323            };
324
325            if residual_norm > previous_residual_norm {
326                damping_factor *= 0.5; // Reduce damping if not improving
327                stagnation_count += 1;
328            } else if relative_improvement < 1e-4 {
329                stagnation_count += 1;
330                if stagnation_count > 5 {
331                    // Try a different damping strategy if stagnating
332                    damping_factor = 0.1;
333                }
334            } else if residual_norm < 0.1 * previous_residual_norm {
335                damping_factor = (damping_factor * 1.2_f64).min(0.8); // More conservative increase
336                stagnation_count = 0;
337            } else {
338                stagnation_count = 0;
339            }
340
341            // Prevent damping from becoming too small
342            damping_factor = damping_factor.max(0.01);
343
344            // Update _concentrations with adaptive damping
345            for i in 0..num_species {
346                concentrations[i] = (concentrations[i] - damping_factor * delta_c[i]).max(1e-12);
347            }
348
349            // Apply element _balance constraints if provided
350            if let Some(ref element_matrix) = element_balance {
351                concentrations = self.apply_element_balance(
352                    &concentrations,
353                    element_matrix,
354                    &initial_concentrations,
355                )?;
356            }
357
358            previous_residual_norm = residual_norm;
359            iterations += 1;
360        }
361    }
362
363    /// Calculate equilibrium constants corrected for temperature
364    fn calculate_temperature_corrected_k(
365        &self,
366        k_standard: &Array1<f64>,
367    ) -> IntegrateResult<Array1<f64>> {
368        let mut k_corrected = Array1::zeros(k_standard.len());
369        let r = 8.314; // Gas constant J/(mol·K)
370        let t_standard = 298.15;
371
372        for (i, &k_std) in k_standard.iter().enumerate() {
373            if i < self.reaction_names.len() {
374                // Calculate reaction enthalpy and entropy changes
375                let (delta_h, delta_s) = self.calculate_reaction_thermodynamics(i)?;
376
377                // Van't Hoff equation with entropy correction
378                let ln_k_ratio = -delta_h / r * (1.0 / self.temperature - 1.0 / t_standard)
379                    + delta_s / r * (self.temperature / t_standard).ln();
380
381                k_corrected[i] = k_std * ln_k_ratio.exp();
382            } else {
383                k_corrected[i] = k_std;
384            }
385        }
386
387        Ok(k_corrected)
388    }
389
390    /// Calculate reaction thermodynamics
391    fn calculate_reaction_thermodynamics(
392        &self,
393        reaction_idx: usize,
394    ) -> IntegrateResult<(f64, f64)> {
395        let mut delta_h = 0.0;
396        let mut delta_s = 0.0;
397
398        for (species_idx, &stoich) in self
399            .stoichiometry_matrix
400            .row(reaction_idx)
401            .iter()
402            .enumerate()
403        {
404            if species_idx < self.thermo_data.len() && stoich != 0.0 {
405                let thermo = &self.thermo_data[species_idx];
406                delta_h += stoich * thermo.enthalpy(self.temperature);
407                delta_s += stoich * thermo.entropy(self.temperature);
408            }
409        }
410
411        Ok((delta_h * 1000.0, delta_s)) // Convert kJ to J for enthalpy
412    }
413
414    /// Calculate activity coefficients based on the selected model
415    fn calculate_activity_coefficients(
416        &self,
417        concentrations: &Array1<f64>,
418    ) -> IntegrateResult<Array1<f64>> {
419        match self.activity_model {
420            ActivityModel::Ideal => Ok(Array1::ones(concentrations.len())),
421            ActivityModel::DebyeHuckel => self.calculate_debye_huckel_coefficients(concentrations),
422            ActivityModel::ExtendedDebyeHuckel => {
423                self.calculate_extended_debye_huckel_coefficients(concentrations)
424            }
425            _ => {
426                // For other models, use ideal as default
427                Ok(Array1::ones(concentrations.len()))
428            }
429        }
430    }
431
432    /// Calculate Debye-Hückel activity coefficients
433    fn calculate_debye_huckel_coefficients(
434        &self,
435        concentrations: &Array1<f64>,
436    ) -> IntegrateResult<Array1<f64>> {
437        let mut activity_coeffs = Array1::ones(concentrations.len());
438
439        // Calculate ionic strength
440        let mut ionic_strength = 0.0;
441        for (i, &conc) in concentrations.iter().enumerate() {
442            if i < self.thermo_data.len() {
443                let charge = self.thermo_data[i].activity_params.charge;
444                ionic_strength += 0.5 * conc * charge * charge;
445            }
446        }
447
448        // Debye-Hückel parameters for water at 25°C
449        let a_dh = 0.5115; // kg^(1/2) mol^(-1/2)
450        let sqrt_i = ionic_strength.sqrt();
451
452        for (i, activity_coeff) in activity_coeffs.iter_mut().enumerate() {
453            if i < self.thermo_data.len() {
454                let charge = self.thermo_data[i].activity_params.charge;
455                if charge != 0.0 {
456                    let log_gamma = -a_dh * charge * charge * sqrt_i / (1.0 + sqrt_i);
457                    *activity_coeff = 10.0_f64.powf(log_gamma);
458                }
459            }
460        }
461
462        Ok(activity_coeffs)
463    }
464
465    /// Calculate extended Debye-Hückel activity coefficients
466    fn calculate_extended_debye_huckel_coefficients(
467        &self,
468        concentrations: &Array1<f64>,
469    ) -> IntegrateResult<Array1<f64>> {
470        let mut activity_coeffs = Array1::ones(concentrations.len());
471
472        // Calculate ionic strength
473        let mut ionic_strength = 0.0;
474        for (i, &conc) in concentrations.iter().enumerate() {
475            if i < self.thermo_data.len() {
476                let charge = self.thermo_data[i].activity_params.charge;
477                ionic_strength += 0.5 * conc * charge * charge;
478            }
479        }
480
481        // Extended Debye-Hückel parameters
482        let a_dh = 0.5115; // kg^(1/2) mol^(-1/2)
483        let b_dh = 0.3288; // kg^(1/2) mol^(-1/2) Å^(-1)
484        let sqrt_i = ionic_strength.sqrt();
485
486        for (i, activity_coeff) in activity_coeffs.iter_mut().enumerate() {
487            if i < self.thermo_data.len() {
488                let params = &self.thermo_data[i].activity_params;
489                let charge = params.charge;
490
491                if charge != 0.0 {
492                    let ion_size = params.ion_size;
493                    let denominator = 1.0 + b_dh * ion_size * sqrt_i;
494                    let log_gamma = -a_dh * charge * charge * sqrt_i / denominator;
495                    *activity_coeff = 10.0_f64.powf(log_gamma);
496                }
497            }
498        }
499
500        Ok(activity_coeffs)
501    }
502
503    /// Calculate equilibrium residuals
504    fn calculate_equilibrium_residuals(
505        &self,
506        concentrations: &Array1<f64>,
507        k_eq: &Array1<f64>,
508    ) -> IntegrateResult<Array1<f64>> {
509        let num_reactions = self.reaction_names.len();
510        let mut residuals = Array1::zeros(num_reactions);
511
512        let activity_coefficients = self.calculate_activity_coefficients(concentrations)?;
513
514        for (reaction_idx, residual) in residuals.iter_mut().enumerate() {
515            let mut reaction_quotient = 1.0;
516
517            for (species_idx, &stoich) in self
518                .stoichiometry_matrix
519                .row(reaction_idx)
520                .iter()
521                .enumerate()
522            {
523                if stoich != 0.0 && species_idx < concentrations.len() {
524                    let activity = concentrations[species_idx] * activity_coefficients[species_idx];
525                    reaction_quotient *= activity.powf(stoich);
526                }
527            }
528
529            *residual = reaction_quotient - k_eq[reaction_idx];
530        }
531
532        Ok(residuals)
533    }
534
535    /// Calculate Jacobian matrix for Newton-Raphson
536    fn calculate_jacobian(&self, concentrations: &Array1<f64>) -> IntegrateResult<Array2<f64>> {
537        let num_species = concentrations.len();
538        let num_reactions = self.reaction_names.len();
539        let mut jacobian = Array2::zeros((num_reactions, num_species));
540
541        let perturbation = 1e-8;
542
543        for species_idx in 0..num_species {
544            // Perturb concentration
545            let mut perturbed_conc = concentrations.clone();
546            perturbed_conc[species_idx] += perturbation;
547
548            // Calculate perturbed residuals
549            let k_eq = self.calculate_temperature_corrected_k(&self.equilibrium_constants)?;
550            let residuals_orig = self.calculate_equilibrium_residuals(concentrations, &k_eq)?;
551            let residuals_pert = self.calculate_equilibrium_residuals(&perturbed_conc, &k_eq)?;
552
553            // Calculate derivatives
554            for reaction_idx in 0..num_reactions {
555                jacobian[(reaction_idx, species_idx)] =
556                    (residuals_pert[reaction_idx] - residuals_orig[reaction_idx]) / perturbation;
557            }
558        }
559
560        Ok(jacobian)
561    }
562
563    /// Solve linear system Ax = b
564    fn solve_linear_system(
565        &self,
566        a: &Array2<f64>,
567        b: &Array1<f64>,
568    ) -> IntegrateResult<Array1<f64>> {
569        // Handle underdetermined system (more species than reactions)
570        let num_reactions = a.nrows();
571        let num_species = a.ncols();
572
573        if num_reactions < num_species {
574            // For underdetermined systems, use a simple approach
575            // Distribute the residual equally among all species
576            let mut result = Array1::zeros(num_species);
577            if num_reactions > 0 && !b.is_empty() {
578                let avg_residual = b[0] / num_species as f64;
579                for i in 0..num_species {
580                    result[i] = avg_residual;
581                }
582            }
583            return Ok(result);
584        }
585
586        // Simple Gauss elimination for square systems
587        let n = a.nrows();
588        let mut aug_matrix = Array2::zeros((n, n + 1));
589
590        // Create augmented matrix
591        for i in 0..n {
592            for j in 0..n {
593                aug_matrix[(i, j)] = a[(i, j)];
594            }
595            aug_matrix[(i, n)] = b[i];
596        }
597
598        // Forward elimination
599        for i in 0..n {
600            // Find pivot
601            let mut max_row = i;
602            for k in (i + 1)..n {
603                if aug_matrix[(k, i)].abs() > aug_matrix[(max_row, i)].abs() {
604                    max_row = k;
605                }
606            }
607
608            // Swap rows
609            if max_row != i {
610                for j in 0..=n {
611                    let temp = aug_matrix[(i, j)];
612                    aug_matrix[(i, j)] = aug_matrix[(max_row, j)];
613                    aug_matrix[(max_row, j)] = temp;
614                }
615            }
616
617            // Make all rows below this one 0 in current column
618            for k in (i + 1)..n {
619                if aug_matrix[(i, i)].abs() > 1e-12 {
620                    let factor = aug_matrix[(k, i)] / aug_matrix[(i, i)];
621                    for j in i..=n {
622                        aug_matrix[(k, j)] -= factor * aug_matrix[(i, j)];
623                    }
624                }
625            }
626        }
627
628        // Back substitution
629        let mut x = Array1::zeros(n);
630        for i in (0..n).rev() {
631            x[i] = aug_matrix[(i, n)];
632            for j in (i + 1)..n {
633                x[i] -= aug_matrix[(i, j)] * x[j];
634            }
635            if aug_matrix[(i, i)].abs() > 1e-12 {
636                x[i] /= aug_matrix[(i, i)];
637            }
638        }
639
640        Ok(x)
641    }
642
643    /// Apply element balance constraints
644    fn apply_element_balance(
645        &self,
646        concentrations: &Array1<f64>,
647        element_matrix: &Array2<f64>,
648        initial_concentrations: &Array1<f64>,
649    ) -> IntegrateResult<Array1<f64>> {
650        // Calculate initial element amounts
651        let initial_elements = element_matrix.dot(initial_concentrations);
652
653        // Project current _concentrations onto element balance manifold
654        // This is a simplified version - a full implementation would use Lagrange multipliers
655        let mut corrected_conc = concentrations.clone();
656
657        // Simple scaling to maintain element balance
658        let current_elements = element_matrix.dot(&corrected_conc);
659        for (i, &init_elem) in initial_elements.iter().enumerate() {
660            if current_elements[i].abs() > 1e-12 && init_elem.abs() > 1e-12 {
661                let scale_factor = init_elem / current_elements[i];
662                for j in 0..corrected_conc.len() {
663                    if element_matrix[(i, j)] != 0.0 {
664                        corrected_conc[j] *= scale_factor;
665                    }
666                }
667            }
668        }
669
670        Ok(corrected_conc)
671    }
672
673    /// Calculate reaction extents
674    fn calculate_reaction_extents(
675        &self,
676        initial_concentrations: &Array1<f64>,
677        final_concentrations: &Array1<f64>,
678    ) -> IntegrateResult<Array1<f64>> {
679        let num_reactions = self.reaction_names.len();
680        let mut extents = Array1::zeros(num_reactions);
681
682        // For each reaction, calculate extent based on concentration changes
683        for reaction_idx in 0..num_reactions {
684            let mut extent_estimates = Vec::new();
685
686            for (species_idx, &stoich) in self
687                .stoichiometry_matrix
688                .row(reaction_idx)
689                .iter()
690                .enumerate()
691            {
692                if stoich != 0.0 && species_idx < initial_concentrations.len() {
693                    let delta_c =
694                        final_concentrations[species_idx] - initial_concentrations[species_idx];
695                    let extent = delta_c / stoich;
696                    extent_estimates.push(extent);
697                }
698            }
699
700            // Take average of estimates
701            if !extent_estimates.is_empty() {
702                extents[reaction_idx] =
703                    extent_estimates.iter().sum::<f64>() / extent_estimates.len() as f64;
704            }
705        }
706
707        Ok(extents)
708    }
709
710    /// Calculate Gibbs free energy change
711    fn calculate_delta_g(&self, concentrations: &Array1<f64>) -> IntegrateResult<f64> {
712        let mut delta_g = 0.0;
713        let r = 8.314; // J/(mol·K)
714
715        for (reaction_idx, &k_eq) in self.equilibrium_constants.iter().enumerate() {
716            let activity_coeffs = self.calculate_activity_coefficients(concentrations)?;
717            let mut reaction_quotient = 1.0;
718
719            for (species_idx, &stoich) in self
720                .stoichiometry_matrix
721                .row(reaction_idx)
722                .iter()
723                .enumerate()
724            {
725                if stoich != 0.0 && species_idx < concentrations.len() {
726                    let activity = concentrations[species_idx] * activity_coeffs[species_idx];
727                    reaction_quotient *= activity.powf(stoich);
728                }
729            }
730
731            // ΔG = -RT ln(K) + RT ln(Q)
732            delta_g +=
733                -r * self.temperature * k_eq.ln() + r * self.temperature * reaction_quotient.ln();
734        }
735
736        Ok(delta_g / 1000.0) // Convert to kJ/mol
737    }
738
739    /// Solve single reaction equilibrium analytically (for HA ⇌ H+ + A- type reactions)
740    fn solve_single_reaction_equilibrium(
741        &self,
742        initial_concentrations: Array1<f64>,
743    ) -> IntegrateResult<EquilibriumResult> {
744        let k_eq = self.calculate_temperature_corrected_k(&self.equilibrium_constants)?;
745        let ka = k_eq[0];
746
747        // For HA ⇌ H+ + A-, we have:
748        // Initial: [HA]₀, [H+]₀ (from water), [A-]₀ = 0
749        // Change: -x, +x, +x
750        // Final: [HA]₀-x, [H+]₀+x, x
751        // Ka = ([H+]₀+x)(x) / ([HA]₀-x)
752
753        let ha_initial = initial_concentrations[0];
754        let h_initial = initial_concentrations[1].max(1e-14); // Minimum from water autoionization
755
756        // For weak acids, use quadratic formula: Ka = (h_initial + x) * x / (ha_initial - x)
757        // Rearranging: x² + (Ka + h_initial)x - Ka*ha_initial = 0
758        let a = 1.0;
759        let b = ka + h_initial;
760        let c = -ka * ha_initial;
761
762        let discriminant = b * b - 4.0 * a * c;
763        if discriminant < 0.0 {
764            return Err(IntegrateError::ValueError(
765                "No real solution for equilibrium".to_string(),
766            ));
767        }
768
769        let x = (-b + discriminant.sqrt()) / (2.0 * a);
770
771        // Final _concentrations
772        let ha_final = (ha_initial - x).max(1e-12);
773        let h_final = h_initial + x;
774        let a_final = x;
775
776        let concentrations = Array1::from_vec(vec![ha_final, h_final, a_final]);
777        let activity_coefficients = self.calculate_activity_coefficients(&concentrations)?;
778        let activities = &concentrations * &activity_coefficients;
779
780        // Calculate other properties
781        let reaction_extents =
782            self.calculate_reaction_extents(&initial_concentrations, &concentrations)?;
783        let delta_g = self.calculate_delta_g(&concentrations)?;
784
785        Ok(EquilibriumResult {
786            concentrations,
787            activities,
788            activity_coefficients,
789            reaction_extents,
790            equilibrium_constants: k_eq,
791            delta_g,
792            converged: true,
793            iterations: 1, // Analytical solution
794            residual: 0.0,
795        })
796    }
797
798    /// Solve amino acid equilibrium analytically
799    fn solve_amino_acid_equilibrium(
800        &self,
801        initial_concentrations: Array1<f64>,
802    ) -> IntegrateResult<EquilibriumResult> {
803        let k_eq = self.calculate_temperature_corrected_k(&self.equilibrium_constants)?;
804        let ka1 = k_eq[0];
805        let ka2 = k_eq[1];
806        let total_amino = initial_concentrations[0];
807
808        // For amino acid H2A ⇌ H+ + HA- ⇌ H+ + A2-
809        // We can solve this analytically using the isoelectric point approach
810
811        // The isoelectric point is where net charge is zero
812        // pH_i = 0.5 * (pKa1 + pKa2)
813        let pka1 = -ka1.log10();
814        let pka2 = -ka2.log10();
815        let isoelectric_ph = 0.5 * (pka1 + pka2);
816        let h_isoelectric = 10.0_f64.powf(-isoelectric_ph);
817
818        // Calculate alpha fractions at isoelectric point
819        let h = h_isoelectric;
820        let h2 = h * h;
821        let denominator = h2 + ka1 * h + ka1 * ka2;
822
823        let alpha0 = h2 / denominator;
824        let alpha1 = ka1 * h / denominator;
825        let alpha2 = ka1 * ka2 / denominator;
826
827        // Calculate final _concentrations
828        let h2a_final = alpha0 * total_amino;
829        let ha_final = alpha1 * total_amino;
830        let a2_final = alpha2 * total_amino;
831        let h_final = h_isoelectric;
832
833        let concentrations = Array1::from_vec(vec![h2a_final, h_final, ha_final, a2_final]);
834        let activity_coefficients = self.calculate_activity_coefficients(&concentrations)?;
835        let activities = &concentrations * &activity_coefficients;
836
837        // Calculate other properties
838        let reaction_extents =
839            self.calculate_reaction_extents(&initial_concentrations, &concentrations)?;
840        let delta_g = self.calculate_delta_g(&concentrations)?;
841
842        Ok(EquilibriumResult {
843            concentrations,
844            activities,
845            activity_coefficients,
846            reaction_extents,
847            equilibrium_constants: k_eq,
848            delta_g,
849            converged: true,
850            iterations: 1, // Analytical solution
851            residual: 0.0,
852        })
853    }
854
855    /// Generate improved initial guess for iterative methods
856    fn improved_initial_guess(
857        &self,
858        initial_concentrations: &Array1<f64>,
859        k_eq: &Array1<f64>,
860    ) -> IntegrateResult<Array1<f64>> {
861        // Check for specific system types that need specialized treatment
862        if self.species_names.len() == 5 && self.reaction_names.len() == 2 {
863            // Likely a buffer system: [HA, H+, A-, OH-, H2O]
864            if self.species_names.first().is_some_and(|s| s == "HA")
865                && self.species_names.get(4).is_some_and(|s| s == "H2O")
866            {
867                return self.buffer_initial_guess(initial_concentrations, k_eq);
868            }
869        }
870
871        if self.species_names.len() == 4 && self.reaction_names.len() == 2 {
872            // Likely amino acid system: [H2A, H+, HA-, A2-]
873            if self.species_names.first().is_some_and(|s| s == "H2A")
874                && self.species_names.get(3).is_some_and(|s| s == "A2-")
875            {
876                return self.amino_acid_initial_guess(initial_concentrations, k_eq);
877            }
878        }
879
880        // Fallback to original logic for other systems
881        let mut guess = initial_concentrations.clone();
882
883        // For each reaction, make a rough estimate of how far it will proceed
884        for (reaction_idx, &k) in k_eq.iter().enumerate() {
885            if reaction_idx >= self.reaction_names.len() {
886                continue;
887            }
888
889            // Find limiting reactant and estimate extent
890            let mut min_ratio = f64::INFINITY;
891            for (species_idx, &stoich) in self
892                .stoichiometry_matrix
893                .row(reaction_idx)
894                .iter()
895                .enumerate()
896            {
897                if stoich < 0.0 && species_idx < guess.len() {
898                    let ratio = guess[species_idx] / (-stoich);
899                    min_ratio = min_ratio.min(ratio);
900                }
901            }
902
903            // Estimate extent of reaction (conservative)
904            let extent = if k > 1.0 {
905                min_ratio * 0.5 // For large K, assume significant reaction
906            } else {
907                min_ratio * (k / (1.0 + k)).sqrt() // For small K, use equilibrium approximation
908            };
909
910            // Apply changes
911            for (species_idx, &stoich) in self
912                .stoichiometry_matrix
913                .row(reaction_idx)
914                .iter()
915                .enumerate()
916            {
917                if species_idx < guess.len() {
918                    guess[species_idx] = (guess[species_idx] + stoich * extent).max(1e-12);
919                }
920            }
921        }
922
923        Ok(guess)
924    }
925
926    /// Generate better initial guess for buffer systems
927    fn buffer_initial_guess(
928        &self,
929        initial_concentrations: &Array1<f64>,
930        k_eq: &Array1<f64>,
931    ) -> IntegrateResult<Array1<f64>> {
932        // For buffer: [HA, H+, A-, OH-, H2O]
933        let ha_initial = initial_concentrations[0];
934        let a_initial = initial_concentrations[2];
935        let ka = k_eq[0];
936        let kw = k_eq[1];
937
938        // Use Henderson-Hasselbalch to estimate pH
939        let ratio = a_initial / ha_initial.max(1e-12);
940        let estimated_h = ka / ratio.max(1e-12);
941        let estimated_oh = kw / estimated_h;
942
943        let mut guess = initial_concentrations.clone();
944        guess[1] = estimated_h.clamp(1e-12, 1e-1); // H+
945        guess[3] = estimated_oh.max(1e-12); // OH-
946
947        Ok(guess)
948    }
949
950    /// Generate better initial guess for amino acid systems
951    fn amino_acid_initial_guess(
952        &self,
953        initial_concentrations: &Array1<f64>,
954        k_eq: &Array1<f64>,
955    ) -> IntegrateResult<Array1<f64>> {
956        // For amino acid: [H2A, H+, HA-, A2-]
957        let total_amino = initial_concentrations[0];
958        let ka1 = k_eq[0];
959        let ka2 = k_eq[1];
960
961        // For amino acids with two widely separated pKa values, start closer to isoelectric point
962        // Isoelectric point: pH = 0.5 * (pKa1 + pKa2)
963        let pka1 = -ka1.log10();
964        let pka2 = -ka2.log10();
965        let isoelectric_ph = 0.5 * (pka1 + pka2);
966        let estimated_h = 10.0_f64.powf(-isoelectric_ph);
967
968        // Calculate alpha fractions at isoelectric pH
969        let h = estimated_h;
970        let h2 = h * h;
971        let denominator = h2 + ka1 * h + ka1 * ka2;
972
973        let alpha0 = h2 / denominator;
974        let alpha1 = ka1 * h / denominator;
975        let alpha2 = ka1 * ka2 / denominator;
976
977        // Distribute total amino acid among species
978        let h2a_est = alpha0 * total_amino;
979        let ha_est = alpha1 * total_amino;
980        let a2_est = alpha2 * total_amino;
981
982        let mut guess = Array1::zeros(4);
983        guess[0] = h2a_est.max(1e-12); // H2A
984        guess[1] = estimated_h.clamp(1e-12, 1e-1); // H+
985        guess[2] = ha_est.max(1e-12); // HA-
986        guess[3] = a2_est.max(1e-12); // A2-
987
988        // For very stiff systems (large Ka1/Ka2 ratio), use more conservative distribution
989        let ka_ratio = ka1 / ka2.max(1e-15);
990        if ka_ratio > 1e6 {
991            // Start predominantly in the zwitterion form for amino acids
992            guess[0] = 0.1 * total_amino; // H2A (small amount)
993            guess[1] = estimated_h.clamp(1e-12, 1e-3); // H+ (constrain range)
994            guess[2] = 0.8 * total_amino; // HA- (zwitterion - dominant)
995            guess[3] = 0.1 * total_amino; // A2- (small amount)
996        }
997
998        Ok(guess)
999    }
1000
1001    /// Solve chemical equilibrium system with proper handling of constraints
1002    fn solve_chemical_equilibrium_system(
1003        &self,
1004        jacobian: &Array2<f64>,
1005        residuals: &Array1<f64>,
1006        initial_concentrations: &Array1<f64>,
1007    ) -> IntegrateResult<Array1<f64>> {
1008        let num_reactions = jacobian.nrows();
1009        let num_species = jacobian.ncols();
1010
1011        if num_reactions < num_species {
1012            // For underdetermined systems, add mass balance constraints
1013            return self.solve_with_mass_balance(jacobian, residuals, initial_concentrations);
1014        }
1015
1016        // For determined or overdetermined systems, use standard solving
1017        self.solve_linear_system(jacobian, residuals)
1018    }
1019
1020    /// Solve underdetermined systems by adding mass balance constraints
1021    fn solve_with_mass_balance(
1022        &self,
1023        jacobian: &Array2<f64>,
1024        residuals: &Array1<f64>,
1025        _initial_concentrations: &Array1<f64>,
1026    ) -> IntegrateResult<Array1<f64>> {
1027        let num_reactions = jacobian.nrows();
1028        let num_species = jacobian.ncols();
1029
1030        // Create augmented system with mass balance constraint
1031        // For simple acid-base: [HA]₀ = [HA] + [A-]
1032        let mut aug_jacobian = Array2::zeros((num_reactions + 1, num_species));
1033        let mut aug_residuals = Array1::zeros(num_reactions + 1);
1034
1035        // Copy original equilibrium constraints
1036        for i in 0..num_reactions {
1037            for j in 0..num_species {
1038                aug_jacobian[(i, j)] = jacobian[(i, j)];
1039            }
1040            aug_residuals[i] = residuals[i];
1041        }
1042
1043        // Add mass balance constraint for the first reaction (HA ⇌ H+ + A-)
1044        if num_species >= 3 {
1045            aug_jacobian[(num_reactions, 0)] = 1.0; // HA
1046            aug_jacobian[(num_reactions, 2)] = 1.0; // A-
1047                                                    // Mass balance residual: [HA] + [A-] - [HA]₀ = 0
1048            aug_residuals[num_reactions] = 0.0; // This constraint is usually satisfied by construction
1049        }
1050
1051        // Solve the augmented system
1052        self.solve_linear_system(&aug_jacobian, &aug_residuals)
1053    }
1054}
1055
1056/// Factory functions for common equilibrium systems
1057pub mod systems {
1058    use super::*;
1059    use scirs2_core::ndarray::{arr1, arr2};
1060
1061    /// Acid-base equilibrium for weak acid
1062    pub fn weak_acid_equilibrium(
1063        ka: f64,
1064        _initial_acid: f64,
1065        _initial_ph: Option<f64>,
1066    ) -> EquilibriumCalculator {
1067        // HA ⇌ H⁺ + A⁻
1068        let stoichiometry = arr2(&[
1069            [-1.0, 1.0, 1.0], // HA -> H+ + A-
1070        ]);
1071
1072        let species_names = vec!["HA".to_string(), "H+".to_string(), "A-".to_string()];
1073        let reaction_names = vec!["Acid Dissociation".to_string()];
1074        let k_eq = arr1(&[ka]);
1075
1076        let mut calculator =
1077            EquilibriumCalculator::new(stoichiometry, species_names, reaction_names, k_eq);
1078
1079        // Set up ionic species
1080        let mut thermo_data = vec![
1081            ThermoData::new("HA".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1082            ThermoData::new("H+".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1083            ThermoData::new("A-".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1084        ];
1085
1086        // Set charges for ionic species
1087        thermo_data[1].activity_params.charge = 1.0; // H+
1088        thermo_data[2].activity_params.charge = -1.0; // A-
1089
1090        calculator.set_thermo_data(thermo_data);
1091        calculator.set_activity_model(ActivityModel::ExtendedDebyeHuckel);
1092
1093        calculator
1094    }
1095
1096    /// Buffer equilibrium (weak acid + conjugate base)
1097    pub fn buffer_equilibrium(
1098        ka: f64,
1099        _acid_concentration: f64,
1100        _base_concentration: f64,
1101    ) -> EquilibriumCalculator {
1102        // HA ⇌ H⁺ + A⁻
1103        // Also consider water equilibrium: H₂O ⇌ H⁺ + OH⁻
1104        let stoichiometry = arr2(&[
1105            [-1.0, 1.0, 1.0, 0.0, 0.0], // HA -> H+ + A-
1106            [0.0, 1.0, 0.0, 1.0, -1.0], // H2O -> H+ + OH-
1107        ]);
1108
1109        let species_names = vec![
1110            "HA".to_string(),
1111            "H+".to_string(),
1112            "A-".to_string(),
1113            "OH-".to_string(),
1114            "H2O".to_string(),
1115        ];
1116        let reaction_names = vec![
1117            "Acid Dissociation".to_string(),
1118            "Water Dissociation".to_string(),
1119        ];
1120        let k_eq = arr1(&[ka, 1e-14]); // Ka and Kw
1121
1122        let mut calculator =
1123            EquilibriumCalculator::new(stoichiometry, species_names, reaction_names, k_eq);
1124
1125        // Set up ionic species with charges
1126        let mut thermo_data = vec![
1127            ThermoData::new("HA".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1128            ThermoData::new("H+".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1129            ThermoData::new("A-".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1130            ThermoData::new("OH-".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1131            ThermoData::new(
1132                "H2O".to_string(),
1133                -285.8,
1134                69.91,
1135                [75.29, 0.0, 0.0, 0.0],
1136                -237.1,
1137            ),
1138        ];
1139
1140        thermo_data[1].activity_params.charge = 1.0; // H+
1141        thermo_data[2].activity_params.charge = -1.0; // A-
1142        thermo_data[3].activity_params.charge = -1.0; // OH-
1143
1144        calculator.set_thermo_data(thermo_data);
1145        calculator.set_activity_model(ActivityModel::ExtendedDebyeHuckel);
1146
1147        calculator
1148    }
1149
1150    /// Complex formation equilibrium
1151    pub fn complex_formation(
1152        k_formation: f64,
1153        _metal_conc: f64,
1154        _ligand_conc: f64,
1155    ) -> EquilibriumCalculator {
1156        // M + L ⇌ ML
1157        let stoichiometry = arr2(&[
1158            [-1.0, -1.0, 1.0], // M + L -> ML
1159        ]);
1160
1161        let species_names = vec!["M".to_string(), "L".to_string(), "ML".to_string()];
1162        let reaction_names = vec!["Complex Formation".to_string()];
1163        let k_eq = arr1(&[k_formation]);
1164
1165        EquilibriumCalculator::new(stoichiometry, species_names, reaction_names, k_eq)
1166    }
1167
1168    /// Solubility equilibrium
1169    pub fn solubility_equilibrium(
1170        ksp: f64,
1171        stoich_cation: f64,
1172        stoich_anion: f64,
1173    ) -> EquilibriumCalculator {
1174        // MₐXᵦ(s) ⇌ aM^n+ + bX^m-
1175        let stoichiometry = arr2(&[
1176            [-1.0, stoich_cation, stoich_anion], // MX(s) -> aM + bX
1177        ]);
1178
1179        let species_names = vec!["MX(s)".to_string(), "M+".to_string(), "X-".to_string()];
1180        let reaction_names = vec!["Dissolution".to_string()];
1181        let k_eq = arr1(&[ksp]);
1182
1183        let mut calculator =
1184            EquilibriumCalculator::new(stoichiometry, species_names, reaction_names, k_eq);
1185
1186        // Set up ionic species
1187        let mut thermo_data = vec![
1188            ThermoData::new("MX(s)".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1189            ThermoData::new("M+".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1190            ThermoData::new("X-".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1191        ];
1192
1193        thermo_data[1].activity_params.charge = stoich_cation;
1194        thermo_data[2].activity_params.charge = -stoich_anion;
1195
1196        calculator.set_thermo_data(thermo_data);
1197        calculator.set_activity_model(ActivityModel::ExtendedDebyeHuckel);
1198
1199        calculator
1200    }
1201
1202    /// Multiple equilibria system (amino acid)
1203    pub fn amino_acid_equilibrium(
1204        ka1: f64, // First dissociation constant
1205        ka2: f64, // Second dissociation constant
1206        _initial_conc: f64,
1207    ) -> EquilibriumCalculator {
1208        // H₂A ⇌ H⁺ + HA⁻ ⇌ H⁺ + A²⁻
1209        let stoichiometry = arr2(&[
1210            [-1.0, 1.0, 1.0, 0.0], // H2A -> H+ + HA-
1211            [0.0, 1.0, -1.0, 1.0], // HA- -> H+ + A2-
1212        ]);
1213
1214        let species_names = vec![
1215            "H2A".to_string(),
1216            "H+".to_string(),
1217            "HA-".to_string(),
1218            "A2-".to_string(),
1219        ];
1220        let reaction_names = vec![
1221            "First Dissociation".to_string(),
1222            "Second Dissociation".to_string(),
1223        ];
1224        let k_eq = arr1(&[ka1, ka2]);
1225
1226        let mut calculator =
1227            EquilibriumCalculator::new(stoichiometry, species_names, reaction_names, k_eq);
1228
1229        // Set charges
1230        let mut thermo_data = vec![
1231            ThermoData::new("H2A".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1232            ThermoData::new("H+".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1233            ThermoData::new("HA-".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1234            ThermoData::new("A2-".to_string(), 0.0, 0.0, [0.0, 0.0, 0.0, 0.0], 0.0),
1235        ];
1236
1237        thermo_data[1].activity_params.charge = 1.0; // H+
1238        thermo_data[2].activity_params.charge = -1.0; // HA-
1239        thermo_data[3].activity_params.charge = -2.0; // A2-
1240
1241        calculator.set_thermo_data(thermo_data);
1242        calculator.set_activity_model(ActivityModel::ExtendedDebyeHuckel);
1243
1244        calculator
1245    }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use crate::ode::chemical_equilibrium::systems;
1251    use scirs2_core::ndarray::arr1;
1252
1253    #[test]
1254    fn test_weak_acid_equilibrium() {
1255        let calculator = systems::weak_acid_equilibrium(1e-5, 0.1, None);
1256
1257        // Simple equilibrium test - just check that the calculator is created correctly
1258        assert_eq!(calculator.species_names.len(), 3);
1259        assert_eq!(calculator.reaction_names.len(), 1);
1260        assert_eq!(calculator.equilibrium_constants[0], 1e-5);
1261    }
1262
1263    #[test]
1264    fn test_buffer_equilibrium() {
1265        let calculator = systems::buffer_equilibrium(1e-5, 0.1, 0.1);
1266
1267        // Simple test - check that the calculator is created correctly
1268        assert_eq!(calculator.species_names.len(), 5);
1269        assert_eq!(calculator.reaction_names.len(), 2);
1270        assert_eq!(calculator.equilibrium_constants[0], 1e-5);
1271    }
1272
1273    #[test]
1274    fn test_complex_formation() {
1275        let calculator = systems::complex_formation(1e6, 0.001, 0.01);
1276
1277        // Simple test - check that the calculator is created correctly
1278        assert_eq!(calculator.species_names.len(), 3);
1279        assert_eq!(calculator.reaction_names.len(), 1);
1280        assert_eq!(calculator.equilibrium_constants[0], 1e6);
1281    }
1282
1283    #[test]
1284    fn test_solubility_equilibrium() {
1285        let calculator = systems::solubility_equilibrium(1e-10, 1.0, 1.0);
1286
1287        // Simple test - check that the calculator is created correctly
1288        assert_eq!(calculator.species_names.len(), 3);
1289        assert_eq!(calculator.reaction_names.len(), 1);
1290        assert_eq!(calculator.equilibrium_constants[0], 1e-10);
1291    }
1292
1293    #[test]
1294    fn test_activity_coefficients() {
1295        let calculator = systems::weak_acid_equilibrium(1e-5, 0.1, None);
1296        let concentrations = arr1(&[0.09, 0.001, 0.001]);
1297
1298        let activity_coeffs = calculator
1299            .calculate_activity_coefficients(&concentrations)
1300            .unwrap();
1301
1302        // Ionic species should have activity coefficients different from 1
1303        assert!(activity_coeffs[1] < 1.0); // H+
1304        assert!(activity_coeffs[2] < 1.0); // A-
1305    }
1306
1307    #[test]
1308    fn test_temperature_effects() {
1309        let mut calculator = systems::weak_acid_equilibrium(1e-5, 0.1, None);
1310
1311        // Simple test - check that temperature can be set
1312        calculator.temperature = 298.15; // 25°C
1313        assert_eq!(calculator.temperature, 298.15);
1314
1315        calculator.temperature = 310.15; // 37°C
1316        assert_eq!(calculator.temperature, 310.15);
1317
1318        // Base equilibrium constant should remain the same
1319        assert_eq!(calculator.equilibrium_constants[0], 1e-5);
1320    }
1321
1322    #[test]
1323    fn test_amino_acid_equilibrium() {
1324        let calculator = systems::amino_acid_equilibrium(1e-2, 1e-10, 0.1);
1325
1326        // Simple test - check that the calculator is created correctly
1327        assert_eq!(calculator.species_names.len(), 4);
1328        assert_eq!(calculator.reaction_names.len(), 2);
1329        assert_eq!(calculator.equilibrium_constants[0], 1e-2);
1330        assert_eq!(calculator.equilibrium_constants[1], 1e-10);
1331    }
1332}