Skip to content

Commit 4e07936

Browse files
authored
Generic profiles (#2)
* Python: add finite-volume derivative matrix builder (build_matrices) * Scaffold ChromoSim Python package: matrices, sparsity, multi-species RHS * Generic salt/feed profiles, Jacobian sparsity, unpack helper
1 parent 458b25e commit 4e07936

File tree

10 files changed

+415
-0
lines changed

10 files changed

+415
-0
lines changed

chromosim/models/__init__.py

Whitespace-only changes.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from __future__ import annotations
2+
import numpy as np
3+
4+
def _get(par, key):
5+
try:
6+
return getattr(par, key)
7+
except AttributeError:
8+
return par[key]
9+
10+
def ev_column_ode_multi(t: float, y: np.ndarray, par) -> np.ndarray:
11+
"""
12+
Multi-species 1D convection–dispersion + competitive Langmuir adsorption
13+
with zeta- and ionic-strength–dependent kinetics, integrated in *scaled* vars.
14+
15+
y = [ c(:) ; qhat(:) ], with c,Q scaled by Cscale,Qscale respectively.
16+
"""
17+
N = int(_get(par, "N"))
18+
Ns = int(_get(par, "Nsp"))
19+
dz = float(_get(par, "dz"))
20+
u = float(_get(par, "u"))
21+
Dax = float(_get(par, "Dax"))
22+
eps = float(_get(par, "eps"))
23+
24+
Cscale = float(_get(par, "Cscale"))
25+
Qscale = float(_get(par, "Qscale"))
26+
qmax = float(_get(par, "qmax"))
27+
28+
zeta_mV = np.asarray(_get(par, "zeta_mV"), dtype=float).reshape(-1) # (Ns,)
29+
NN = N * Ns
30+
31+
c = y[0:NN].reshape(N, Ns) # dimensionless
32+
qhat = y[NN:2*NN].reshape(N, Ns) # dimensionless
33+
34+
C = c * Cscale # physical
35+
Q = qhat * Qscale
36+
37+
I = float(_get(par, "salt_profile")(t)) # ionic strength (scalar)
38+
fI = float(_get(par, "fI")(I))
39+
40+
K0 = float(_get(par, "K0"))
41+
gamma = float(_get(par, "gamma"))
42+
Ki_vec = K0 * np.exp(gamma * np.abs(zeta_mV) * fI) # (Ns,)
43+
44+
# desorption (constant or function of I)
45+
kd_fun = getattr(par, "kd_fun", None) if hasattr(par, "kd_fun") else _get(par, "kd0")
46+
if callable(kd_fun):
47+
kd_vec = np.asarray(kd_fun(I), dtype=float).reshape(-1)
48+
if kd_vec.size == 1:
49+
kd_vec = np.repeat(kd_vec, Ns)
50+
else:
51+
kd_vec = np.ones(Ns) * float(_get(par, "kd0"))
52+
53+
ka_vec = Ki_vec * kd_vec # (Ns,)
54+
55+
ka = np.broadcast_to(ka_vec, (N, Ns))
56+
kd = np.broadcast_to(kd_vec, (N, Ns))
57+
58+
Qsum = np.sum(Q, axis=1) # (N,)
59+
Qfree = np.maximum(qmax - Qsum, 0.0) # (N,)
60+
Qfree_mat = Qfree[:, None] # (N,1) -> broadcast
61+
62+
dQdt = ka * C * Qfree_mat - kd * Q
63+
dqhatdt = dQdt / Qscale
64+
65+
D1 = _get(par, "D1")
66+
D2 = _get(par, "D2")
67+
dcdz = D1.dot(c)
68+
d2cdz = D2.dot(c)
69+
70+
beta = ((1.0 - eps) / eps) * (Qscale / Cscale)
71+
dcdt = -u * dcdz + Dax * d2cdz - beta * dqhatdt
72+
73+
# inlet boundary: Dirichlet via ghost/upwind (scaled)
74+
Cin_phys = np.asarray(_get(par, "feed_profile")(t), dtype=float).reshape(-1)
75+
if Cin_phys.size != Ns:
76+
raise ValueError(f"feed_profile(t) must return length {Ns}, got {Cin_phys.size}")
77+
cin = Cin_phys / Cscale
78+
dcdt[0, :] += (-u/dz) * (c[0, :] - cin) + (Dax/dz**2) * (cin - c[0, :])
79+
80+
return np.concatenate([dcdt.reshape(-1), dqhatdt.reshape(-1)])

chromosim/models/params_ev_aex.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
from types import SimpleNamespace as NS
3+
from ..profiles.salt_and_feed import salt_profile_clamped_linear, feed_profile_by_load_volume
4+
5+
def params_ev_aex():
6+
"""
7+
Multi-species parameter set matching your MATLAB 'params_ev_aex.m'.
8+
Returns a SimpleNamespace so attributes can be attached.
9+
"""
10+
par = NS()
11+
# Geometry / grid
12+
par.L = 0.025 # m
13+
par.dID = 0.007 # m
14+
par.eps = 0.40
15+
par.N = 80
16+
par.dz = par.L / par.N
17+
18+
# Hydro
19+
par.u = 1.0e-4 # m s^-1
20+
par.Dax = 8e-11 # m^2 s^-1
21+
22+
# EV classes
23+
par.zeta_mV = [-12, -25, -45]
24+
par.frac = [0.10, 0.15, 0.75]
25+
par.Nsp = len(par.zeta_mV)
26+
27+
# Scaling / capacity
28+
par.Cfeed_total = 7.5e9 # particles m^-3
29+
par.qmax = 4e13 # particles m^-3 wall
30+
par.Cscale = par.Cfeed_total
31+
par.Qscale = par.qmax
32+
par.CplotScale = 1e10
33+
par.CplotLabel = r"10^{10} per m^{-3}"
34+
35+
# Kinetics & zeta mapping
36+
par.kd0 = 2e-3
37+
par.K0 = 1e-22
38+
par.gamma = 0.53
39+
par.Imax = 1.2
40+
par.fI = lambda I: max(0.0, 1.0 - float(I)/par.Imax)
41+
42+
# Gradient
43+
par.grad_start = 460.0
44+
par.grad_end = par.grad_start + 1200.0
45+
par.I_load = 0.05
46+
par.I_elute = 1.0
47+
par.salt_profile = lambda t: salt_profile_clamped_linear(t, par)
48+
49+
# Feed (physical units)
50+
# flow & load (optional but enables volume-based load windows)
51+
par.flow_mL_min = 1.0
52+
par.load_mL = 5.0
53+
par.feed_profile = lambda t: feed_profile_by_load_volume(t, par)
54+
55+
# Time / solver tolerances (used by runner)
56+
par.tspan = (0.0, 3000.0) # s
57+
par.rtol = 1e-6
58+
par.atol = 1e-6
59+
par.max_step = 8.0
60+
61+
# Plot helper
62+
par.B_start = 10.0
63+
par.B_end = 100.0
64+
return par
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
from types import SimpleNamespace as NS
3+
from ..profiles.salt_and_feed import salt_profile_clamped_linear, feed_profile_by_load_volume
4+
5+
def params_ev_aex_single():
6+
"""Single-species parameter set matching your MATLAB 'params_ev_aex_single.m'."""
7+
par = NS()
8+
# Geometry / grid
9+
par.L = 0.025
10+
par.dID = 0.007
11+
par.eps = 0.40
12+
par.N = 80
13+
par.dz = par.L / par.N
14+
15+
# Hydro
16+
par.u = 1.0e-4
17+
par.Dax = 9e-11
18+
19+
# EV class (single)
20+
par.zeta_mV = [-25]
21+
par.frac = [1.0]
22+
par.Nsp = 1
23+
24+
# Scaling / capacity
25+
par.Cfeed_total = 5e9
26+
par.qmax = 4e14
27+
par.Cscale = par.Cfeed_total
28+
par.Qscale = par.qmax
29+
par.CplotScale = 1e10
30+
par.CplotLabel = r"10^{10} per m^{-3}"
31+
32+
# Kinetics & zeta mapping
33+
par.kd0 = 1e-2
34+
par.K0 = 1e-22
35+
par.gamma = 0.750
36+
par.Imax = 1.2
37+
par.fI = lambda I: max(0.0, 1.0 - float(I)/par.Imax)
38+
39+
# Gradient
40+
par.grad_start = 12.0*60.0
41+
par.grad_end = par.grad_start + 20.0*60.0
42+
par.I_load = 0.01
43+
par.I_elute = 1.0
44+
par.salt_profile = lambda t: salt_profile_clamped_linear(t, par)
45+
46+
# Feed (physical units)
47+
# flow & load for a clear 5 mL at 1 mL/min → 300 s load
48+
par.flow_mL_min = 1.0
49+
par.load_mL = 5.0
50+
par.feed_profile = lambda t: feed_profile_by_load_volume(t, par)
51+
52+
# Time / solver tolerances
53+
par.tspan = (0.0, 45.0*60.0)
54+
par.rtol = 1e-6
55+
par.atol = 1e-6
56+
par.max_step = 8.0
57+
58+
# Plot helper
59+
par.B_start = 10.0
60+
par.B_end = 100.0
61+
return par

chromosim/numerics/sparsity.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from __future__ import annotations
2+
import numpy as np
3+
from scipy.sparse import spdiags, kron, eye, bmat, csr_matrix
4+
5+
def sparsity_pattern_multi(N: int, Ns: int):
6+
"""
7+
Boolean sparse pattern for the Jacobian of size 2*N*Ns × 2*N*Ns.
8+
State ordering:
9+
[ c(1..N, species 1..Ns) ; qhat(1..N, species 1..Ns) ]
10+
Blocks:
11+
dc/dc ~ tri-diagonal in z for each species
12+
dc/dq ~ diagonal (local kinetics)
13+
dq/dc ~ diagonal (local kinetics)
14+
dq/dq ~ diagonal
15+
"""
16+
e = np.ones(N)
17+
T = spdiags([e, e, e], [-1, 0, 1], N, N, format='csr') # tri-diagonal
18+
# replicate per species
19+
Ac = kron(eye(Ns, format='csr'), (T != 0), format='csr') # Nc×Nc, Nc=N*Ns
20+
21+
Nc = N * Ns
22+
B = eye(Nc, format='csr', dtype=bool) # diagonal
23+
Iq = eye(Nc, format='csr', dtype=bool)
24+
25+
# assemble block matrix
26+
S = bmat([[Ac.astype(bool), B],
27+
[B, Iq]], format='csr')
28+
return S

chromosim/profiles/__init__.py

Whitespace-only changes.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
import numpy as np
3+
4+
def salt_profile_clamped_linear(t, par):
5+
"""
6+
Clamped linear ramp:
7+
I(t) = I_load + (I_elute - I_load) * clamp((t - grad_start)/(grad_end - grad_start), 0, 1)
8+
Accepts scalar or array t. Returns same shape as t.
9+
"""
10+
tarr = np.atleast_1d(np.asarray(t, dtype=float))
11+
frac = (tarr - par.grad_start) / (par.grad_end - par.grad_start)
12+
frac = np.clip(frac, 0.0, 1.0)
13+
I = par.I_load + (par.I_elute - par.I_load) * frac
14+
return I if np.ndim(t) else float(I.item())
15+
16+
def feed_profile_by_load_volume(t, par):
17+
"""
18+
Physical inlet concentration vector for Ns species (length Ns).
19+
During the 'load' window, Cin = Cfeed_total * frac; otherwise zeros.
20+
The load window is computed as:
21+
load_duration_s = 60 * load_mL / flow_mL_min
22+
If those fields are missing, defaults to 300 s.
23+
Works with scalar or array t. For scalar t returns 1×Ns array.
24+
"""
25+
Ns = par.Nsp
26+
tarr = np.atleast_1d(np.asarray(t, dtype=float))
27+
Cin = np.zeros((tarr.size, Ns), dtype=float)
28+
29+
# derive load duration if not given
30+
if not hasattr(par, "load_duration_s"):
31+
if hasattr(par, "load_mL") and hasattr(par, "flow_mL_min"):
32+
par.load_duration_s = 60.0 * float(par.load_mL) / float(par.flow_mL_min)
33+
else:
34+
par.load_duration_s = 300.0 # sensible default (5 min)
35+
36+
mask_load = tarr < par.load_duration_s
37+
if np.any(mask_load):
38+
Cin[mask_load, :] = (par.Cfeed_total * np.asarray(par.frac)).reshape(1, Ns)
39+
40+
if np.ndim(t) == 0:
41+
return Cin[0, :]
42+
return Cin

chromosim/util/__init__.py

Whitespace-only changes.

chromosim/util/unpack.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
import numpy as np
3+
4+
def unpack_state(Y: np.ndarray, par):
5+
"""
6+
Reshape solution (Nt × (2*N*Ns)) into:
7+
C : Nt × N × Ns (mobile phase, physical or scaled depending on caller)
8+
q : Nt × N × Ns (bound phase)
9+
"""
10+
Nt, total = Y.shape
11+
N, Ns = par.N, par.Nsp
12+
NN = N * Ns
13+
C = Y[:, :NN].reshape(Nt, N, Ns)
14+
q = Y[:, NN:].reshape(Nt, N, Ns)
15+
return C, q

0 commit comments

Comments
 (0)