Skip to content

Commit 42c95fe

Browse files
committed
Generic salt/feed profiles, Jacobian sparsity, unpack helper
1 parent 0fca4d1 commit 42c95fe

File tree

9 files changed

+362
-36
lines changed

9 files changed

+362
-36
lines changed

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
Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,58 @@
11
from __future__ import annotations
2+
from collections.abc import Mapping
23
import numpy as np
3-
from scipy.sparse import diags, csc_matrix
4+
from scipy.sparse import diags
5+
from .sparsity import sparsity_pattern_multi
6+
7+
def _get(par, key):
8+
"""Get a parameter from either a Mapping (dict) or an object with attrs."""
9+
if isinstance(par, Mapping):
10+
return par[key]
11+
return getattr(par, key)
12+
13+
def _set(par, key, value):
14+
"""Set a parameter on either a Mapping (dict) or an object with attrs."""
15+
if isinstance(par, Mapping):
16+
par[key] = value
17+
else:
18+
setattr(par, key, value)
419

520
def build_matrices(par):
621
"""
722
Assemble finite-volume derivative matrices in z and attach to `par`.
8-
par.N, par.dz must exist.
23+
Requires: par.N (int), par.dz (float).
924
Adds:
1025
par.D1 -- first derivative (backward/upwind) [N x N] sparse
1126
par.D2 -- second derivative (central) [N x N] sparse
1227
"""
13-
N = int(getattr(par, "N", par["N"]))
14-
dz = float(getattr(par, "dz", par["dz"]))
28+
N = int(_get(par, "N"))
29+
dz = float(_get(par, "dz"))
1530

1631
e = np.ones(N)
17-
# Backward/upwind first derivative
18-
D1 = diags([-e, e], offsets=[-1, 0], shape=(N, N), format="csc") / dz
19-
D1 = D1.tolil()
32+
33+
# 1) Backward/upwind first derivative
34+
D1 = diags([-e, e], offsets=[-1, 0], shape=(N, N), format="lil") / dz
2035
D1[0, :] = 0 # inlet row handled via boundary condition
2136
D1 = D1.tocsc()
2237

23-
# Central second derivative
24-
D2 = diags([e, -2*e, e], offsets=[-1, 0, 1], shape=(N, N), format="csc") / (dz**2)
25-
D2 = D2.tolil()
26-
D2[0, 0] = -2.0 / (dz**2) # ghost for inlet Dirichlet
27-
D2[-1, -1] = 1.0 / (dz**2) # Danckwerts outlet
38+
# 2) Central second derivative
39+
D2 = diags([e, -2.0 * e, e], offsets=[-1, 0, 1], shape=(N, N), format="lil") / (dz**2)
40+
D2[0, 0] = -2.0 / (dz**2) # ghost for inlet Dirichlet
41+
D2[-1, -1] = 1.0 / (dz**2) # Danckwerts outlet
2842
D2 = D2.tocsc()
2943

30-
setattr(par, "D1", D1)
31-
setattr(par, "D2", D2)
44+
_set(par, "D1", D1)
45+
_set(par, "D2", D2)
3246
return par
3347

34-
# Optional helper to attach Jacobian sparsity later
35-
from .sparsity import jacobian_sparsity_multi
36-
3748
def attach_jacobian_sparsity(par):
38-
N = int(getattr(par, "N", par["N"]))
39-
Ns = int(getattr(par, "Nsp", par["Nsp"]))
40-
J = jacobian_sparsity_multi(N, Ns)
41-
setattr(par, "Jpattern", J)
49+
"""
50+
Attach Jacobian sparsity pattern for multi-species model.
51+
Requires: par.N, par.Nsp.
52+
Adds: par.Jpattern
53+
"""
54+
N = int(_get(par, "N"))
55+
Nsp = int(_get(par, "Nsp"))
56+
Jpat = sparsity_pattern_multi(N, Nsp)
57+
_set(par, "Jpattern", Jpat)
4258
return par

chromosim/numerics/sparsity.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,28 @@
11
from __future__ import annotations
22
import numpy as np
3-
from scipy.sparse import diags, eye, bmat, csc_matrix
3+
from scipy.sparse import spdiags, kron, eye, bmat, csr_matrix
44

5-
def jacobian_sparsity_multi(N: int, Ns: int) -> csc_matrix:
5+
def sparsity_pattern_multi(N: int, Ns: int):
66
"""
7-
2*N*Ns x 2*N*Ns logical Jacobian sparsity for multi-species column model.
8-
9-
State layout (row-major):
10-
y = [ c(0,0..Ns-1), ..., c(N-1,0..Ns-1),
11-
qhat(0,0..Ns-1), ..., qhat(N-1,0..Ns-1) ]
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
1215
"""
1316
e = np.ones(N)
14-
T = diags([e, e, e], offsets=[-1, 0, 1], shape=(N, N), format="csc")
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
1520

16-
# replicate tri-diagonal axial stencil per species
17-
Ac = bmat([[T if s == r else None for s in range(Ns)]
18-
for r in range(Ns)], format="csc")
1921
Nc = N * Ns
20-
I = eye(Nc, format="csc")
22+
B = eye(Nc, format='csr', dtype=bool) # diagonal
23+
Iq = eye(Nc, format='csr', dtype=bool)
2124

22-
S = bmat([[Ac, I],
23-
[I, I]], format="csc")
24-
S.data[:] = 1
25+
# assemble block matrix
26+
S = bmat([[Ac.astype(bool), B],
27+
[B, Iq]], format='csr')
2528
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)