pysfbox 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pysfbox/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """
2
+ PySFBox -- the Scheutjens-Fleer self-consistent-field (SF-SCF) lattice theory
3
+ for polymers and surfactants at interfaces, in pure Python/NumPy. It reads and
4
+ writes the input/output file format of the classic sfbox and Namics programs.
5
+
6
+ Scope (v1): one gradient direction (planar / cylindrical / spherical,
7
+ including FJC_choices lattice refinement) plus the Namics two- and
8
+ three-gradient lattices (2D flat, 2D cylindrical, 3D flat); linear
9
+ (multi-block) and branched chains, monomeric solvents, frozen surfaces,
10
+ pinned (grafted) segments, Flory-Huggins chi interactions; charged systems
11
+ (fixed charges, electrodes, and weak/multistate charges via
12
+ state/reaction); var scans and search/super-iteration. Unknown keywords are
13
+ reported and ignored; unknown output properties print "NiN", exactly like
14
+ Namics; unsupported features raise a clear NotImplementedError.
15
+
16
+ Every supported feature is validated against the compiled Namics at or near
17
+ machine precision (regression suite in tests/); the pure-NumPy source doubles
18
+ as a readable reference implementation of the SF-SCF machinery.
19
+
20
+ Usage:
21
+ python -m pysfbox path/to/input.in
22
+
23
+ The architecture mirrors Namics (Lattice / Segment / Molecule / System /
24
+ Solver / Output) so that extensions slot in where they live in the C++
25
+ original.
26
+ """
27
+
28
+ from .system import System
29
+ from .inputreader import read_input
30
+ from .runner import run_file
31
+
32
+ __version__ = "1.0.0"
33
+ __all__ = ["System", "read_input", "run_file", "__version__"]
pysfbox/__main__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Command-line entry point:
2
+ python -m pysfbox input.in [input2.in ...] run each file (one process)
3
+ """
4
+
5
+ import sys
6
+
7
+ from .runner import run_file
8
+
9
+
10
+ def main(argv=None):
11
+ args = (argv or sys.argv)[1:]
12
+ if not args or args[0] in ("-h", "--help"):
13
+ print(__doc__)
14
+ print("Runs Scheutjens-Fleer SCF on Namics-format input files;")
15
+ print("writes .kal/.pro output files next to each input file.")
16
+ return 1
17
+ for path in args:
18
+ print(f"== {path}")
19
+ run_file(path)
20
+ return 0
21
+
22
+
23
+ if __name__ == "__main__":
24
+ sys.exit(main())
pysfbox/inputreader.py ADDED
@@ -0,0 +1,91 @@
1
+ """Namics input-file parser.
2
+
3
+ Namics input files consist of lines
4
+
5
+ keyword : name : parameter : value
6
+
7
+ with `//` comments, blank lines, and the word `start` on its own line closing
8
+ a calculation. Settings accumulate across `start` blocks (later blocks add to
9
+ or override earlier ones), exactly as in Namics. `alias : X : value : v`
10
+ defines `#X#` substitutions inside composition strings, and a `var` block
11
+ defines a parameter scan within one calculation.
12
+
13
+ Because output-specification lines may repeat a parameter with different
14
+ values (e.g. `kal : mon : G : 1st_M_phi_z` and `kal : mon : G : 2nd_M_phi_z`),
15
+ every parameter stores a LIST of values; use `last(...)` for normal settings
16
+ (override semantics) and the full list for output specs.
17
+ """
18
+
19
+ from collections import OrderedDict
20
+
21
+
22
+ def _parse_line(line):
23
+ line = line.split("//")[0].strip()
24
+ if not line:
25
+ return None
26
+ if line.lower() == "start":
27
+ return "START"
28
+ parts = [p.strip() for p in line.split(":")]
29
+ if len(parts) < 4:
30
+ raise ValueError(f"cannot parse input line: '{line}'")
31
+ key, name, param = parts[0], parts[1], parts[2]
32
+ value = ":".join(parts[3:]).strip()
33
+ return key, name, param, value
34
+
35
+
36
+ def read_input(path):
37
+ """Parse a Namics .in file -> list of calculations.
38
+
39
+ Each calculation is settings[(key, name)][param] = [values...], with
40
+ settings accumulated over consecutive `start` blocks.
41
+ """
42
+ with open(path) as f:
43
+ lines = f.readlines()
44
+
45
+ calculations = []
46
+ settings = OrderedDict()
47
+ block_has_content = False
48
+ for ln in lines:
49
+ parsed = _parse_line(ln)
50
+ if parsed is None:
51
+ continue
52
+ if parsed == "START":
53
+ calculations.append(_copy(settings))
54
+ block_has_content = False
55
+ continue
56
+ key, name, param, value = parsed
57
+ params = settings.setdefault((key, name), OrderedDict())
58
+ params.setdefault(param, []).append(value)
59
+ block_has_content = True
60
+ if block_has_content or not calculations:
61
+ calculations.append(_copy(settings))
62
+ return calculations
63
+
64
+
65
+ def _copy(settings):
66
+ return OrderedDict((k, OrderedDict((p, list(v)) for p, v in d.items()))
67
+ for k, d in settings.items())
68
+
69
+
70
+ def last(params, key, default=None):
71
+ """Override semantics: the last value given for a parameter."""
72
+ v = params.get(key)
73
+ return v[-1] if v else default
74
+
75
+
76
+ def get_blocks(settings, key):
77
+ """All (name, params) blocks for a keyword, in input order."""
78
+ return [(name, params) for (k, name), params in settings.items()
79
+ if k == key]
80
+
81
+
82
+ def set_value(settings, key, name, param, value):
83
+ settings.setdefault((key, name), OrderedDict())[param] = [str(value)]
84
+
85
+
86
+ def substitute_aliases(text, settings):
87
+ """Replace #X# by the value of `alias : X : value`."""
88
+ for (key, name), params in settings.items():
89
+ if key == "alias" and "value" in params:
90
+ text = text.replace(f"#{name}#", str(params["value"][-1]))
91
+ return text
pysfbox/lattice.py ADDED
@@ -0,0 +1,217 @@
1
+ """One-gradient lattice: planar, cylindrical, or spherical.
2
+
3
+ Interior sites z = fjc .. MX+fjc-1 with `fjc` ghost layers on each side for the
4
+ boundary conditions, exactly as in Namics LGrad1.cpp. With fjc = 1 (the default)
5
+ this is the familiar z = 1..MX plus ghosts 0 and MX+1, first-order Markov chains,
6
+ and the three-point step weights lambda_1 (toward z-1), lambda0, lambda1 (toward
7
+ z+1).
8
+
9
+ `FJC_choices > 3` refines the lattice: fjc = (FJC_choices-1)/2 sub-layers per
10
+ bond length, so the internal MX = fjc * n_layers and the propagator becomes a
11
+ (2*fjc+1)-point stencil `LAMBDA[c, z]` (c = 0..FJC-1, neighbour offset c-fjc)
12
+ with curvature-weighted, position-dependent coefficients (Namics
13
+ ComputeLambdas). fjc = 1 keeps the original three-point code bit-for-bit.
14
+ """
15
+
16
+ import numpy as np
17
+
18
+ LAMBDA = {"simple_cubic": 1.0 / 6.0, "hexagonal": 0.25}
19
+
20
+
21
+ class Lattice1D:
22
+ def __init__(self, n_layers, geometry="planar",
23
+ lattice_type="simple_cubic", lowerbound="mirror",
24
+ upperbound="mirror", offset_first_layer=0.0, fjc=1):
25
+ if geometry == "flat": # Namics accepts flat as planar synonym
26
+ geometry = "planar"
27
+ self.geometry = geometry
28
+ self.lattice_type = lattice_type
29
+ self.lowerbound, self.upperbound = lowerbound, upperbound
30
+ # chain-stiffness defaults (Namics reads Markov/k_stiff at both lat
31
+ # and mol level; System fills these from the lat block and each
32
+ # Molecule may override with its own mol : X : Markov / k_stiff).
33
+ self.markov = 1
34
+ self.k_stiff = 0.0
35
+ self.fjc = int(fjc)
36
+ self.FJC = 2 * self.fjc + 1
37
+ self.offset = float(offset_first_layer)
38
+ self.MX = self.fjc * int(n_layers) # internal (refined) sites
39
+ self.M = self.MX + 2 * self.fjc # incl. fjc ghosts each side
40
+ self.iv = slice(self.fjc, self.M - self.fjc) # interior slice
41
+ self.gradients = 1
42
+ interior = np.zeros(self.M, dtype=bool) # flat interior mask
43
+ interior[self.iv] = True # (uniform ND-style API)
44
+ self.interior = interior
45
+ lam = LAMBDA[lattice_type]
46
+ self.lam = lam
47
+ # physical coordinate of the interior sites (Namics .pro: (k+0.5)/fjc);
48
+ # for fjc=1 this is k+0.5 = z-0.5, matching the original output/moments
49
+ self.z_phys = (np.arange(self.MX) + 0.5) / self.fjc
50
+
51
+ if self.fjc == 1:
52
+ self._setup_fjc1(geometry, lam, offset_first_layer)
53
+ else:
54
+ self._setup_fjc(geometry)
55
+
56
+ # ---- fjc = 1: original three-point lattice (unchanged) ------------------
57
+ def _setup_fjc1(self, geometry, lam, offset_first_layer):
58
+ z = np.arange(self.M, dtype=float) # ghost, 1..MX, ghost
59
+ r = offset_first_layer + z
60
+ if geometry == "planar":
61
+ self.L = np.ones(self.M)
62
+ self.l1 = np.full(self.M, lam) # weight toward z+1
63
+ self.l_1 = np.full(self.M, lam) # weight toward z-1
64
+ elif geometry == "cylindrical":
65
+ self.L = np.pi * (r**2 - (r - 1) ** 2)
66
+ self.l1 = 2 * np.pi * r * lam / self.L
67
+ self.l_1 = 2 * np.pi * (r - 1) * lam / self.L
68
+ elif geometry == "spherical":
69
+ self.L = 4.0 / 3.0 * np.pi * (r**3 - (r - 1) ** 3)
70
+ self.l1 = 4 * np.pi * r**2 * lam / self.L
71
+ self.l_1 = 4 * np.pi * (r - 1) ** 2 * lam / self.L
72
+ else:
73
+ raise ValueError(f"unknown geometry '{geometry}'")
74
+ self.l0 = 1.0 - self.l1 - self.l_1
75
+ self.volume = (self.L[self.iv].sum() if geometry != "planar"
76
+ else float(self.MX))
77
+
78
+ # ---- fjc > 1: refined (2*fjc+1)-point lattice (Namics ComputeLambdas) ---
79
+ def _setup_fjc(self, geometry):
80
+ if geometry not in ("spherical", "cylindrical"):
81
+ raise NotImplementedError(
82
+ f"FJC_choices > 1 with geometry '{geometry}' is not supported "
83
+ "yet (not yet ported to PySFBox); PySFBox has spherical/cylindrical")
84
+ fjc, FJC, MX, M = self.fjc, self.FJC, self.MX, self.M
85
+ off = self.offset
86
+ L = np.zeros(M)
87
+ LAM = np.zeros((FJC, M)) # LAM[c, i], neighbour offset c-fjc
88
+ sph = geometry == "spherical"
89
+ if sph: # sphere: C_ext=1/2 C_mid, area=4r^2
90
+ area = lambda rr: 4.0 * rr * rr
91
+ c_ext, c_mid = 0.5 / (FJC - 1), 1.0 / (FJC - 1)
92
+ else: # cylinder: middle area doubled, area=r
93
+ area = lambda rr: rr
94
+ c_ext, c_mid = 1.0 / (FJC - 1), 2.0 / (FJC - 1)
95
+ for i in range(fjc, M - fjc):
96
+ r = off + (i - fjc + 1.0) / fjc
97
+ rlow, rhigh = r - 0.5, r + 0.5
98
+ if sph:
99
+ L[i] = np.pi * 4.0 / 3.0 * (rhigh**3 - rlow**3) / fjc
100
+ VL = 4.0 / 3.0 * (rhigh**3 - rlow**3)
101
+ else:
102
+ L[i] = np.pi * (2.0 * r) / fjc
103
+ VL = 2.0 * r
104
+ edge = MX / fjc
105
+ # outermost channels (bond endpoints)
106
+ if 2 * rlow - r > 0:
107
+ LAM[0, i] += c_ext * area(rlow) / VL
108
+ if 2 * rhigh - r < edge:
109
+ LAM[FJC - 1, i] += c_ext * area(rhigh) / VL
110
+ else: # reflect off the outer mirror
111
+ self._reflect(LAM, FJC - 1, i, r, rhigh, edge, fjc, c_ext, VL, area)
112
+ # inner channels
113
+ for j in range(1, fjc):
114
+ rlow += 0.5 / fjc
115
+ rhigh -= 0.5 / fjc
116
+ if 2 * rlow - r > 0:
117
+ LAM[j, i] += c_mid * area(rlow) / VL
118
+ if 2 * rhigh - r < off + edge:
119
+ LAM[FJC - 1 - j, i] += c_mid * area(rhigh) / VL
120
+ else:
121
+ self._reflect(LAM, FJC - 1 - j, i, r, rhigh, edge, fjc,
122
+ c_mid, VL, area)
123
+ LAM[fjc, i] += 1.0 - LAM[:, i].sum() # centre closes the row
124
+ self.L, self.LAM = L, LAM
125
+ # geometric volume in closed form (Namics LGrad1.cpp:184-185); this
126
+ # equals sum(L[iv]) at fjc=1 but the interior L-sum over-counts the
127
+ # true volume for fjc>1 (boundary half-cells). Found 5 Jul 2026.
128
+ off = self.offset
129
+ if geometry == "spherical":
130
+ self.volume = 4.0 / 3.0 * np.pi * ((MX + off) ** 3 - off ** 3) \
131
+ / fjc ** 3
132
+ else: # cylindrical
133
+ self.volume = np.pi * ((MX + off) ** 2 - off ** 2) / fjc ** 2
134
+
135
+ @staticmethod
136
+ def _reflect(LAM, c, i, r, rhigh, edge, fjc, coef, VL, area):
137
+ """Fold a bond that reaches past the outer mirror back into channel c
138
+ (Namics' else-branch: reflected radius rhigh - k/fjc)."""
139
+ d = 2 * rhigh - r - edge
140
+ if -0.001 < d < 0.001:
141
+ LAM[c, i] += coef * area(rhigh) / VL
142
+ for k in range(1, fjc + 1):
143
+ if 0.99 * k / fjc < d < 1.01 * k / fjc:
144
+ LAM[c, i] += coef * area(rhigh - k / fjc) / VL
145
+
146
+ # -- boundary handling -------------------------------------------------
147
+ def set_bounds(self, f, lower_value=None, upper_value=None):
148
+ """Fill the fjc ghost layers on each side. mirror: reflect the interior;
149
+ surface: 0 (impenetrable solid). Explicit values override the whole
150
+ ghost block (used for frozen surface densities)."""
151
+ f = f.copy()
152
+ fjc, MX = self.fjc, self.MX
153
+ for k in range(fjc):
154
+ lo, hi = k, MX + fjc + k # ghost indices (lower, upper)
155
+ if lower_value is not None:
156
+ f[lo] = lower_value
157
+ elif self.lowerbound == "surface":
158
+ f[lo] = 0.0
159
+ else: # mirror: reflect about the edge
160
+ f[lo] = f[2 * fjc - k - 1]
161
+ if upper_value is not None:
162
+ f[hi] = upper_value
163
+ elif self.upperbound == "surface":
164
+ f[hi] = 0.0
165
+ else:
166
+ f[hi] = f[MX + fjc - k - 1]
167
+ return f
168
+
169
+ def set_mirror_bounds(self, f):
170
+ """Fill ghost layers by MIRRORING regardless of the boundary type
171
+ (Namics set_M_bounds) — used for the electrostatic potential, whose
172
+ wall condition is zero-field, not zero-value."""
173
+ f = f.copy()
174
+ fjc, MX = self.fjc, self.MX
175
+ for k in range(fjc):
176
+ f[k] = f[2 * fjc - k - 1]
177
+ f[MX + fjc + k] = f[MX + fjc - k - 1]
178
+ return f
179
+
180
+ def site_average(self, f_with_bounds):
181
+ """<f>(z): three-point (fjc=1) or (2*fjc+1)-point (fjc>1) weighted
182
+ average over neighbours, interior only."""
183
+ out = np.zeros(self.M)
184
+ if self.fjc == 1:
185
+ out[1:-1] = (self.l_1[1:-1] * f_with_bounds[:-2]
186
+ + self.l0[1:-1] * f_with_bounds[1:-1]
187
+ + self.l1[1:-1] * f_with_bounds[2:])
188
+ return out
189
+ f, fjc, M = f_with_bounds, self.fjc, self.M
190
+ for c in range(self.FJC):
191
+ d = c - fjc # neighbour offset
192
+ if d == 0:
193
+ out += self.LAM[c] * f
194
+ elif d > 0:
195
+ out[:-d] += self.LAM[c][:-d] * f[d:]
196
+ else:
197
+ out[-d:] += self.LAM[c][-d:] * f[:d]
198
+ return out
199
+
200
+ def propagate(self, gs, G1, lower_value=0.0, upper_value=None):
201
+ """One chain-propagation step: G1 * <gs>. Propagators see solids as
202
+ 0 (default lower ghost 0 when lowerbound is a surface; mirror
203
+ otherwise handled by set_bounds default)."""
204
+ lv = lower_value if self.lowerbound == "surface" else None
205
+ gb = self.set_bounds(gs, lower_value=lv, upper_value=upper_value)
206
+ return G1 * self.site_average(gb)
207
+
208
+ # -- observables --------------------------------------------------------
209
+ def weighted_sum(self, X):
210
+ return float(np.dot(X[self.iv], self.L[self.iv]))
211
+
212
+ def moment(self, X, Xb, n):
213
+ # Namics PutM divides the summed moment by fjc so refined-lattice
214
+ # (fjc>1) moments come out in physical-layer units (factor 1 at
215
+ # fjc=1). Found in the 5 Jul 2026 physics review.
216
+ return float(np.dot(self.z_phys**n, (X[self.iv] - Xb)
217
+ * self.L[self.iv])) / self.fjc