flory 0.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.
flory/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """
2
+ Package :mod:`flory` provides methods and classes for phase separation in multicomponent mixtures based on Flory-Huggins theory.
3
+ """
4
+
5
+ try:
6
+ # try reading version of the automatically generated module
7
+ from ._version import __version__
8
+ except ImportError:
9
+ from importlib.metadata import version, PackageNotFoundError
10
+
11
+ try:
12
+ __version__ = version("flory")
13
+ except PackageNotFoundError:
14
+ # package is not installed
15
+ __version__ = "unknown"
16
+ del PackageNotFoundError, version
17
+
18
+ from .mcmp import CoexistingPhasesFinder, find_coexisting_phases
flory/_version.py ADDED
@@ -0,0 +1,16 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '0.0.0'
16
+ __version_tuple__ = version_tuple = (0, 0, 0)
@@ -0,0 +1,524 @@
1
+ """ The implementation details of the module :mod:`~flory.mcmp`.
2
+
3
+ :mod:`flory.detail.mcmp_impl` contains the implementation details of the module
4
+ :mod:`~flory.mcmp`. The main components of the module is the function
5
+ :meth:`multicomponent_self_consistent_metastep`, which implements the self consistent
6
+ iterations for minimizing the extended free energy functional, and the function
7
+ :meth:`get_clusters`, which finds the unique phases.
8
+
9
+ In this module, arguments of functions are always marked by `Const`, `Output` or
10
+ `Mutable`, to indicate whether the arguments will keep invariant, be directly overwritten,
11
+ or reused.
12
+
13
+ .. codeauthor:: Yicheng Qiang <yicheng.qiang@ds.mpg.de>
14
+ .. codeauthor:: David Zwicker <david.zwicker@ds.mpg.de>
15
+ """
16
+
17
+ import numba as nb
18
+ import numpy as np
19
+ from scipy import cluster, spatial
20
+
21
+
22
+ @nb.njit()
23
+ def count_valid_compartments(Js: np.ndarray, threshold: float) -> int:
24
+ """
25
+ Count how many entries in :paramref:`~count_valid_compartments.Js` are larger than
26
+ :paramref:`~count_valid_compartments.threshold`.
27
+
28
+ Args:
29
+ Js:
30
+ Const. 1D array with the size of :math:`M`.
31
+ threshold:
32
+ Const. The threshold value.
33
+
34
+ Returns:
35
+ : Number of entries in :paramref:`Js` larger than :paramref:`threshold`.
36
+ """
37
+ return (Js > threshold).sum()
38
+
39
+
40
+ @nb.njit()
41
+ def make_valid_compartment_masks(Js: np.ndarray, threshold: float) -> np.ndarray:
42
+ """
43
+ Create masks for entries in :paramref:`~make_valid_compartment_masks.Js` are larger
44
+ than :paramref:`~make_valid_compartment_masks.threshold`. Value of 1.0 or 0.0
45
+ indicates a valid or invalid mask, respectively.
46
+
47
+ Args:
48
+ Js:
49
+ Const. 1D array with the size of :math:`M`.
50
+ threshold:
51
+ Const. The threshold value.
52
+
53
+ Returns:
54
+ :
55
+ 1D array with the size of :math:`M`, containing masks of entries in
56
+ :paramref:`Js` larger than :paramref:`threshold`.
57
+ """
58
+ return np.sign(Js - threshold).clip(0.0)
59
+
60
+
61
+ @nb.njit()
62
+ def revive_compartments_by_random(
63
+ Js: np.ndarray,
64
+ targets: np.ndarray,
65
+ threshold: float,
66
+ rng: np.random.Generator,
67
+ scaler: float,
68
+ ) -> int:
69
+ """
70
+ Randomly revive compartments whose relative volume (element of
71
+ :paramref:`~revive_compartments_by_random.Js`) is smaller than
72
+ :paramref:`~revive_compartments_by_random.threshold`. The revived values are randomly
73
+ and uniformly sampled between the extreme values of :paramref:`targets` across all
74
+ compartments. The range can be scaled by the parameter :paramref:`scaler`. Note that
75
+ this function does not conserve the quantities in :paramref:`targets` across all
76
+ compartments, since the new values are randomly generated.
77
+
78
+ Args:
79
+ Js:
80
+ Mutable. 1D array with the size of :math:`M`, containing values to determine
81
+ whether a compartment is dead.
82
+ targets:
83
+ Mutable. 2D array with the size of :math:`N_\\mathrm{c} \\times M`, containing
84
+ the values to be revived. The second dimension has to be the same as that of
85
+ Js. Note that this is not checked.
86
+ threshold:
87
+ Const. The threshold of :paramref:`Js` for a compartment to be considered
88
+ dead. For each element of :paramref:`Js` smaller than this parameter, the
89
+ corresponding compartment will be considered as dead, and its targets values
90
+ will then be randomly drawn between the corresponding minimum and maximum
91
+ values of :paramref:`targets` across all compartments. Corresponding
92
+ :paramref:`Js` will be set to be unity.
93
+ rng:
94
+ Mutable. Random number generator for reviving.
95
+ scaler:
96
+ Const. The scaler for generating random new values.
97
+
98
+ Returns:
99
+ : Number of dead compartments that have been revived.
100
+ """
101
+ revive_count = 0
102
+ num_component, num_compartment = targets.shape
103
+
104
+ target_centers = np.full(num_component, 0.0, float)
105
+ omega_widths = np.full(num_component, 0.0, float)
106
+ for itr_component in range(num_component):
107
+ current_target_max = targets[itr_component].max()
108
+ current_target_min = targets[itr_component].min()
109
+ target_centers[itr_component] = (current_target_max + current_target_min) * 0.5
110
+ omega_widths[itr_component] = (current_target_max - current_target_min) * 0.5
111
+
112
+ # revive the compartment with random conjugate field
113
+ for itr_compartment in range(num_compartment):
114
+ if Js[itr_compartment] <= threshold:
115
+ Js[itr_compartment] = 1.0
116
+ revive_count += 1
117
+ for itr_component in range(num_component):
118
+ targets[itr_component, itr_compartment] = target_centers[
119
+ itr_component
120
+ ] + omega_widths[itr_component] * scaler * rng.uniform(-1, 1)
121
+
122
+ return revive_count
123
+
124
+
125
+ @nb.njit()
126
+ def revive_compartments_by_copy(
127
+ Js: np.ndarray,
128
+ targets: np.ndarray,
129
+ threshold: float,
130
+ rng: np.random.Generator,
131
+ ) -> int:
132
+ """
133
+ Revive compartments whose relative volume (element of
134
+ :paramref:`~revive_compartments_by_copy.Js`) is smaller than
135
+ :paramref:`~revive_compartments_by_copy.threshold`. The revived values are randomly
136
+ copied from other living compartments. Note that this function conserves the
137
+ quantities in :paramref:`targets` across all compartments by modifying the volumes
138
+ :paramref:`Js` accordingly.
139
+
140
+ Args:
141
+ Js:
142
+ Mutable. 1D array with the size of :math:`M`, containing values to determine
143
+ whether a compartment is dead.
144
+ targets:
145
+ Mutable. 2D array with the size of :math:`N_\\mathrm{c} \\times M`, containing the
146
+ values to be revived. The second dimension has to be the same as that of Js.
147
+ Note that this is not checked.
148
+ threshold:
149
+ Const. The threshold of :paramref:`Js` for a compartment to be considered
150
+ dead. For each element of :paramref:`Js` smaller than this parameter, the
151
+ corresponding compartment will be considered as dead, and its targets values
152
+ will then be randomly drawn between the corresponding minimum and maximum
153
+ values of :paramref:`targets` across all compartments. Corresponding
154
+ :paramref:`Js` will be set to be unity.
155
+ rng:
156
+ Mutable. Random number generator for reviving.
157
+
158
+ Returns:
159
+ : Number of revives
160
+ """
161
+ revive_count = 0
162
+ num_components, num_compartments = targets.shape
163
+
164
+ dead_indexes = np.full(num_compartments, -1, dtype=np.int32)
165
+ dead_count = 0
166
+ living_nicely_indexes = np.full(num_compartments, -1, dtype=np.int32)
167
+ living_nicely_count = 0
168
+ for itr_compartment in range(num_compartments):
169
+ if Js[itr_compartment] > 2.0 * threshold:
170
+ living_nicely_indexes[living_nicely_count] = itr_compartment
171
+ living_nicely_count += 1
172
+ elif Js[itr_compartment] <= threshold:
173
+ dead_indexes[dead_count] = itr_compartment
174
+ dead_count += 1
175
+
176
+ for itr_dead in dead_indexes[:dead_count]:
177
+ while True:
178
+ pos_in_living = rng.integers(0, living_nicely_count)
179
+ ref_index = living_nicely_indexes[pos_in_living]
180
+ if Js[int(ref_index)] > 2.0 * threshold:
181
+ targets[:, itr_dead] = targets[:, ref_index]
182
+ new_J = 0.5 * Js[ref_index]
183
+ Js[itr_dead] = new_J
184
+ Js[ref_index] = new_J
185
+ living_nicely_indexes[living_nicely_count] = itr_dead
186
+ living_nicely_count += 1
187
+ revive_count += 1
188
+ break
189
+ else:
190
+ living_nicely_count -= 1
191
+ living_nicely_indexes[pos_in_living] = living_nicely_indexes[
192
+ living_nicely_count
193
+ ]
194
+ living_nicely_indexes[living_nicely_count] = -1
195
+ return revive_count
196
+
197
+
198
+ @nb.njit()
199
+ def calc_volume_fractions(
200
+ phis: np.ndarray,
201
+ Js: np.ndarray,
202
+ sizes: np.ndarray,
203
+ phi_means: np.ndarray,
204
+ omegas: np.ndarray,
205
+ masks: np.ndarray,
206
+ ) -> tuple[np.ndarray, np.ndarray]:
207
+ """
208
+ Calculate the volume fractions of components :math:`\\phi_i^{(m)}` from the conjugate
209
+ fields :math:`\\omega_i^{(m)}`. The single molecular partition functions $Q_i$ and the
210
+ incompressibility :math:`\\sum_i \\phi_i^{(m)} - 1` are also calculated. Note that the
211
+ volume fractions :math:`\\phi_i^{(m)}` are returned through the parameter.
212
+
213
+ Args:
214
+ phis:
215
+ Output. The 2D array with the size of :math:`N_\\mathrm{c} \\times M`,
216
+ containing the volume fractions :math:`\\phi_i^{(m)}`. The first dimension has
217
+ to be the same as :paramref:`sizes`. The second dimension has to be the same
218
+ as that of :paramref:`Js`. Note that these are not checked.
219
+ Js:
220
+ Const. The 1D array with the size of :math:`M`, containing the relative
221
+ volumes of compartments :math:`J_m`. Note that :paramref:`Js` must be
222
+ invariant under multiplication of :paramref:`masks`.
223
+ sizes:
224
+ Const. The 1D array with the size of :math:`M`, containing the relative
225
+ molecule volumes of the components :math:`l_i`.
226
+ phi_means:
227
+ Const. The 1D array with the size of :math:`N_\\mathrm{c}`, containing the
228
+ mean volume fractions of the components :math:`\\bar{\\phi}_i`.
229
+ omegas:
230
+ Const. The 2D array with the size of :math:`N_\\mathrm{c} \\times M`,
231
+ containing the conjugate field :math:`\\omega_i^{(m)}`. The first dimension
232
+ has to be the same as :paramref:`sizes`. The second dimension has to be the
233
+ same as that of :paramref:`Js`. Note that these are not checked.
234
+ masks:
235
+ Const. The 1D array with the size of :math:`M`, containing the masks of
236
+ compartments. See :meth:`make_valid_compartment_masks` for more information.
237
+
238
+ Returns:
239
+ [0]:
240
+ 1D array with the size of :math:`N_\\mathrm{c}`, containing the single
241
+ molecular partition functions of components :math:`Q_i`.
242
+ [1]:
243
+ 1D array with the size of :math:`M`, containing the incompressibility
244
+ :math:`\\sum_i \\phi_i^{(m)} - 1`.
245
+ """
246
+
247
+ num_components, num_compartments = omegas.shape
248
+ Qs = np.full(num_components, 0.0, float)
249
+ incomp = np.full(num_compartments, -1.0, float)
250
+ total_Js = Js.sum()
251
+ for itr_comp in range(num_components):
252
+ phis[itr_comp] = np.exp(-omegas[itr_comp] * sizes[itr_comp])
253
+ Qs[itr_comp] = (phis[itr_comp] * Js).sum()
254
+ Qs[itr_comp] /= total_Js
255
+ factor = phi_means[itr_comp] / Qs[itr_comp]
256
+ phis[itr_comp] = factor * phis[itr_comp] * masks
257
+ incomp += phis[itr_comp]
258
+ incomp *= masks
259
+ return Qs, incomp
260
+
261
+
262
+ @nb.njit()
263
+ def multicomponent_self_consistent_metastep(
264
+ phi_means: np.ndarray,
265
+ chis: np.ndarray,
266
+ sizes: np.ndarray,
267
+ *,
268
+ omegas: np.ndarray,
269
+ Js: np.ndarray,
270
+ phis: np.ndarray,
271
+ steps_inner: int,
272
+ acceptance_Js: float,
273
+ Js_step_upper_bound: float,
274
+ acceptance_omega: float,
275
+ kill_threshold: float,
276
+ revive_tries: int,
277
+ revive_scaler: float,
278
+ rng: np.random.Generator,
279
+ ) -> tuple[float, float, float, int, bool]:
280
+ """
281
+ The core algorithm of finding coexisting states of multicomponent systems with
282
+ self-consistent iterations.
283
+
284
+ Args:
285
+ phi_means:
286
+ Const. The interaction matrix. 2D array with size of :math:`N_\\mathrm{c}
287
+ \\times N_\\mathrm{c}`. This matrix should be the full :math:`\\chi_{ij}`
288
+ matrix of the system, including the solvent component. Note that the matrix
289
+ must be symmetric, which is not checked but should be guaranteed externally.
290
+ chis:
291
+ Const. The average volume fractions :math:`\\bar{\\phi}_i` of all the
292
+ components of the system. 1D array with size of :math:`N_\\mathrm{c}`. Note
293
+ that the volume fraction of the solvent is included as well, therefore the sum
294
+ of this array must be unity, which is not checked by this function and should
295
+ be guaranteed externally.
296
+ sizes:
297
+ Const. The relative molecule volumes :math:`l_i` of the components. 1D array
298
+ with size of :math:`N_\\mathrm{c}`. This sizes vector should be the full sizes
299
+ vector of the system, including the solvent component. An element of one
300
+ indicates that the corresponding specie has the same volume as the reference.
301
+ None indicates a all-one vector.
302
+ omegas:
303
+ Mutable. The conjugate field :math:`\\omega_i^{(m)}`. 2D array with size of
304
+ :math:`N_\\mathrm{c} \\times M`. Note that this field is both used as input
305
+ and output. Note again that this function DO NOT initialize `omegas`, it
306
+ should be initialized externally, and usually a random initialization will be
307
+ a reasonable choice.
308
+ Js:
309
+ Mutable. The relative volumes of the compartments :math:`J_m`. 1D array with
310
+ size of :math:`M`. The average value of `Js` will and should be unity. Note
311
+ that this field is both used as input and output. An all-one array is usually
312
+ a nice initialization, unless resume of a previous run is intended.
313
+ phis:
314
+ Output. The volume fractions :math:`\\phi_i^{(m)}`. 2D array with size of
315
+ :math:`N_\\mathrm{c} \\times M`.
316
+ steps_inner:
317
+ Constant. Number of steps in current routine. Within these steps, convergence
318
+ is not checked and no output will be generated.
319
+ acceptance_Js:
320
+ Constant. The acceptance of :paramref:`Js` (the relative compartment size
321
+ :math:`J_m`). This value determines the amount of changes accepted in each
322
+ step for the :math:`J_m` field. Typically this value can take the order of
323
+ :math:`10^{-3}`, or smaller when the system becomes larger or stiffer.
324
+ Js_step_upper_bound:
325
+ Constant. The maximum change of :paramref:`Js` (the relative compartment size
326
+ :math:`J_m`) per step. This value is designed to reduce the risk that a the
327
+ volume of a compartment changes too fast before it develops meaningful
328
+ composition. If the intended change is larger this value, all the changes will
329
+ be scaled down to guarantee that the maximum changes do not exceed this value.
330
+ Typically this value can take the order of :math:`10^{-3}`, or smaller when
331
+ the system becomes larger or stiffer.
332
+ acceptance_omega:
333
+ Constant. The acceptance of :paramref:`omegas`(the conjugate fields
334
+ :math:`\\omega_i^{(m)}`). This value determines the amount of changes accepted
335
+ in each step for the :math:`\\omega_i^{(m)}` field. Note that if the iteration
336
+ of :math:`J_m` is scaled down due to parameter
337
+ :paramref:`Js_step_upper_bound`, the iteration of :math:`\\omega_i^{(m)}`
338
+ fields will be scaled down simultaneously. Typically this value can take the
339
+ order of :math:`10^{-2}`, or smaller when the system becomes larger or
340
+ stiffer.
341
+ kill_threshold:
342
+ Constant. The threshold of the :math:`J_m` for a compartment to be considered
343
+ dead and killed afterwards. Should be not less than 0. In each iteration step,
344
+ the :math:`J_m` array will be checked, for each element smaller than this
345
+ parameter, the corresponding compartment will be killed and 0 will be assigned
346
+ to the internal mask. The dead compartment may be revived, depending whether
347
+ reviving is allowed or whether the number of the revive tries has been
348
+ exhausted.
349
+ revive_tries:
350
+ Constant. Number of tries left to revive the dead compartment. 0 or negative
351
+ value indicates no reviving. When this value is exhausted, i.e. the number of
352
+ revive in current function call exceeds this value, the revive will be turned
353
+ off. Note that this function does not decrease this value, but returns the number
354
+ of revives that have happened after completion.
355
+ revive_scaler:
356
+ Constant. The scaling factor for the conjugate fields :math:`\\omega_i^{(m)}`
357
+ when a dead compartment is revived. This value determines the range of the
358
+ random conjugate field generated by the algorithm. Typically 1.0 or some value
359
+ slightly larger will be a reasonable choice. See
360
+ :meth:`revive_compartments_by_random` for more information.
361
+ rng:
362
+ Mutable. random number generator for reviving.
363
+
364
+ Returns:
365
+ [0]: Max absolute incompressibility.
366
+ [1]: Max absolute conjugate field error.
367
+ [2]: Max absolute relative volumes error.
368
+ [3]: Number of revives.
369
+ [4]: Whether no phase is killed in the last step.
370
+ """
371
+ num_components, num_compartments = omegas.shape
372
+ chi_sum_sum = chis.sum()
373
+
374
+ n_valid_phase = 0
375
+
376
+ revive_count = 0
377
+ for _ in range(steps_inner):
378
+ # check if we are still allowed to revive compartments
379
+ if revive_count < revive_tries:
380
+ n_valid_phase = count_valid_compartments(Js, kill_threshold)
381
+ if n_valid_phase != num_compartments:
382
+ # revive dead compartments
383
+ revive_count += revive_compartments_by_random(
384
+ Js, omegas, kill_threshold, rng, revive_scaler
385
+ )
386
+
387
+ # generate masks for the compartments
388
+ masks = make_valid_compartment_masks(Js, kill_threshold)
389
+ n_valid_phase = int(masks.sum())
390
+ Js *= masks
391
+
392
+ # calculate volume fractions, single molecular partition function Q and incompressibility
393
+ (Qs, incomp) = calc_volume_fractions(phis, Js, sizes, phi_means, omegas, masks)
394
+ max_abs_incomp = np.abs(incomp).max()
395
+
396
+ # temp for omega, namely chi.phi
397
+ omega_temp = chis @ phis
398
+
399
+ # xi, the lagrangian multiplier
400
+ xi = chi_sum_sum * incomp
401
+ for itr_comp in range(num_components):
402
+ xi += omegas[itr_comp] - omega_temp[itr_comp]
403
+ xi *= masks
404
+ xi /= num_components
405
+
406
+ # local energy. i.e. energy of phases excluding the partition function part
407
+ local_energy = xi * incomp
408
+ for itr_comp in range(num_components):
409
+ local_energy += (
410
+ -0.5 * omega_temp[itr_comp] - xi - 1.0 / sizes[itr_comp]
411
+ ) * phis[itr_comp]
412
+
413
+ # calculate the difference of Js
414
+ local_energy_mean = (local_energy * Js).sum() / n_valid_phase
415
+ Js_diff = (local_energy_mean - local_energy) * masks
416
+ max_abs_Js_diff = np.abs(Js_diff).max()
417
+
418
+ # calculate additional factor to scale down iteration
419
+ Js_max_change = max_abs_Js_diff * acceptance_Js
420
+ additional_factor = Js_step_upper_bound / max(Js_max_change, Js_step_upper_bound)
421
+
422
+ # update Js
423
+ Js += additional_factor * acceptance_Js * Js_diff
424
+ Js *= masks
425
+ Js += 1 - (Js.sum() / n_valid_phase)
426
+ Js *= masks
427
+
428
+ # calculate difference of omega and update omega directly
429
+ max_abs_omega_diff = 0
430
+ for itr_comp in range(num_components):
431
+ omega_temp[itr_comp] = omega_temp[itr_comp] + xi - omegas[itr_comp]
432
+ omega_temp[itr_comp] *= masks
433
+ omega_temp[itr_comp] -= omega_temp[itr_comp].sum() / n_valid_phase
434
+ max_abs_omega_diff = max(max_abs_omega_diff, omega_temp[itr_comp].max())
435
+ omegas[itr_comp] += (
436
+ additional_factor * acceptance_omega * omega_temp[itr_comp]
437
+ )
438
+ omegas[itr_comp] *= masks
439
+ omegas[itr_comp] -= omegas[itr_comp].sum() / n_valid_phase
440
+
441
+ # count the valid phases in the last step
442
+ n_valid_phase_last = count_valid_compartments(Js, kill_threshold)
443
+
444
+ return (
445
+ max_abs_incomp,
446
+ max_abs_omega_diff,
447
+ max_abs_Js_diff,
448
+ revive_count,
449
+ n_valid_phase == n_valid_phase_last,
450
+ )
451
+
452
+
453
+ def sort_phases(
454
+ Js_phases: np.ndarray, phis_phases: np.ndarray
455
+ ) -> tuple[np.ndarray, np.ndarray]:
456
+ """
457
+ Sort the phases according to the index of most concentrated components. Note that this
458
+ function uses different data structure from other functions in the module. See
459
+ :paramref:`phis_phases`.
460
+
461
+ Args:
462
+ Js_phases:
463
+ Const. 1D array with the size of :math:`N_\\mathrm{p}`, containing the volumes
464
+ of each phase.
465
+ phis_phases:
466
+ Const. 2D array with the size of :math:`N_\\mathrm{p} \\times N_\\mathrm{c}`,
467
+ containing the volume fractions of the components in each phase. The first
468
+ dimension must be the same as :paramref:`Js_phases`. Note that this usually
469
+ corresponds to the transpose of the arrays of :math:`\\phi_i^{(m)}`, for
470
+ example :attr:`~flory.mcmp.CoexistingPhasesFinder.phis` in class
471
+ :class:`~flory.mcmp.CoexistingPhasesFinder`.
472
+
473
+ Returns:
474
+ [0]: Sorted :paramref:`Js_phases`.
475
+ [1]: Sorted :paramref:`phis_phases`.
476
+ """
477
+ enrich_indexes = np.argsort(phis_phases)
478
+ sorting_index = np.lexsort(np.transpose(enrich_indexes))
479
+ return Js_phases[sorting_index], phis_phases[sorting_index]
480
+
481
+
482
+ def get_clusters(
483
+ Js: np.ndarray, phis: np.ndarray, dist: float = 1e-2
484
+ ) -> tuple[np.ndarray, np.ndarray]:
485
+ """
486
+ Find unique phases from compartments by clustering. The returning results are sorted
487
+ according to the index of most concentrated components.
488
+
489
+ Args:
490
+ Js:
491
+ Const. 1D array with the size of :math:`M`, containing the volumes of each
492
+ compartment.
493
+ phis:
494
+ Const. 2D array with the size of :math:`N_\\mathrm{c} \\times M`, containing
495
+ the volume fractions of the components in each phase :math:`\\phi_i^{(m)}`.
496
+ The second dimension must be the same as :math:`Js`.
497
+ dist:
498
+ Cut-off distance for cluster analysis
499
+
500
+ Returns:
501
+ [0]:
502
+ 1D array with the size of :math:`N_\\mathrm{p}`, containing the volumes of the
503
+ unique phases.
504
+ [1]:
505
+ 1D array with the size of :math:`N_\\mathrm{p} \\times N_\\mathrm{c}`,
506
+ containing the compositions of all unique phases. Note that the data structure
507
+ is different from normal, see :meth:`sort_phases` for more information.
508
+ """
509
+ # transpose to make the compartment index the first index
510
+ phis = np.transpose(phis)
511
+ # calculate distances between compositions
512
+ if phis.shape[0] == 1:
513
+ return phis, 1
514
+ dists = spatial.distance.pdist(phis)
515
+ # obtain hierarchy structure
516
+ links = cluster.hierarchy.linkage(dists, method="centroid")
517
+ # flatten the hierarchy by clustering
518
+ clusters = cluster.hierarchy.fcluster(links, dist, criterion="distance")
519
+ cluster_phis = np.array(
520
+ [phis[clusters == n, :].mean(axis=0) for n in np.unique(clusters)]
521
+ )
522
+ cluster_Js = np.array([Js[clusters == n].sum(axis=0) for n in np.unique(clusters)])
523
+ cluster_Js /= cluster_Js.sum()
524
+ return sort_phases(cluster_Js, cluster_phis)
flory/mcmp.py ADDED
@@ -0,0 +1,641 @@
1
+ """Module for finding coexisting phases of multicomponent mixtures.
2
+
3
+ :mod:`flory.mcmp` provides tools for finding equilibrium multiple coexisting phases in
4
+ multicomponent mixtures in the canonical ensemble based on Flory-Huggins theory. The
5
+ module provides both the function API :meth:`find_coexisting_phases` and the class API
6
+ :class:`CoexistingPhasesFinder`. The function :meth:`find_coexisting_phases` aims at
7
+ easing the calculation of the coexisting phases with a single system setting, namely one
8
+ single point in the phase diagram. In contrast, The class :class:`CoexistingPhasesFinder`
9
+ is designed for reuse over systems with the same system sizes such as the number of
10
+ components, which includes generating a coexisting curve or sampling a phase diagram. It
11
+ creates a finder to maintain its internal data, and provides more control and diagnostics
12
+ over the iteration. See :ref:`Examples` for examples.
13
+
14
+ .. codeauthor:: Yicheng Qiang <yicheng.qiang@ds.mpg.de>
15
+ .. codeauthor:: David Zwicker <david.zwicker@ds.mpg.de>
16
+ """
17
+
18
+ import logging
19
+ import numpy as np
20
+ from datetime import datetime
21
+ from typing import Optional
22
+ from tqdm.auto import tqdm
23
+ from .detail.mcmp_impl import *
24
+
25
+
26
+ class CoexistingPhasesFinder:
27
+ """
28
+ Create a finder for coexisting phases.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ chis: np.ndarray,
34
+ phi_means: np.ndarray,
35
+ num_compartments: int,
36
+ *,
37
+ sizes: Optional[np.ndarray] = None,
38
+ rng: Optional[np.random.Generator] = None,
39
+ max_steps: int = 1000000,
40
+ convergence_criterion: str = "standard",
41
+ tolerance: float = 1e-5,
42
+ interval: int = 10000,
43
+ progress: bool = True,
44
+ random_std: float = 5.0,
45
+ acceptance_Js: float = 0.0002,
46
+ acceptance_omega: float = 0.002,
47
+ Js_step_upper_bound: float = 0.001,
48
+ kill_threshold: float = 0.0,
49
+ revive_scaler: float = 1.0,
50
+ max_revive_per_compartment: int = 16,
51
+ additional_chis_shift: float = 1.0,
52
+ ):
53
+ """
54
+ Construct a :class:`CoexistingPhasesFinder` instance for finding coexisting
55
+ phases. This class is recommended when multiple instances of :paramref:`chis`
56
+ matrix or :paramref:`phi_means` vector need to be calculated. The class will reuse
57
+ all the options and the internal resources. Note that reuse the instance of this
58
+ class is only possible when all the system sizes are not changed, including the
59
+ number of components :math:`N_\\mathrm{c}` and the number of compartments
60
+ :math:`M`. The the number of components :math:`N_\\mathrm{c}` is inferred from
61
+ :paramref:`chis` and :paramref:`phi_means`, while :math:`N_\\mathrm{c}` is set by
62
+ the parameter :paramref:`num_compartments`. Setting :paramref:`chis` matrix and
63
+ :paramref:`phi_means` manually by the setters leads to the reset of the internal
64
+ revive counters.
65
+
66
+ Args:
67
+ chis:
68
+ The interaction matrix. 2D array with size of :math:`N_\\mathrm{c} \\times
69
+ N_\\mathrm{c}`. This matrix should be the full :math:`\\chi_{ij}` matrix
70
+ of the system, including the solvent component. Note that the matrix must
71
+ be symmetric, which is not checked but should be guaranteed externally.
72
+ phi_means:
73
+ The average volume fractions :math:`\\bar{\\phi}_i` of all the components
74
+ of the system. 1D array with size of :math:`N_\\mathrm{c}`. Note that the
75
+ volume fraction of the solvent is included as well, therefore the sum of
76
+ this array must be unity, which is not checked by this function and should
77
+ be guaranteed externally.
78
+ num_compartments:
79
+ Number of compartments :math:`M` in the system.
80
+ sizes:
81
+ The relative molecule volumes :math:`l_i` of the components. 1D array with
82
+ size of :math:`N_\\mathrm{c}`. This sizes vector should be the full sizes
83
+ vector of the system, including the solvent component. An element of one
84
+ indicates that the corresponding specie has the same volume as the
85
+ reference. None indicates a all-one vector.
86
+ rng:
87
+ Random number generator for initialization and reviving. None indicates
88
+ that a new random number generator should be created by the class, seeded
89
+ by current timestamp.
90
+ max_steps:
91
+ The default maximum number of steps in each run to find the coexisting
92
+ phases. This value can be temporarily overwritten, see :meth:`run` for more
93
+ information.
94
+ convergence_criterion:
95
+ The criterion to determine convergence. Currently "standard" is the only
96
+ option, which requires checking of incompressibility, field error and the
97
+ volume error. Note that all these metrics are state functions, namely they
98
+ are independent of iteration parameters.
99
+ tolerance:
100
+ The tolerance to determine convergence. This value can be temporarily
101
+ overwritten. See :paramref:`convergence_criterion` and :meth:`run` for
102
+ more information.
103
+ interval:
104
+ The interval of steps to check convergence. This value can be temporarily
105
+ overwritten, see :meth:`run` for more information.
106
+ progress:
107
+ Whether to show progress bar when checking convergence. This value can be
108
+ temporarily overwritten, see :meth:`run` for more information.
109
+ random_std:
110
+ The amplitude of the randomly generated conjugate fields internally.
111
+ During instantiation, the conjugate field is randomly generated according
112
+ to an normal distribution with standard deviation :paramref:`random_std`.
113
+ acceptance_Js:
114
+ The acceptance of the relative compartment size :math:`J_m`. This value
115
+ determines the amount of changes accepted in each step for the :math:`J_m`
116
+ field. Typically this value can take the order of :math:`10^{-3}`, or
117
+ smaller when the system becomes larger or stiffer.
118
+ Js_step_upper_bound:
119
+ The maximum change of the relative compartment size :math:`J_m` per step.
120
+ This value is designed to reduce the risk that a the volume of a
121
+ compartment changes too fast before it develops meaningful composition. If
122
+ the intended change is larger this value, all the changes will be scaled
123
+ down to guarantee that the maximum changes do not exceed this value.
124
+ Typically this value can take the order of :math:`10^{-3}`, or smaller
125
+ when the system becomes larger or stiffer.
126
+ acceptance_omega:
127
+ The acceptance of the conjugate fields :math:`\\omega_i^{(m)}`. This value
128
+ determines the amount of changes accepted in each step for the
129
+ :math:`\\omega_i^{(m)}` field. Note that if the iteration of :math:`J_m` is
130
+ scaled down due to parameter :paramref:`Js_step_upper_bound`, the
131
+ iteration of :math:`\\omega_i^{(m)}` fields will be scaled down simultaneously.
132
+ Typically this value can take the order of :math:`10^{-2}`, or smaller
133
+ when the system becomes larger or stiffer.
134
+ kill_threshold:
135
+ The threshold of the :math:`J_m` for a compartment to be considered dead
136
+ and killed afterwards. Should be not less than 0. In each iteration step,
137
+ the :math:`J_m` array will be checked, for each element smaller than this
138
+ parameter, the corresponding compartment will be killed and 0 will be
139
+ assigned to the internal mask. The dead compartment may be revived,
140
+ depending whether reviving is allowed or whether the number of the revive
141
+ tries has been exhausted.
142
+ revive_scaler:
143
+ The scaler for the value of the newly-generated conjugate fields when a
144
+ dead compartment is revived. The compartment is revived by drawing random
145
+ numbers for their conjugate fields in the range of the minimum and the
146
+ maximum of the :math:`\\omega_i^{(m)}` their conjugate fields across all
147
+ compartments. This value determines whether this range should be enlarged
148
+ (a value larger than 1) or reduced (a value smaller than 1). Typically 1.0
149
+ or a value slightly larger than 1.0 will be a reasonable choice.
150
+ max_revive_per_compartment:
151
+ Maximum average number of tries per compartment to revive the dead
152
+ compartments. 0 or negative value indicates no reviving. When this value
153
+ is exhausted, the revive will be turned off.
154
+ additional_chis_shift:
155
+ Shift of the entire chis matrix to improve the convergence by evolving
156
+ towards incompressible system faster. This value should be larger than 0.
157
+ Note that with very large value, the convergence will be slowed down,
158
+ since the algorithm no longer have enough ability to temporarily relax the
159
+ incompressibility.
160
+ """
161
+ self._logger = logging.getLogger(self.__class__.__name__)
162
+
163
+ # chis
164
+ chis = np.array(chis)
165
+ if chis.shape[0] == chis.shape[1]:
166
+ self._chis = chis
167
+ self._num_components = chis.shape[0]
168
+ self._logger.info(
169
+ f"We infer that there are {self._num_components} components in the system from the chis matrix."
170
+ )
171
+ else:
172
+ self._logger.error(f"chis matrix with size of {chis.shape} is not square.")
173
+ raise ValueError("chis matrix must be square.")
174
+ if (chis != chis.transpose()).any():
175
+ self._logger.error(f"chis matrix is not symmetric.")
176
+ raise ValueError("chis matrix must be symmetric.")
177
+
178
+ # phi_means
179
+ phi_means = np.array(phi_means)
180
+ if phi_means.shape[0] == self._num_components:
181
+ self._phi_means = phi_means
182
+ if np.abs(self._phi_means.sum() - 1.0) > 1e-12:
183
+ self._logger.warning(
184
+ f"Total phi_means is not 1.0. Iteration may never converge."
185
+ )
186
+ else:
187
+ self._logger.error(
188
+ f"phi_means vector with size of {phi_means.shape} is invalid, since {self._num_components} is defined by chis matrix."
189
+ )
190
+ raise ValueError(
191
+ "phi_means vector must imply same component number as chis matrix."
192
+ )
193
+
194
+ self._num_compartments = num_compartments
195
+
196
+ ## optional arguments
197
+
198
+ # sizes
199
+ if sizes is None:
200
+ self._sizes = np.ones(self._num_components)
201
+ else:
202
+ sizes = np.array(sizes)
203
+ if sizes.shape[0] == self._num_components:
204
+ self._sizes = sizes
205
+ if np.sum(self._sizes <= 0):
206
+ self._logger.warning(
207
+ f"Non-positive sizes detected. Iteration will probably fail."
208
+ )
209
+ else:
210
+ self._logger.error(
211
+ f"sizes vector with size of {sizes.shape} is invalid, since {self._num_components} is defined by chis matrix."
212
+ )
213
+ raise ValueError(
214
+ "sizes vector must imply same component number as chis matrix."
215
+ )
216
+
217
+ # rng
218
+ if rng is None:
219
+ self._rng_is_external = False
220
+ self._rng_seed = int(datetime.now().timestamp())
221
+ self._rng = np.random.default_rng(self._rng_seed)
222
+ else:
223
+ self._rng_is_external = True
224
+ self._rng_seed = int(0)
225
+ self._rng = rng
226
+
227
+ # other parameters
228
+ self._max_steps = max_steps
229
+ self._convergence_criterion = convergence_criterion
230
+ self._tolerance = tolerance
231
+ self._interval = interval
232
+ self._progress = progress
233
+
234
+ self._random_std = random_std
235
+ self._acceptance_Js = acceptance_Js
236
+ self._acceptance_omega = acceptance_omega
237
+ self._Js_step_upper_bound = Js_step_upper_bound
238
+ self._kill_threshold = kill_threshold
239
+ self._revive_scaler = revive_scaler
240
+ self._max_revive_per_compartment = max_revive_per_compartment
241
+ self._additional_chis_shift = additional_chis_shift
242
+
243
+ # diagnostics
244
+ self._diagnostics = {}
245
+
246
+ ## initialize derived internal states
247
+ self._Js = np.full(self._num_compartments, 0.0, float)
248
+ self._omegas = np.full((self._num_components, self._num_compartments), 0.0, float)
249
+ self._phis = np.full((self._num_components, self._num_compartments), 0.0, float)
250
+ self._revive_count_left = (
251
+ self._max_revive_per_compartment * self._num_compartments
252
+ )
253
+ self.reinitialize_random()
254
+
255
+ def reinitialize_random(self):
256
+ """
257
+ Reinitialize the internal conjugate field :math:`\\omega_i^{(m)}` randomly. See parameter
258
+ :paramref:`CoexistingPhasesFinder.random_std` for more information.
259
+ """
260
+ self._omegas = self._rng.normal(
261
+ 0.0,
262
+ self._random_std,
263
+ (self._num_components, self._num_compartments),
264
+ )
265
+ self._Js = np.full(self._num_compartments, 1.0, float)
266
+ self._revive_count_left = (
267
+ self._max_revive_per_compartment * self._num_compartments
268
+ )
269
+
270
+ def reinitialize_from_omegas(self, omegas: np.ndarray):
271
+ """
272
+ Reinitialize the internal conjugate field :math:`\\omega_i^{(m)}` from input.
273
+
274
+ Args:
275
+ omegas:
276
+ New :math:`\\omega_i^{(m)}` field, must have the same size of
277
+ :math:`N_\\mathrm{c} \\times M`.
278
+ """
279
+ omegas = np.array(omegas)
280
+ if omegas.shape == self._omegas.shape:
281
+ self._omegas = omegas
282
+ else:
283
+ self._logger.error(
284
+ f"new omegas with size of {omegas.shape} is invalid. It must have the size of {(self._num_components, self._num_compartments)}."
285
+ )
286
+ raise ValueError("New omegas must match the size of the old one.")
287
+ self._Js = np.ones_like(self._Js)
288
+ self._revive_count_left = (
289
+ self._max_revive_per_compartment * self._num_compartments
290
+ )
291
+
292
+ def reinitialize_from_phis(self, phis):
293
+ """
294
+ Reinitialize the internal conjugate field :math:`\\omega_i^{(m)}` from volume fraction
295
+ field :math:`\\phi_i^{(m)}`. Note that it is not guaranteed that the initial volume
296
+ fraction field :math:`\\phi_i^{(m)}` is fully respected. The input is only considered
297
+ as a suggestion for the generation of :math:`\\omega_i^{(m)}` field.
298
+
299
+ Args:
300
+ phis:
301
+ New :math:`\\phi_i^{(m)}` field, must have the same size of :math:`N_\\mathrm{c}
302
+ \\times M`.
303
+ """
304
+ if phis.shape == self._omegas.shape:
305
+ self._omegas = -np.log(phis)
306
+ for itr in range(self._num_components):
307
+ self._omegas[itr] /= self._sizes[itr]
308
+ else:
309
+ self._logger.error(
310
+ f"phis with size of {phis.shape} is invalid. It must have the size of {(self._num_components, self._num_compartments)}."
311
+ )
312
+ raise ValueError("phis must match the size of the omegas.")
313
+ self._Js = np.ones_like(self._Js)
314
+ self._revive_count_left = (
315
+ self._max_revive_per_compartment * self._num_compartments
316
+ )
317
+
318
+ @property
319
+ def chis(self) -> np.ndarray:
320
+ """
321
+ The full interaction matrix :math:`\\chi_{ij}`, with the size of
322
+ :math:`N_\\mathrm{c} \\times N_\\mathrm{c}`. Resetting this property requires
323
+ that the new matrix has the same size with the internal one. Note that this
324
+ implies implicit reset of the internal data, the number of revives, but not the
325
+ others including volume fractions :math:`\\phi_i^{(m)}` and conjugate fields
326
+ :math:`\\omega_i^{(m)}`. See class parameters
327
+ :paramref:`~CoexistingPhasesFinder.chis` and
328
+ :paramref:`~CoexistingPhasesFinder.max_revive_per_compartment` for more
329
+ information.
330
+ """
331
+ return self._chis
332
+
333
+ @chis.setter
334
+ def chis(self, chis_new: np.ndarray):
335
+ chis_new = np.array(chis_new)
336
+ if chis_new.shape == self._chis.shape:
337
+ if (chis_new != chis_new.transpose()).any():
338
+ self._logger.error(f"chis matrix is not symmetric.")
339
+ raise ValueError("chis matrix must be symmetric.")
340
+ self._chis = chis_new
341
+ else:
342
+ self._logger.error(
343
+ f"new chis with size of {chis_new.shape} is invalid. It must have the size of {self._chis.shape}."
344
+ )
345
+ raise ValueError("New chis must match the size of the old one.")
346
+ self._revive_count_left = (
347
+ self._max_revive_per_compartment * self._num_compartments
348
+ )
349
+
350
+ @property
351
+ def phi_means(self) -> np.ndarray:
352
+ """
353
+ The average volume fractions of all components :math:`\\bar{\\phi}_i`, with the
354
+ size of :math:`N_\\mathrm{c}`. Resetting this property requires that the new array
355
+ has the same size with the internal one. Note that this implies implicit reset of
356
+ the internal data, the number of revives, but not the others including volume
357
+ fractions :math:`\\phi_i^{(m)}` and conjugate fields :math:`\\omega_i^{(m)}`. See
358
+ class parameters :paramref:`~CoexistingPhasesFinder.phis_mean` and
359
+ :paramref:`~CoexistingPhasesFinder.max_revive_per_compartment` for more
360
+ information.
361
+ """
362
+ return self._phi_means
363
+
364
+ @phi_means.setter
365
+ def phi_means(self, phi_means_new):
366
+ phi_means_new = np.array(phi_means_new)
367
+ if phi_means_new.shape == self._phi_means.shape:
368
+ self._phi_means = phi_means_new
369
+ if np.abs(self._phi_means.sum() - 1.0) > 1e-12:
370
+ self._logger.warning(
371
+ f"Total phi_means is not 1.0. Iteration may never converge."
372
+ )
373
+ else:
374
+ self._logger.error(
375
+ f"new phi_means with size of {phi_means_new.shape} is invalid. It must have the size of {self._phi_means.shape}."
376
+ )
377
+ raise ValueError("New phi_means must match the size of the old one.")
378
+ self._revive_count_left = (
379
+ self._max_revive_per_compartment * self._num_compartments
380
+ )
381
+
382
+ @property
383
+ def sizes(self) -> np.ndarray:
384
+ """
385
+ The relative molecule sizes of all components :math:`l_i` with the size of
386
+ :math:`N_\\mathrm{c}`. Resetting this property requires that the new array has
387
+ the same size with the internal one. Note that this implies implicit reset of the
388
+ internal data, the number of revives, but not the others including volume
389
+ fractions :math:`\\phi_i^{(m)}` and conjugate fields :math:`\\omega_i^{(m)}`. See
390
+ class parameters :paramref:`~CoexistingPhasesFinder.sizes` and
391
+ :paramref:`~CoexistingPhasesFinder.max_revive_per_compartment` for more
392
+ information.
393
+ """
394
+ return self._sizes
395
+
396
+ @sizes.setter
397
+ def sizes(self, sizes_new):
398
+ sizes_new = np.array(sizes_new)
399
+ if sizes_new.shape == self._sizes.shape:
400
+ self._sizes = sizes_new
401
+ else:
402
+ self._logger.error(
403
+ f"new sizes with size of {sizes_new.shape} is invalid. It must have the size of {self._sizes.shape}."
404
+ )
405
+ raise ValueError("New sizes must match the size of the old one.")
406
+ self._revive_count_left = (
407
+ self._max_revive_per_compartment * self._num_compartments
408
+ )
409
+
410
+ @property
411
+ def phis(self) -> np.ndarray:
412
+ """
413
+ The volume fractions field :math:`\\phi_i^{(m)}`, with the size of
414
+ :math:`N_\\mathrm{c} \\times M`. This property should not be reset in most cases.
415
+ Resetting this property also takes no effect, since :math:`\\phi_i^{(m)}` is not
416
+ considered as the master internal state of the algorithm. Any set values will be
417
+ ignored. If you intend to initialize the system from certain volume fractions,
418
+ please refer to :meth:`reinitialize_from_phis`.
419
+ """
420
+
421
+ return self._phis
422
+
423
+ @property
424
+ def omegas(self) -> np.ndarray:
425
+ """
426
+ The internal conjugate field :math:`\\omega_i^{(m)}`, with the size of
427
+ :math:`N_\\mathrm{c} \\times M`. This property should not be reset in most cases.
428
+ Resetting this property will change the state of the system, but skip necessary
429
+ reset of other internal states. If you intend to initialize the system from a
430
+ custom conjugate field, please refer to :meth:`reinitialize_from_omegas`.
431
+ """
432
+
433
+ return self._omegas
434
+
435
+ @property
436
+ def diagnostics(self) -> dict:
437
+ """
438
+ The diagnostics of the most recent call of :meth:`run`. The diagnostics contain
439
+ the convergence status and the original volume fractions before the clustering and
440
+ sorting algorithm is utilized to determine the unique phases.
441
+ """
442
+
443
+ return self._diagnostics
444
+
445
+ def run(
446
+ self,
447
+ *,
448
+ max_steps: Optional[float] = None,
449
+ tolerance: Optional[float] = None,
450
+ interval: Optional[int] = None,
451
+ progress: Optional[bool] = None,
452
+ ) -> tuple[np.ndarray, np.ndarray]:
453
+ """
454
+ Run instance to find coexisting phases. The keywords arguments can be used to
455
+ temporarily overwrite the provided values during construction of the class. Note
456
+ that this temporary values will not affect the defaults. See class
457
+ :class:`CoexistingPhasesFinder` for other tunable parameters. After each call, the
458
+ diagnostics will be updated, which can be used to, for example, inspecting whether
459
+ the iteration really converges. See :attr:`diagnostics` for more information.
460
+
461
+ Args:
462
+ max_steps:
463
+ The maximum number of steps to find the coexisting phases.
464
+ tolerance:
465
+ The tolerance to determine convergence. See
466
+ :paramref:`~CoexistingPhasesFinder.convergence_criterion` for more
467
+ information.
468
+ interval:
469
+ The interval of steps to check convergence.
470
+ progress:
471
+ Whether to show progress bar when checking convergence.
472
+
473
+ Returns:
474
+ [0]:
475
+ Volume fractions of each phase :math:`J_\\alpha`. 1D array with the size of
476
+ :math:`N_\\mathrm{p}`.
477
+ [1]:
478
+ Volume fractions of components in each phase :math:`\\phi_i^{(\\alpha)}`.
479
+ 2D array with the size of :math:`N_\\mathrm{p} \\times N_\\mathrm{c}`.
480
+ """
481
+
482
+ if max_steps is None:
483
+ max_steps = self._max_steps
484
+ if tolerance is None:
485
+ tolerance = self._tolerance
486
+ if interval is None:
487
+ interval = self._interval
488
+ if progress is None:
489
+ progress = self._progress
490
+
491
+ steps_tracker = int(np.ceil(max_steps / interval))
492
+ steps_inner = max(1, int(np.ceil(max_steps)) // steps_tracker)
493
+
494
+ steps = 0
495
+
496
+ chis_shifted = self._chis.copy()
497
+ chis_shifted += -self._chis.min() + self._additional_chis_shift
498
+
499
+ bar_max = -np.log10(tolerance)
500
+ bar_format = "{desc:<20}: {percentage:3.0f}%|{bar}{r_bar}"
501
+ bar_args = {
502
+ "total": bar_max,
503
+ "disable": not progress,
504
+ "bar_format": bar_format,
505
+ }
506
+ bar_text_args = {
507
+ "total": 1,
508
+ "disable": not progress,
509
+ "bar_format": "{desc}",
510
+ }
511
+ pbar1 = tqdm(**bar_args, position=0, desc="Incompressibility")
512
+ pbar2 = tqdm(**bar_args, position=1, desc="Field Error")
513
+ pbar3 = tqdm(**bar_args, position=2, desc="Volume Error")
514
+ pbar4 = tqdm(**bar_text_args, position=3, desc="Revive Count Left")
515
+
516
+ bar_val_func = lambda a: max(0, min(round(-np.log10(max(a, 1e-100)), 1), bar_max))
517
+
518
+ for _ in range(steps_tracker):
519
+ # do the inner steps
520
+ (
521
+ max_abs_incomp,
522
+ max_abs_omega_diff,
523
+ max_abs_Js_diff,
524
+ revive_count,
525
+ is_last_step_safe,
526
+ ) = multicomponent_self_consistent_metastep(
527
+ self._phi_means,
528
+ chis_shifted,
529
+ self._sizes,
530
+ omegas=self._omegas,
531
+ Js=self._Js,
532
+ phis=self._phis,
533
+ steps_inner=steps_inner,
534
+ acceptance_Js=self._acceptance_Js,
535
+ Js_step_upper_bound=self._Js_step_upper_bound,
536
+ acceptance_omega=self._acceptance_omega,
537
+ kill_threshold=self._kill_threshold,
538
+ revive_tries=self._revive_count_left,
539
+ revive_scaler=self._revive_scaler,
540
+ rng=self._rng,
541
+ )
542
+
543
+ steps += steps_inner
544
+ self._revive_count_left -= revive_count
545
+
546
+ if progress:
547
+ pbar1.n = bar_val_func(max_abs_incomp)
548
+ pbar2.n = bar_val_func(max_abs_omega_diff)
549
+ pbar3.n = bar_val_func(max_abs_Js_diff)
550
+ pbar4.n = 1
551
+ pbar1.refresh()
552
+ pbar2.refresh()
553
+ pbar3.refresh()
554
+ pbar4.set_description_str(
555
+ "{:<20}: {}".format("Revive Count Left", self._revive_count_left)
556
+ )
557
+ pbar4.refresh()
558
+
559
+ # check convergence
560
+ if self._convergence_criterion == "standard":
561
+ if (
562
+ is_last_step_safe
563
+ and tolerance > max_abs_incomp
564
+ and tolerance > max_abs_omega_diff
565
+ and tolerance > max_abs_Js_diff
566
+ ):
567
+ self._logger.info(
568
+ f"Composition and volumes reached stationary state after {steps} steps"
569
+ )
570
+ break
571
+ else:
572
+ raise ValueError(
573
+ f"Undefined convergence criterion: {self._convergence_criterion}"
574
+ )
575
+
576
+ pbar1.close()
577
+ pbar2.close()
578
+ pbar3.close()
579
+ pbar4.close()
580
+
581
+ # get final result
582
+ final_Js = self._Js.copy()
583
+ final_phis = self._phis.copy()
584
+ revive_compartments_by_copy(final_Js, final_phis, self._kill_threshold, self._rng)
585
+
586
+ # store diagnostic output
587
+ self._diagnostics = {
588
+ "steps": steps,
589
+ "max_abs_incomp": max_abs_incomp,
590
+ "max_abs_omega_diff": max_abs_omega_diff,
591
+ "max_abs_js_diff": max_abs_Js_diff,
592
+ "revive_count_left": self._revive_count_left,
593
+ "phis": final_phis,
594
+ "Js": final_Js,
595
+ }
596
+
597
+ phases_volumes, phases_compositions = get_clusters(final_Js, final_phis)
598
+
599
+ return phases_volumes, phases_compositions
600
+
601
+
602
+ def find_coexisting_phases(
603
+ chis: np.ndarray,
604
+ phi_means: np.ndarray,
605
+ num_compartments: int,
606
+ **kwargs,
607
+ ) -> tuple[np.ndarray, np.ndarray]:
608
+ """
609
+ The convenience wrapper for class :class:`CoexistingPhasesFinder`. This function will
610
+ create the class :class:`CoexistingPhasesFinder` internally, and then conduct the
611
+ random initialization, finally use self consistent iterations to find coexisting
612
+ phases. :paramref:`kwargs` is forwarded to :class:`CoexistingPhasesFinder`. See class
613
+ :class:`CoexistingPhasesFinder` for all the possible options.
614
+
615
+ Args:
616
+ chis:
617
+ The interaction matrix. 2D array with size of :math:`N_\\mathrm{c} \\times
618
+ N_\\mathrm{c}`. This matrix should be the full :math:`\\chi_{ij}` matrix of
619
+ the system, including the solvent component. Note that the matrix must be
620
+ symmetric, which is not checked but should be guaranteed externally.
621
+ phi_means:
622
+ The average volume fractions :math:`\\bar{\\phi}_i` of all the components of
623
+ the system. 1D array with size of :math:`N_\\mathrm{c}`. Note that the volume
624
+ fraction of the solvent is included as well, therefore the sum of this array
625
+ must be unity, which is not checked by this function and should be guaranteed
626
+ externally.
627
+ num_compartments:
628
+ Number of compartments :math:`M` in the system.
629
+ kwargs:
630
+ See class :class:`CoexistingPhasesFinder` for all the possible options.
631
+
632
+ Returns:
633
+ [0]:
634
+ Volume fractions of each phase :math:`J_\\alpha`. 1D array with the size of
635
+ :math:`N_\\mathrm{p}`.
636
+ [1]:
637
+ Volume fractions of components in each phase :math:`\\phi_i^{(\\alpha)}`. 2D
638
+ array with the size of :math:`N_\\mathrm{p} \\times N_\\mathrm{c}`.
639
+ """
640
+ finder = CoexistingPhasesFinder(chis, phi_means, num_compartments, **kwargs)
641
+ return finder.run()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Yicheng Qiang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.1
2
+ Name: flory
3
+ Version: 0.0.0
4
+ Summary: Python package for finding coexisting phases in multicomponent mixtures
5
+ Author-email: Yicheng Qiang <yicheng.qiang@ds.mpg.de>
6
+ License: MIT License
7
+ Project-URL: homepage, https://github.com/qiangyicheng/flory
8
+ Project-URL: repository, https://github.com/qiangyicheng/flory
9
+ Keywords: physics,phase-separation,free-energy
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Topic :: Scientific/Engineering
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numba >=0.57
23
+ Requires-Dist: numpy >=1.22
24
+ Requires-Dist: scipy >=1.7
25
+ Requires-Dist: tqdm >=4.60
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest >=6.2 ; extra == 'dev'
28
+ Requires-Dist: pytest-cov >=4.0 ; extra == 'dev'
29
+ Requires-Dist: Sphinx >=4 ; extra == 'dev'
30
+ Requires-Dist: sphinx-autodoc-typehints >=2.2 ; extra == 'dev'
31
+ Requires-Dist: sphinx-paramlinks >=0.6 ; extra == 'dev'
32
+ Requires-Dist: sphinx-rtd-theme >=2.0 ; extra == 'dev'
33
+
34
+ # flory
35
+
36
+ [![Tests](https://github.com/qiangyicheng/flory/actions/workflows/python-package.yml/badge.svg)](https://github.com/qiangyicheng/flory/actions/workflows/python-package.yml)
37
+ [![Codecov](https://codecov.io/github/qiangyicheng/flory/graph/badge.svg?token=YF3K9ST8XQ)](https://codecov.io/github/qiangyicheng/flory)
38
+ [![Documentation Status](https://readthedocs.org/projects/flory/badge/?version=latest)](https://flory.readthedocs.io/en/latest/?badge=latest)
39
+
40
+ `flory` is a Python package for determining multiple coexisting phases in multicomponent mixtures.
41
+ Although finding coexisting phases of binary mixture is simple, finding multiple coexisting phases in many-component mixtures is challenging, mainly due to the coupling of lots of nolinear equations.
42
+ Currently `flory` supports finding coexisting phases of arbitrary multicomponent system with Flory-Huggins interactions in the canonical ensemble, whose average free energy density reads
43
+
44
+ $$\bar{f}({N_\mathrm{p}}, \{J_\alpha\}, \{\phi_i^{(\alpha)}\}) = \sum_{\alpha=1}^{{N_\mathrm{p}}} J_\alpha f(\{\phi_i^{(\alpha)}\}) \; ,$$
45
+
46
+ where $N_\mathrm{c}$ is the number of components, $N_\mathrm{p}$ is the number of phases, $J_\alpha$ is the volume fraction of the phase $\alpha$ and $\phi_i^{(\alpha)}$ is the volume fraction of the component $i$ in the phase $\alpha$.
47
+ Each phase is considered to be homogeneous with a energy density
48
+
49
+ $$f(\{\phi_i\}) = \frac{1}{2}\sum_{i,j=1}^{N_\mathrm{c}} \chi_{ij} \phi_i \phi_j + \sum_{i=1}^{N_\mathrm{c}} \frac{\phi_i}{l_i} \ln \phi_i \; ,$$
50
+
51
+ where $\chi_{ij}$ is the Flory-Huggins interaction between component $i$ and $j$, and $l_i$ is the relative molecule size of the component $i$.
52
+ With given interaction matrix $\chi_{ij}$, average volume fractions of all components across the system $\bar{\phi}_i$ and the relative molecule sizes $l_i$, `flory` provides tools to find the coexisting phases in equilibrium.
53
+
54
+ Installation
55
+ ------------
56
+
57
+ `flory` is available on `pypi`, so you should be able to install it through `pip`:
58
+
59
+ ```bash
60
+ pip install flory
61
+ ```
62
+
63
+ Usage
64
+ -----
65
+
66
+ A simple example finds the coexisting phases of a binary mixture:
67
+
68
+ ```python
69
+ import flory
70
+
71
+ chis = [[0, 4.0], [4.0, 0]] # Set the \chi matrix
72
+ phi_means = [0.5, 0.5] # Set the average volume fractions
73
+
74
+ volumes, phis = flory.find_coexisting_phases(chis, phi_means, 16) # obtain the relative volumes and the compositions of the two coexisting phases
75
+ ```
76
+
77
+
78
+ More information
79
+ ----------------
80
+ * See examples in [examples folder](https://github.com/qiangyicheng/flory/tree/main/examples)
81
+ * [Full documentation on readthedocs](https://flory.readthedocs.io/)
82
+
@@ -0,0 +1,9 @@
1
+ flory/__init__.py,sha256=nJKzfDFUBaQ7iAIMYIzGmG46gK0_qlQjpe1Rr8Q3fIM,582
2
+ flory/_version.py,sha256=Pv9ouMrHW4YJ2RqzWPcojtYRoZtNSgzCzgB3B3Q2FMU,411
3
+ flory/mcmp.py,sha256=rolC1EIChl2BuNUN9Adw3L6pN2qpRIqujf_zo38FKvA,29453
4
+ flory/detail/mcmp_impl.py,sha256=Q-0plKjUwq5byFzyNCrRa8uts5XkSK2NEqMbIAYfh6k,23394
5
+ flory-0.0.0.dist-info/LICENSE,sha256=Avmhj-OrLS-F1uar0j_Z6LllTF0oRBAlAV_JpRlGD6w,1070
6
+ flory-0.0.0.dist-info/METADATA,sha256=XdkhR_kMtxUvH0iiDzIC5MvaB6QdbwVBFhA4Lf6fxZ0,3974
7
+ flory-0.0.0.dist-info/WHEEL,sha256=R0nc6qTxuoLk7ShA2_Y-UWkN8ZdfDBG2B6Eqpz2WXbs,91
8
+ flory-0.0.0.dist-info/top_level.txt,sha256=jtZpdTpD2w3GUbSbuIwh0-DTaWB2Jz_Nqd6up2DxXBU,6
9
+ flory-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (72.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ flory