gradostat_multiperiod_socp/gradostat_multiperiod_socp.rs
1//! Multi-period second-order cone optimization of the gradostat (model `P_RC`).
2//!
3//! Replication of Section 7.2 of Taylor & Rapaport (2021).
4//! A four-tank gradostat with constant flows and diffusions is
5//! optimized over `TAU = 1000` time periods to maximize cumulative
6//! biogas production.
7//!
8//! # Model
9//!
10//! Growth parameters `mu_max = K = y = 1`, unit tank volumes `V = I`, Euler's
11//! explicit method with time step `Delta = 1`, and a periodic horizon
12//! (`S(1) = S(TAU+1)`, `X(1) = X(TAU+1)`).
13//!
14//! ```text
15//! max sum_{i,t} T_i(t) (eq. 24)
16//! s.t. S_i(t+1) - S_i(t) = -T_i(t) + [(M+L) S(t)]_i + Qin_i Sin_i(t) (25a)
17//! X_i(t+1) - X_i(t) = T_i(t) + [(M+L) X(t)]_i + Qin_i Xin_i(t) (25b)
18//! || [S_i(t), T_i(t), X_i(t)] || <= X_i(t) + S_i(t) - T_i(t) (eq. 9a)
19//! S_i(t) - T_i(t) >= 0 (eq. 9b)
20//! sum_i Qin_i Xin_i(t) <= 3 (biomass-inflow budget)
21//! S, X, T >= 0, Xin >= 0
22//! ```
23//!
24//! The substrate inflow `Sin(t)` is a fixed data profile, the biomass inflow
25//! `Xin(t)` is the decision variable.
26//!
27//! # Reproduction vs. the authors' code
28//!
29//! This model matches the authors' reference implementation and
30//! returns the same values.
31//!
32//! ## Details matched to the MATLAB code:
33//!
34//! 1. Diffusion `d = 0.3` on each existing pipe (`d = 0.3*(Q>0)`).
35//! 2. Budget on the inflow `Xin(t)`: `Qin' Xin(t) <= 3`, not on the tank
36//! concentration `X(t)`.
37//! 3. Time base: The paper indexes `t in {1..TAU}`, here periods are
38//! `p in {0..TAU-1}` with `t = p + 1` used in the `Sin` profiles.
39//!
40//! Run:
41//! cargo run -p oximo --example gradostat_multiperiod_socp --features clarabel
42//!
43//! References:
44//! - Taylor, J. A., & Rapaport, A. (2021).
45//! Second-order cone optimization of the gradostat.
46//! Computers & Chemical Engineering, 151, 107347.
47//! <https://doi.org/10.1016/j.compchemeng.2021.107347>
48
49#[cfg(feature = "clarabel")]
50fn main() -> Result<(), Box<dyn std::error::Error>> {
51 use oximo::ClarabelOptions;
52 use oximo::prelude::*;
53 use oximo::solvers::Clarabel;
54
55 // Problem data
56 const N: usize = 4; // tanks
57 const TAU: usize = 1000; // time periods
58
59 // Forced-flow + diffusion matrix M + L.
60 const ML: [[f64; N]; N] = [
61 [-2.3, 0.3, 0.0, 0.0],
62 [1.3, -3.9, 0.3, 1.3],
63 [0.0, 2.3, -3.6, 0.3],
64 [0.0, 0.3, 1.3, -2.6],
65 ];
66
67 // C = diag(Qin), the external water inflow at each tank.
68 const QIN: [f64; N] = [2.0, 1.0, 1.0, 1.0];
69
70 // Total biomass mass allowed in the tanks per period: 1' Qin X(t) <= BUDGET.
71 const BUDGET: f64 = 3.0;
72
73 // Substrate inflow profiles Sin_i(t), indexed sin[i][p] with paper time t=p+1.
74 #[expect(clippy::needless_range_loop, clippy::cast_precision_loss)]
75 let sin: Vec<Vec<f64>> = {
76 let pi = std::f64::consts::PI;
77 let tau = TAU as f64;
78 let mut sin = vec![vec![0.0f64; TAU]; N];
79 for p in 0..TAU {
80 let t = (p + 1) as f64;
81 let ti = p + 1;
82 sin[0][p] = 1.0 + (4.0 * pi * t / tau).sin();
83 sin[1][p] = 0.0;
84 sin[2][p] = if ti > TAU / 4 && ti <= 3 * TAU / 4 { 0.5 } else { 0.0 };
85 sin[3][p] = 1.0 + (4.0 * pi * t / tau).cos();
86 }
87 sin
88 };
89
90 let m = Model::new("gradostat_multiperiod");
91 let tanks = Set::range(0..N);
92 let periods = Set::range(0..TAU);
93
94 variable!(m, s[i in tanks, p in periods] >= 0.0); // substrate S
95 variable!(m, x[i in tanks, p in periods] >= 0.0); // biomass X
96 variable!(m, kin[i in tanks, p in periods] >= 0.0); // kinetics T
97 variable!(m, xin[i in tanks, p in periods] >= 0.0); // biomass inflow Xin (decision)
98
99 // Dynamic substrate and biomass balances (25a, 25b), Euler-explicit, V=y=1.
100 constraint!(m, s_bal[i in tanks, p in periods],
101 s[i, (p + 1) % TAU] - s[i, p]
102 == -kin[i, p] + sum!(ML[i][j] * s[j, p] for j in tanks) + QIN[i] * sin[i][p]);
103 constraint!(m, x_bal[i in tanks, p in periods],
104 x[i, (p + 1) % TAU] - x[i, p]
105 == kin[i, p] + sum!(ML[i][j] * x[j, p] for j in tanks) + QIN[i] * xin[i, p]);
106
107 // Contois growth as a second-order cone (eq. 9, mu_max = K = 1).
108 soc_constraint!(m, growth[i in tanks, p in periods],
109 [s[i, p], kin[i, p], x[i, p]] <= x[i, p] + s[i, p] - kin[i, p]);
110 constraint!(m, st_diff[i in tanks, p in periods], s[i, p] - kin[i, p] >= 0.0);
111
112 // Per-period biomass-inflow budget, 1' Qin Xin(t) <= BUDGET.
113 constraint!(m, bio_budget[p in periods], sum!(QIN[i] * xin[i, p] for i in tanks) <= BUDGET);
114
115 // Maximize cumulative biogas, sum_{i,t} V_ii T_i with V = I (eq. 24, undiscounted).
116 objective!(m, Max, sum!(kin[i, p] for i in tanks, p in periods));
117
118 assert_eq!(m.kind(), ModelKind::SOCP);
119
120 let res = Clarabel.solve(&m, &ClarabelOptions::default())?;
121 assert_eq!(res.termination, TerminationStatus::Optimal);
122
123 // Exactness metric E = max_{i,t} |r(S,X)-T|/r(S,X), with the Contois
124 // kinetics r(S,X) = mu_max S X/(K X + S) evaluated at the solution.
125 let mut e_max = 0.0f64;
126 for i in 0..N {
127 for p in 0..TAU {
128 let sv = res.value_of(s[(i, p)])?.unwrap();
129 let xv = res.value_of(x[(i, p)])?.unwrap();
130 let tv = res.value_of(kin[(i, p)])?.unwrap();
131 let denom = sv + xv;
132 if denom > 1e-9 {
133 let r = sv * xv / denom;
134 if r > 1e-9 {
135 e_max = e_max.max((r - tv).abs() / r);
136 }
137 }
138 }
139 }
140
141 println!("Results:");
142 println!("Termination: {:?}", res.termination);
143 println!("Objective: {:.4}", res.objective().unwrap());
144 println!("Exactness E: {e_max:.3e}");
145 println!("Iterations: {}", res.iterations);
146 println!("Solve time: {:.3} s", res.solve_time.as_secs_f64());
147
148 Ok(())
149}
150
151#[cfg(not(feature = "clarabel"))]
152fn main() {
153 eprintln!("This example needs the clarabel feature:");
154 eprintln!(" cargo run -p oximo --example gradostat_multiperiod_socp --features clarabel");
155}