1use crate::error::{IntegrateError, IntegrateResult};
7use scirs2_core::ndarray::{Array1, Array2};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, PartialEq)]
12pub enum EquilibriumType {
13 SingleReaction,
15 MultipleReactions,
17 PhaseEquilibrium,
19 IonicEquilibrium,
21 ConstrainedEquilibrium,
23}
24
25#[derive(Debug, Clone)]
27pub struct EquilibriumCalculator {
28 pub stoichiometry_matrix: Array2<f64>,
30 pub species_names: Vec<String>,
32 pub reaction_names: Vec<String>,
34 pub equilibrium_constants: Array1<f64>,
36 pub temperature: f64,
38 pub pressure: f64,
40 pub thermo_data: Vec<ThermoData>,
42 pub activity_model: ActivityModel,
44}
45
46#[derive(Debug, Clone)]
48pub struct ThermoData {
49 pub name: String,
51 pub delta_h_f: f64,
53 pub s0: f64,
55 pub cp_coeffs: [f64; 4], pub delta_g_f: f64,
59 pub activity_params: ActivityParams,
61}
62
63#[derive(Debug, Clone)]
65pub struct ActivityParams {
66 pub charge: f64,
68 pub ion_size: f64,
70 pub interaction_params: HashMap<String, f64>,
72}
73
74#[derive(Debug, Clone, PartialEq)]
76pub enum ActivityModel {
77 Ideal,
79 DebyeHuckel,
81 ExtendedDebyeHuckel,
83 Pitzer,
85 Uniquac,
87 VanLaar,
89}
90
91#[derive(Debug, Clone)]
93pub struct EquilibriumResult {
94 pub concentrations: Array1<f64>,
96 pub activities: Array1<f64>,
98 pub activity_coefficients: Array1<f64>,
100 pub reaction_extents: Array1<f64>,
102 pub equilibrium_constants: Array1<f64>,
104 pub delta_g: f64,
106 pub converged: bool,
108 pub iterations: usize,
110 pub residual: f64,
112}
113
114#[derive(Debug, Clone)]
116pub struct PitzerParams {
117 pub beta0: f64,
119 pub beta1: f64,
121 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, interaction_params: HashMap::new(),
131 }
132 }
133}
134
135impl ThermoData {
136 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 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 pub fn enthalpy(&self, temperature: f64) -> f64 {
165 let t = temperature;
166 let t_ref = 298.15; 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 pub fn entropy(&self, temperature: f64) -> f64 {
179 let t = temperature;
180 let t_ref = 298.15;
181
182 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 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 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, 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 pub fn set_thermo_data(&mut self, _thermodata: Vec<ThermoData>) {
230 self.thermo_data = _thermodata;
231 }
232
233 pub fn set_activity_model(&mut self, model: ActivityModel) {
235 self.activity_model = model;
236 }
237
238 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 if num_reactions == 1 && num_species == 3 {
249 return self.solve_single_reaction_equilibrium(initial_concentrations);
250 }
251
252 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 let k_eq = self.calculate_temperature_corrected_k(&self.equilibrium_constants)?;
263
264 let mut concentrations = self.improved_initial_guess(&initial_concentrations, &k_eq)?;
266 let mut iterations = 0;
267 let max_iterations = 200; let tolerance = if num_reactions > 1 { 1e-6 } else { 1e-9 }; let mut damping_factor = if num_reactions > 1 { 0.5 } else { 0.7 }; let mut previous_residual_norm = f64::INFINITY;
273 let mut stagnation_count = 0;
274
275 loop {
276 let activity_coefficients = self.calculate_activity_coefficients(&concentrations)?;
278 let activities = &concentrations * &activity_coefficients;
279
280 let residuals = self.calculate_equilibrium_residuals(&concentrations, &k_eq)?;
282
283 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 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 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 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; stagnation_count += 1;
328 } else if relative_improvement < 1e-4 {
329 stagnation_count += 1;
330 if stagnation_count > 5 {
331 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); stagnation_count = 0;
337 } else {
338 stagnation_count = 0;
339 }
340
341 damping_factor = damping_factor.max(0.01);
343
344 for i in 0..num_species {
346 concentrations[i] = (concentrations[i] - damping_factor * delta_c[i]).max(1e-12);
347 }
348
349 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 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; let t_standard = 298.15;
371
372 for (i, &k_std) in k_standard.iter().enumerate() {
373 if i < self.reaction_names.len() {
374 let (delta_h, delta_s) = self.calculate_reaction_thermodynamics(i)?;
376
377 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 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)) }
413
414 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 Ok(Array1::ones(concentrations.len()))
428 }
429 }
430 }
431
432 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 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 let a_dh = 0.5115; 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 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 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 let a_dh = 0.5115; let b_dh = 0.3288; 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 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 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 let mut perturbed_conc = concentrations.clone();
546 perturbed_conc[species_idx] += perturbation;
547
548 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 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 fn solve_linear_system(
565 &self,
566 a: &Array2<f64>,
567 b: &Array1<f64>,
568 ) -> IntegrateResult<Array1<f64>> {
569 let num_reactions = a.nrows();
571 let num_species = a.ncols();
572
573 if num_reactions < num_species {
574 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 let n = a.nrows();
588 let mut aug_matrix = Array2::zeros((n, n + 1));
589
590 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 for i in 0..n {
600 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 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 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 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 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 let initial_elements = element_matrix.dot(initial_concentrations);
652
653 let mut corrected_conc = concentrations.clone();
656
657 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 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 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 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 fn calculate_delta_g(&self, concentrations: &Array1<f64>) -> IntegrateResult<f64> {
712 let mut delta_g = 0.0;
713 let r = 8.314; 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 delta_g +=
733 -r * self.temperature * k_eq.ln() + r * self.temperature * reaction_quotient.ln();
734 }
735
736 Ok(delta_g / 1000.0) }
738
739 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 let ha_initial = initial_concentrations[0];
754 let h_initial = initial_concentrations[1].max(1e-14); 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 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 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, residual: 0.0,
795 })
796 }
797
798 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 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 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 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 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, residual: 0.0,
852 })
853 }
854
855 fn improved_initial_guess(
857 &self,
858 initial_concentrations: &Array1<f64>,
859 k_eq: &Array1<f64>,
860 ) -> IntegrateResult<Array1<f64>> {
861 if self.species_names.len() == 5 && self.reaction_names.len() == 2 {
863 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 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 let mut guess = initial_concentrations.clone();
882
883 for (reaction_idx, &k) in k_eq.iter().enumerate() {
885 if reaction_idx >= self.reaction_names.len() {
886 continue;
887 }
888
889 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 let extent = if k > 1.0 {
905 min_ratio * 0.5 } else {
907 min_ratio * (k / (1.0 + k)).sqrt() };
909
910 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 fn buffer_initial_guess(
928 &self,
929 initial_concentrations: &Array1<f64>,
930 k_eq: &Array1<f64>,
931 ) -> IntegrateResult<Array1<f64>> {
932 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 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); guess[3] = estimated_oh.max(1e-12); Ok(guess)
948 }
949
950 fn amino_acid_initial_guess(
952 &self,
953 initial_concentrations: &Array1<f64>,
954 k_eq: &Array1<f64>,
955 ) -> IntegrateResult<Array1<f64>> {
956 let total_amino = initial_concentrations[0];
958 let ka1 = k_eq[0];
959 let ka2 = k_eq[1];
960
961 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 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 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); guess[1] = estimated_h.clamp(1e-12, 1e-1); guess[2] = ha_est.max(1e-12); guess[3] = a2_est.max(1e-12); let ka_ratio = ka1 / ka2.max(1e-15);
990 if ka_ratio > 1e6 {
991 guess[0] = 0.1 * total_amino; guess[1] = estimated_h.clamp(1e-12, 1e-3); guess[2] = 0.8 * total_amino; guess[3] = 0.1 * total_amino; }
997
998 Ok(guess)
999 }
1000
1001 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 return self.solve_with_mass_balance(jacobian, residuals, initial_concentrations);
1014 }
1015
1016 self.solve_linear_system(jacobian, residuals)
1018 }
1019
1020 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 let mut aug_jacobian = Array2::zeros((num_reactions + 1, num_species));
1033 let mut aug_residuals = Array1::zeros(num_reactions + 1);
1034
1035 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 if num_species >= 3 {
1045 aug_jacobian[(num_reactions, 0)] = 1.0; aug_jacobian[(num_reactions, 2)] = 1.0; aug_residuals[num_reactions] = 0.0; }
1050
1051 self.solve_linear_system(&aug_jacobian, &aug_residuals)
1053 }
1054}
1055
1056pub mod systems {
1058 use super::*;
1059 use scirs2_core::ndarray::{arr1, arr2};
1060
1061 pub fn weak_acid_equilibrium(
1063 ka: f64,
1064 _initial_acid: f64,
1065 _initial_ph: Option<f64>,
1066 ) -> EquilibriumCalculator {
1067 let stoichiometry = arr2(&[
1069 [-1.0, 1.0, 1.0], ]);
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 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 thermo_data[1].activity_params.charge = 1.0; thermo_data[2].activity_params.charge = -1.0; calculator.set_thermo_data(thermo_data);
1091 calculator.set_activity_model(ActivityModel::ExtendedDebyeHuckel);
1092
1093 calculator
1094 }
1095
1096 pub fn buffer_equilibrium(
1098 ka: f64,
1099 _acid_concentration: f64,
1100 _base_concentration: f64,
1101 ) -> EquilibriumCalculator {
1102 let stoichiometry = arr2(&[
1105 [-1.0, 1.0, 1.0, 0.0, 0.0], [0.0, 1.0, 0.0, 1.0, -1.0], ]);
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]); let mut calculator =
1123 EquilibriumCalculator::new(stoichiometry, species_names, reaction_names, k_eq);
1124
1125 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; thermo_data[2].activity_params.charge = -1.0; thermo_data[3].activity_params.charge = -1.0; calculator.set_thermo_data(thermo_data);
1145 calculator.set_activity_model(ActivityModel::ExtendedDebyeHuckel);
1146
1147 calculator
1148 }
1149
1150 pub fn complex_formation(
1152 k_formation: f64,
1153 _metal_conc: f64,
1154 _ligand_conc: f64,
1155 ) -> EquilibriumCalculator {
1156 let stoichiometry = arr2(&[
1158 [-1.0, -1.0, 1.0], ]);
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 pub fn solubility_equilibrium(
1170 ksp: f64,
1171 stoich_cation: f64,
1172 stoich_anion: f64,
1173 ) -> EquilibriumCalculator {
1174 let stoichiometry = arr2(&[
1176 [-1.0, stoich_cation, stoich_anion], ]);
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 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 pub fn amino_acid_equilibrium(
1204 ka1: f64, ka2: f64, _initial_conc: f64,
1207 ) -> EquilibriumCalculator {
1208 let stoichiometry = arr2(&[
1210 [-1.0, 1.0, 1.0, 0.0], [0.0, 1.0, -1.0, 1.0], ]);
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 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; thermo_data[2].activity_params.charge = -1.0; thermo_data[3].activity_params.charge = -2.0; 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 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 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 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 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 assert!(activity_coeffs[1] < 1.0); assert!(activity_coeffs[2] < 1.0); }
1306
1307 #[test]
1308 fn test_temperature_effects() {
1309 let mut calculator = systems::weak_acid_equilibrium(1e-5, 0.1, None);
1310
1311 calculator.temperature = 298.15; assert_eq!(calculator.temperature, 298.15);
1314
1315 calculator.temperature = 310.15; assert_eq!(calculator.temperature, 310.15);
1317
1318 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 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}