polyphys 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.
Files changed (69) hide show
  1. polyphys/__init__.py +7 -0
  2. polyphys/__version__.py +22 -0
  3. polyphys/analyze/__init__.py +51 -0
  4. polyphys/analyze/clusters.py +1738 -0
  5. polyphys/analyze/correlations.py +995 -0
  6. polyphys/analyze/density.py +1138 -0
  7. polyphys/analyze/measurer.py +1338 -0
  8. polyphys/analyze/pipeline.py +1868 -0
  9. polyphys/analyze/stats.py +810 -0
  10. polyphys/analyze/sumrule.py +1159 -0
  11. polyphys/manage/__init__.py +11 -0
  12. polyphys/manage/organizer.py +2188 -0
  13. polyphys/manage/parser.py +1264 -0
  14. polyphys/manage/types.py +171 -0
  15. polyphys/manage/utils.py +777 -0
  16. polyphys/probe/__init__.py +25 -0
  17. polyphys/probe/binders.py +711 -0
  18. polyphys/probe/chain_size.py +583 -0
  19. polyphys/probe/core.py +437 -0
  20. polyphys/probe/histograms.py +863 -0
  21. polyphys/probe/pairs.py +555 -0
  22. polyphys/simulate/__init__.py +48 -0
  23. polyphys/simulate/build.py +1231 -0
  24. polyphys/simulate/core.py +237 -0
  25. polyphys/simulate/data.py +977 -0
  26. polyphys/simulate/dump.py +752 -0
  27. polyphys/simulate/log.py +788 -0
  28. polyphys/spec.py +1359 -0
  29. polyphys/tests/analyze/test_clusters.py +1058 -0
  30. polyphys/tests/analyze/test_correlations.py +708 -0
  31. polyphys/tests/analyze/test_density.py +813 -0
  32. polyphys/tests/analyze/test_measurer.py +785 -0
  33. polyphys/tests/analyze/test_pipeline.py +1881 -0
  34. polyphys/tests/analyze/test_stats.py +732 -0
  35. polyphys/tests/analyze/test_sumrule.py +917 -0
  36. polyphys/tests/conftest.py +753 -0
  37. polyphys/tests/data/lammps_build/make_starts.py +70 -0
  38. polyphys/tests/manage/test_organizer.py +1495 -0
  39. polyphys/tests/manage/test_parser.py +1222 -0
  40. polyphys/tests/manage/test_utils.py +498 -0
  41. polyphys/tests/probe/conftest.py +407 -0
  42. polyphys/tests/probe/test_binders.py +686 -0
  43. polyphys/tests/probe/test_chain_size.py +557 -0
  44. polyphys/tests/probe/test_core.py +391 -0
  45. polyphys/tests/probe/test_histograms.py +779 -0
  46. polyphys/tests/probe/test_pairs.py +475 -0
  47. polyphys/tests/simulate/test_build.py +1042 -0
  48. polyphys/tests/simulate/test_core.py +106 -0
  49. polyphys/tests/simulate/test_data.py +858 -0
  50. polyphys/tests/simulate/test_dump.py +487 -0
  51. polyphys/tests/simulate/test_log.py +667 -0
  52. polyphys/tests/strategies.py +120 -0
  53. polyphys/tests/synthetic.py +410 -0
  54. polyphys/tests/test_conftest.py +414 -0
  55. polyphys/tests/test_docs.py +82 -0
  56. polyphys/tests/test_packaging.py +97 -0
  57. polyphys/tests/test_spec.py +772 -0
  58. polyphys/tests/viz/conftest.py +32 -0
  59. polyphys/tests/viz/test_colors.py +113 -0
  60. polyphys/tests/viz/test_helpers.py +292 -0
  61. polyphys/viz/__init__.py +15 -0
  62. polyphys/viz/colors.py +204 -0
  63. polyphys/viz/helpers.py +549 -0
  64. polyphys-1.0.0.dist-info/METADATA +339 -0
  65. polyphys-1.0.0.dist-info/RECORD +69 -0
  66. polyphys-1.0.0.dist-info/WHEEL +5 -0
  67. polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
  68. polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
  69. polyphys-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1159 @@
1
+ """
2
+ ==========================================================
3
+ :mod:`polyphys.analyze.sumrule`
4
+ ==========================================================
5
+
6
+
7
+ The :mod:`polyphys.analyze.sumrule` module tests the density sum rule of
8
+ Jeon et al. (2016) on the ensemble-averaged density profiles of a space: it
9
+ scales each species' profile by its particle size, normalizes it by a
10
+ reference value, folds a longitudinal profile about the polymer's center,
11
+ and sums the species' scaled profiles state point by state point. The
12
+ profiles come from the ``ensAvg`` stage of the artifact lineage that
13
+ :mod:`polyphys.manage.organizer` builds; the per-frame densities behind them
14
+ are the caller's work.
15
+
16
+ The profiles must be the raw ensemble averages, in their physical units: a
17
+ volume fraction or a number density, not a profile already divided by its
18
+ sum, its integral, or its maximum. The sum rule relates the species to each
19
+ other through their physical scales, so a species normalized on its own
20
+ enters the sum :math:`\\sum_i \\phi_i / a_i` with an arbitrary weight and
21
+ ``<prop>-scale`` loses its unit and its comparability with the nominal bulk
22
+ value. Only the ``<prop>-norm`` columns of such a profile survive, because
23
+ dividing by the profile's own ``'first'`` or ``'max'`` value is
24
+ scale-invariant. :func:`space_profiles` warns when a profile sums to one,
25
+ the signature of a profile normalized by its sum, but cannot detect the
26
+ other normalizations from one profile.
27
+
28
+ Notation
29
+ ========
30
+
31
+ :math:`a_i`
32
+ The particle diameter of species :math:`i`, in the monomer diameter: the
33
+ parser attribute that the study's ``species_sizes`` names for it.
34
+ :math:`\\phi_i(x)`, :math:`\\rho_i(x)`
35
+ The local volume fraction (dimensionless) and local number density (per
36
+ cubic monomer diameter) of species :math:`i`, related by
37
+ :math:`\\phi_i = \\pi a_i^3 \\rho_i / 6`.
38
+ :math:`r`
39
+ The radial coordinate: the distance from the cylinder axis in a
40
+ cylinder and from the polymer's center of mass in a periodic cube. The
41
+ bins of an ``r`` profile run outward from :math:`r = 0`.
42
+ :math:`z`
43
+ The longitudinal coordinate: the axial distance from the polymer's
44
+ center of mass in a cylinder, with bins symmetric about :math:`z = 0`.
45
+ *scaled profile*
46
+ One term of the sum rule, the column ``<prop>-scale``:
47
+ :math:`\\phi_i / a_i` for a volume fraction and :math:`a_i^2 \\rho_i` for
48
+ a number density.
49
+ *normalizer*
50
+ The reference value a scaled profile, or the species sum, is divided by:
51
+ see :py:obj:`~.NormalizerT`.
52
+ :math:`D`, :math:`a_c`
53
+ The cylinder diameter and a crowder diameter; the ratio
54
+ :math:`\\kappa = (D - a_c) / a_c` sets how far the crowder layering on
55
+ the wall reaches inward.
56
+
57
+ Contents
58
+ ========
59
+
60
+ - :func:`normalize_r` and :func:`normalize_z`: normalize one scaled profile,
61
+ folding a longitudinal one about :math:`z = 0`.
62
+ - :func:`space_profiles`: scale, normalize, and annotate every
63
+ ensemble-averaged profile of one species across a space.
64
+ - :func:`space_profiles_from_spec`: the same, with the study's choices read
65
+ from its :class:`~polyphys.spec.ProjectSpec`.
66
+ - :func:`sum_rule_profiles`: sum the species' scaled profiles and normalize
67
+ the sum.
68
+ - :data:`PROFILE_NORMALIZERS` and :data:`SUM_NORMALIZERS`: the default
69
+ normalizers by geometry and direction.
70
+
71
+ Dependencies
72
+ ============
73
+ - `numpy` and `pandas`.
74
+ - :mod:`polyphys.manage.organizer`:
75
+ :func:`~polyphys.manage.organizer.make_database` and
76
+ :func:`~polyphys.manage.organizer.create_fullname`.
77
+ - :mod:`polyphys.manage.utils` and :mod:`polyphys.manage.types`.
78
+ - :class:`polyphys.spec.ProjectSpec`.
79
+
80
+ References
81
+ ==========
82
+ - C. Jeon, C. Hyeon, Y. Jung, and B.-Y. Ha, "How are molecular crowding and
83
+ the spatial organization of a biopolymer interrelated", Soft Matter 12,
84
+ 9786-9796 (2016). https://doi.org/10.1039/c6sm01924b
85
+ - J. Kim, C. Jeon, H. Jeong, Y. Jung, and B.-Y. Ha, "A polymer in a crowded
86
+ and confined space: effects of crowder size and poly-dispersity", Soft
87
+ Matter 11, 1877-1888 (2015). https://doi.org/10.1039/c4sm02198c
88
+ - A. H. Sadeghi Isfahani, PhD thesis, University of Waterloo (2024),
89
+ Chapter 3.
90
+
91
+ .. versionadded:: 0.6.0
92
+ """
93
+
94
+ import logging
95
+ import os
96
+ import warnings
97
+ from glob import glob
98
+ from typing import Any, Callable, Literal, Mapping, Sequence, TYPE_CHECKING
99
+
100
+ import numpy as np
101
+ import pandas as pd
102
+
103
+ from ..manage.organizer import create_fullname, make_database
104
+ from ..manage.types import (
105
+ DirectionT,
106
+ EntityT,
107
+ GeometryT,
108
+ GroupT,
109
+ NormalizerT,
110
+ ParserClassT,
111
+ Pathish,
112
+ )
113
+ from ..manage.utils import invalid_keyword, round_to_multiple, sort_filenames
114
+ from ..spec import ProjectSpec
115
+
116
+ if TYPE_CHECKING:
117
+ # Unused here, but sphinx_autodoc_typehints runs this block to resolve
118
+ # the "ParserBase" forward reference inside ParserClassT.
119
+ from ..manage.parser import ParserBase # noqa: F401
120
+
121
+ __all__ = [
122
+ 'PROFILE_NORMALIZERS',
123
+ 'SUM_NORMALIZERS',
124
+ 'normalize_r',
125
+ 'normalize_z',
126
+ 'space_profiles',
127
+ 'space_profiles_from_spec',
128
+ 'sum_rule_profiles',
129
+ ]
130
+
131
+ logger = logging.getLogger(__name__)
132
+
133
+ # Relative tolerance, in the largest |z|, for matching a bin center with its
134
+ # mirror when :func:`normalize_z` folds: bin centers built from a symmetric
135
+ # range carry float64 round-off of a few 1e-16 relative, and the smallest
136
+ # intended asymmetry is a fraction of a bin, far above 1e-9 of the range,
137
+ # as for ``_BIN_COUNT_RTOL`` in :mod:`polyphys.analyze.measurer`.
138
+ _FOLD_RTOL = 1e-9
139
+
140
+
141
+ # The normalizer each density profile takes by default, keyed by (geometry,
142
+ # direction). In a cylinder, r runs from the axis to the wall, whose crowder
143
+ # layering makes the maximum the wrong reference, so the axis value is used;
144
+ # along z and in a periodic cube the maximum is the far-field value of a
145
+ # crowder profile and the center value of a monomer profile.
146
+ PROFILE_NORMALIZERS: dict[tuple[GeometryT, DirectionT], NormalizerT] = {
147
+ ('cylindrical', 'r'): 'first',
148
+ ('cylindrical', 'z'): 'max',
149
+ ('cubic', 'r'): 'max',
150
+ }
151
+ """The default normalizer of a density profile, by (geometry, direction):
152
+ ``'first'`` for ``r`` in a cylinder, ``'max'`` otherwise.
153
+
154
+ .. versionadded:: 0.6.0
155
+ """
156
+
157
+ # The normalizer the species sum takes by default: its own value at the
158
+ # axis in a cylinder, and its far-field value along z and in a cube, where
159
+ # only crowders remain.
160
+ SUM_NORMALIZERS: dict[tuple[GeometryT, DirectionT], NormalizerT] = {
161
+ ('cylindrical', 'r'): 'first',
162
+ ('cylindrical', 'z'): 'last',
163
+ ('cubic', 'r'): 'last',
164
+ }
165
+ """The default normalizer of the species sum, by (geometry, direction):
166
+ ``'first'`` for ``r`` in a cylinder, ``'last'`` otherwise.
167
+
168
+ .. versionadded:: 0.6.0
169
+ """
170
+
171
+
172
+ def _normalizer_value(
173
+ prop: str, profile: pd.DataFrame, normalizer: NormalizerT
174
+ ) -> float:
175
+ """
176
+ Return the value that `normalizer` picks from the ``<prop>-scale``
177
+ column of `profile`: its first or last value, its maximum, the given
178
+ number, or the result of the given function of `profile`.
179
+
180
+ Raises
181
+ ------
182
+ ValueError
183
+ If `normalizer` is a string other than ``'first'``, ``'last'``, or
184
+ ``'max'``, or neither a string, a number, nor callable.
185
+ """
186
+ scale = profile[f"{prop}-scale"]
187
+ if isinstance(normalizer, str):
188
+ invalid_keyword(normalizer, ["first", "last", "max"])
189
+ if normalizer == "first":
190
+ return float(scale.iloc[0])
191
+ if normalizer == "last":
192
+ return float(scale.iloc[-1])
193
+ return float(scale.max())
194
+ if isinstance(normalizer, (int, float)) and not isinstance(
195
+ normalizer, bool
196
+ ):
197
+ return float(normalizer)
198
+ if callable(normalizer):
199
+ return float(normalizer(profile))
200
+ raise ValueError(
201
+ f"'normalizer' must be 'first', 'last', 'max', a number, or a "
202
+ f"function of the profile, not {normalizer!r}."
203
+ )
204
+
205
+
206
+ def _with_parser(
207
+ func: Callable[[pd.DataFrame, Any], float], info: Any
208
+ ) -> Callable[[pd.DataFrame], float]:
209
+ """
210
+ Bind the parser instance `info` as the second argument of `func`, a
211
+ two-argument normalizer of :func:`space_profiles`.
212
+ """
213
+ def normalizer(profile: pd.DataFrame) -> float:
214
+ return func(profile, info)
215
+ return normalizer
216
+
217
+
218
+ def _normalize(
219
+ prop: str, profile: pd.DataFrame, normalizer: NormalizerT
220
+ ) -> pd.DataFrame:
221
+ """
222
+ Return a copy of `profile` with the ``<prop>-normalizer`` column set to
223
+ the value `normalizer` picks and ``<prop>-norm`` to ``<prop>-scale``
224
+ divided by it; a zero value warns and sets ``<prop>-norm`` to zero.
225
+ """
226
+ out = profile.copy()
227
+ value = _normalizer_value(prop, out, normalizer)
228
+ out[f"{prop}-normalizer"] = value
229
+ if value != 0:
230
+ out[f"{prop}-norm"] = out[f"{prop}-scale"] / value
231
+ else:
232
+ warnings.warn(
233
+ f"The normalizer of '{prop}' is zero; its normalized values are"
234
+ " set to zero.",
235
+ UserWarning,
236
+ )
237
+ out[f"{prop}-norm"] = 0.0
238
+ return out
239
+
240
+
241
+ def normalize_r(
242
+ prop: str,
243
+ ens_avg: pd.DataFrame,
244
+ normalizer: NormalizerT = "first",
245
+ ) -> pd.DataFrame:
246
+ """
247
+ Normalize the scaled radial profile ``<prop>-scale`` of an
248
+ ensemble-averaged distribution by a reference value.
249
+
250
+ The radial coordinate :math:`r` is the distance from the cylinder axis
251
+ in a cylinder and from the polymer's center of mass in a periodic cube,
252
+ in the monomer diameter; the profile's bins run outward from
253
+ :math:`r = 0`.
254
+
255
+ Parameters
256
+ ----------
257
+ prop : str
258
+ The full property name, such as ``'rPhiCrd'``; the input has the
259
+ column ``<prop>-scale``.
260
+ ens_avg : pd.DataFrame
261
+ The ensemble-averaged distribution, one row per bin. It is not
262
+ modified.
263
+ normalizer : :py:obj:`~.NormalizerT`, \
264
+ default 'first'
265
+ The reference value: ``'first'`` is the value at :math:`r = 0`,
266
+ ``'last'`` the value of the outermost bin, ``'max'`` the maximum;
267
+ a number is used as given; a function is called with `ens_avg` and
268
+ returns the value.
269
+
270
+ Returns
271
+ -------
272
+ pd.DataFrame
273
+ A copy of `ens_avg` with two more columns: ``<prop>-normalizer``,
274
+ the reference value, and ``<prop>-norm``, ``<prop>-scale`` divided
275
+ by it.
276
+
277
+ Raises
278
+ ------
279
+ ValueError
280
+ If `normalizer` is none of the accepted forms.
281
+
282
+ Warns
283
+ -----
284
+ UserWarning
285
+ If the reference value is zero; ``<prop>-norm`` is then zero.
286
+
287
+ Examples
288
+ --------
289
+ >>> profile = pd.DataFrame({'bin_center': [0.5, 1.5, 2.5],
290
+ ... 'rPhiCrd-scale': [2.0, 4.0, 1.0]})
291
+ >>> normalize_r('rPhiCrd', profile)['rPhiCrd-norm'].tolist()
292
+ [1.0, 2.0, 0.5]
293
+ >>> normalize_r('rPhiCrd', profile, 'max')['rPhiCrd-norm'].tolist()
294
+ [0.5, 1.0, 0.25]
295
+ >>> normalize_r('rPhiCrd', profile, 8.0)['rPhiCrd-norm'].tolist()
296
+ [0.25, 0.5, 0.125]
297
+
298
+ A function sees the whole frame, so it can average the inner bins:
299
+
300
+ >>> normalize_r('rPhiCrd', profile,
301
+ ... lambda df: df['rPhiCrd-scale'].iloc[:2].mean()
302
+ ... )['rPhiCrd-normalizer'].iloc[0]
303
+ np.float64(3.0)
304
+
305
+ Notes
306
+ -----
307
+ Time and space are :math:`O(n_\\text{bins} \\, n_\\text{cols})` for the
308
+ copy. The references of the sum rule are in :func:`space_profiles`.
309
+
310
+ .. versionadded:: 0.6.0
311
+ """
312
+ return _normalize(prop, ens_avg, normalizer)
313
+
314
+
315
+ def normalize_z(
316
+ prop: str,
317
+ ens_avg: pd.DataFrame,
318
+ normalizer: NormalizerT = "max",
319
+ fold: bool = True,
320
+ ) -> pd.DataFrame:
321
+ """
322
+ Normalize the scaled longitudinal profile ``<prop>-scale`` of an
323
+ ensemble-averaged distribution by a reference value and, by default,
324
+ fold it about :math:`z = 0`.
325
+
326
+ The longitudinal coordinate :math:`z` is the axial distance from the
327
+ polymer's center of mass, in the monomer diameter. Folding needs bins
328
+ symmetric about :math:`z = 0`; it averages bin :math:`i` with its
329
+ mirror bin :math:`n - 1 - i` and keeps the center bin of an odd count
330
+ as it is; the folded ``bin_center`` is :math:`|z|`. The one exception
331
+ is ``<prop>-scale-normalized_curve``, a distribution over the bins,
332
+ whose mirror bins are summed, so the folded curve is the distribution
333
+ of :math:`|z|` and still sums to one.
334
+
335
+ Parameters
336
+ ----------
337
+ prop : str
338
+ The full property name, such as ``'zRhoMon'``; the input has the
339
+ column ``<prop>-scale``.
340
+ ens_avg : pd.DataFrame
341
+ The ensemble-averaged distribution, one row per bin, with a
342
+ ``bin_center`` column when `fold` is True. It is not modified.
343
+ normalizer : :py:obj:`~.NormalizerT`, default 'max'
344
+ The reference value, as in :func:`normalize_r`; ``'max'`` is the
345
+ far-field value of a crowder profile and the center value of a
346
+ monomer profile when each is monotone in :math:`|z|`.
347
+ fold : bool, default True
348
+ Whether to fold the profile about :math:`z = 0`.
349
+
350
+ Returns
351
+ -------
352
+ pd.DataFrame
353
+ A copy of `ens_avg` with ``<prop>-normalizer`` and ``<prop>-norm``
354
+ added, as in :func:`normalize_r`. With `fold`, every numeric column
355
+ is averaged with its mirror bin, the other columns are those of the
356
+ :math:`z \\geq 0` half, and the result has ``ceil(n / 2)`` rows
357
+ ordered by :math:`|z|`.
358
+
359
+ Raises
360
+ ------
361
+ ValueError
362
+ If `normalizer` is none of the accepted forms, or `fold` is True
363
+ and `ens_avg` has no ``bin_center`` column or its bin centers do
364
+ not increase symmetrically about :math:`z = 0`, to a tolerance of
365
+ ``1e-9`` times the largest :math:`|z|`.
366
+
367
+ Warns
368
+ -----
369
+ UserWarning
370
+ If the reference value is zero; ``<prop>-norm`` is then zero.
371
+
372
+ Examples
373
+ --------
374
+ An even number of bins folds to half as many:
375
+
376
+ >>> profile = pd.DataFrame({'bin_center': [-1.5, -0.5, 0.5, 1.5],
377
+ ... 'zRhoMon-scale': [1.0, 3.0, 3.0, 1.0]})
378
+ >>> normalize_z('zRhoMon', profile)
379
+ bin_center zRhoMon-scale zRhoMon-normalizer zRhoMon-norm
380
+ 0 0.5 3.0 3.0 1.000000
381
+ 1 1.5 1.0 3.0 0.333333
382
+
383
+ An odd number keeps its center bin:
384
+
385
+ >>> profile = pd.DataFrame({'bin_center': [-1.0, 0.0, 1.0],
386
+ ... 'zRhoMon-scale': [2.0, 4.0, 2.0]})
387
+ >>> normalize_z('zRhoMon', profile)['zRhoMon-norm'].tolist()
388
+ [1.0, 0.5]
389
+
390
+ Notes
391
+ -----
392
+ The ``-var`` and ``-sem`` columns of the input are averaged like every
393
+ other numeric column, which is what the research code did; the result
394
+ is not the uncertainty of the folded mean, which would need the
395
+ per-replicate folded profiles. Time and space are
396
+ :math:`O(n_\\text{bins} \\, n_\\text{cols})`.
397
+
398
+ .. versionadded:: 0.6.0
399
+ .. versionchanged:: 0.9.0
400
+ Reject bins that are not symmetric about :math:`z = 0`, and fold
401
+ ``<prop>-scale-normalized_curve`` by summing mirror bins.
402
+ """
403
+ out = _normalize(prop, ens_avg, normalizer)
404
+ if not fold:
405
+ return out
406
+ if "bin_center" not in out.columns:
407
+ raise ValueError("Folding needs a 'bin_center' column.")
408
+ centers = out["bin_center"].to_numpy(dtype=np.float64)
409
+ # Mirror bins match to a tolerance relative to the largest |z|, far
410
+ # above float64 round-off in the bin centers and far below a bin width.
411
+ atol = _FOLD_RTOL * float(np.max(np.abs(centers), initial=0.0))
412
+ if np.any(np.diff(centers) <= 0) or not np.allclose(
413
+ centers, -centers[::-1], rtol=0.0, atol=atol
414
+ ):
415
+ raise ValueError(
416
+ "Folding needs bin centers that increase and are symmetric"
417
+ f" about z = 0; got {centers.tolist()}."
418
+ )
419
+ n_bins = len(out)
420
+ half = n_bins // 2
421
+ # The z > 0 half in increasing |z|, and the z < 0 half reversed to match
422
+ # it bin for bin; the center bin of an odd count stays on its own.
423
+ pos = out.iloc[n_bins - half:].reset_index(drop=True)
424
+ neg = out.iloc[:half].iloc[::-1].reset_index(drop=True).copy()
425
+ neg["bin_center"] = -neg["bin_center"]
426
+ numeric = out.select_dtypes(include="number").columns
427
+ folded = pos.copy()
428
+ folded[numeric] = 0.5 * (pos[numeric] + neg[numeric])
429
+ # The normalized curve is a distribution over the bins: folding it onto
430
+ # |z| adds the two mirror probabilities, P(|z|) = P(z) + P(-z), so it
431
+ # still sums to one.
432
+ curve = f"{prop}-scale-normalized_curve"
433
+ if curve in folded.columns:
434
+ folded[curve] = pos[curve] + neg[curve]
435
+ if n_bins % 2:
436
+ center = out.iloc[[half]].copy()
437
+ center["bin_center"] = center["bin_center"].abs()
438
+ folded = pd.concat([center, folded], axis=0)
439
+ return folded.reset_index(drop=True)
440
+
441
+
442
+ def space_profiles(
443
+ input_database: str,
444
+ quantity: Literal["Rho", "Phi"],
445
+ direction: DirectionT,
446
+ species: EntityT,
447
+ parser: ParserClassT,
448
+ hierarchy: str,
449
+ physical_attrs: list[str],
450
+ group: GroupT,
451
+ species_sizes: Mapping[EntityT, str],
452
+ *,
453
+ crowders: Sequence[EntityT] = ("Crd",),
454
+ normalizer: (
455
+ Literal["first", "last", "max"] | float
456
+ | Callable[[pd.DataFrame, Any], float] | None
457
+ ) = None,
458
+ divisor: float = 0.025,
459
+ round_to: int = 3,
460
+ is_save: bool = False,
461
+ crowder_attr: str = "phi_bulk_c",
462
+ ) -> pd.DataFrame:
463
+ """
464
+ Scale and normalize the ensemble-averaged density profiles of one
465
+ species across a space for the density sum rule, add the physical
466
+ attributes of each state point, and concatenate them into one
467
+ DataFrame.
468
+
469
+ The density sum rule of Jeon et al. (2016), eqns (5) to (7), states that
470
+ in a crowded polymer solution the volume fractions :math:`\\phi_i(x)`
471
+ of the species :math:`i`, each rescaled by its particle diameter
472
+ :math:`a_i`, sum to the far-field value of the crowder term:
473
+
474
+ .. math::
475
+
476
+ \\sum_i \\frac{\\phi_i(x)}{a_i} \\approx \\frac{\\phi_c(\\infty)}{a_c},
477
+ \\qquad
478
+ \\sum_i a_i^2 \\rho_i(x) \\approx a_c^2 \\rho_c(\\infty),
479
+
480
+ where the number density :math:`\\rho_i = 6 \\phi_i / (\\pi a_i^3)`
481
+ makes the two forms the same statement, and :math:`x` is the radial
482
+ coordinate :math:`r` (the distance from the cylinder axis in a cylinder
483
+ and from the polymer's center of mass in a periodic cube) or the
484
+ longitudinal coordinate :math:`z` (the axial distance from the
485
+ polymer's center of mass). This function builds one term of the sum:
486
+ for ``quantity='Phi'`` the scaled profile ``<prop>-scale`` is the
487
+ volume fraction divided by the species' diameter, and for
488
+ ``quantity='Rho'`` the number density times the diameter squared.
489
+ :func:`sum_rule_profiles` builds the sum.
490
+
491
+ The input profiles must be the raw ensemble averages, in their physical
492
+ units. A profile normalized beforehand, by its sum, its integral, or its
493
+ maximum, enters the species sum with an arbitrary weight, and its
494
+ ``<prop>-scale`` loses its unit; only its ``<prop>-norm`` survives,
495
+ since dividing by the profile's own reference value is scale-invariant.
496
+ A profile whose ``<prop>-mean`` sums to one, as one normalized by its
497
+ sum does, raises a :class:`UserWarning` naming the file; the other
498
+ normalizations cannot be told from one profile.
499
+
500
+ Parameters
501
+ ----------
502
+ input_database : str
503
+ Path to the directory of the ensemble-averaged profiles, named
504
+ ``<ensemble_long>-<group>-<direction><quantity><species>-ensAvg.csv``,
505
+ ending with a path separator. Each file has a ``bin_center`` column
506
+ and a ``<direction><quantity><species>-mean`` column holding the
507
+ raw ensemble-averaged volume fraction or number density.
508
+ quantity : {'Rho', 'Phi'}
509
+ The number density or the volume fraction.
510
+ direction : {'r', 'z'}
511
+ The coordinate of the profile; ``'z'`` exists in a cylinder only.
512
+ species : EntityT
513
+ The species label of the profile, such as ``'Mon'`` or ``'Crd'``; a
514
+ key of `species_sizes`.
515
+ parser : ParserClassT
516
+ The study's parser class; its geometry chooses the default
517
+ normalizer and the recentered columns.
518
+ hierarchy : str
519
+ Pattern prefix of the filenames, such as ``'N*'``.
520
+ physical_attrs : list of str
521
+ The parser attributes added as columns to the result.
522
+ group : GroupT
523
+ The particle group of the profiles, usually ``'all'``.
524
+ species_sizes : mapping of EntityT to str
525
+ Each species label mapped to the parser attribute holding its
526
+ particle diameter, in the monomer diameter: the ``species_sizes`` of
527
+ the study's :class:`~polyphys.spec.ProjectSpec`.
528
+ crowders : sequence of EntityT, default ('Crd',)
529
+ The crowder species, each a key of `species_sizes`; one set of
530
+ ``bin_center`` columns in crowder units is added per crowder.
531
+ normalizer : NormalizerT or callable or None, default None
532
+ The reference value of the normalization. None takes the default of
533
+ :data:`PROFILE_NORMALIZERS` for the parser's geometry and
534
+ `direction`. Otherwise ``'first'``, ``'last'``, ``'max'``, or a
535
+ number, as in :func:`normalize_r`; or a function of two arguments,
536
+ the scaled profile and the parser instance of the file, returning
537
+ the value, so that ``lambda df, info: info.phi_bulk_c / info.dcrowd``
538
+ normalizes a ``'Phi'`` profile by the nominal bulk volume fraction
539
+ of crowders in scale units. An explicit value is logged at INFO
540
+ with the default it replaces.
541
+ divisor : float, default 0.025
542
+ Rounding step of the bulk volume fraction of crowders named by
543
+ `crowder_attr`.
544
+ round_to : int, default 3
545
+ Number of decimal places of that rounding.
546
+ is_save : bool, default False
547
+ If True, save the result as
548
+ ``<space>-<group>-<direction><quantity><species>-normalizedRescaled-space.csv``
549
+ in the ``analysis`` phase's ``space`` directory, which
550
+ :func:`~polyphys.manage.organizer.make_database` creates next to
551
+ `input_database`, and log the path at INFO.
552
+ crowder_attr : str, default 'phi_bulk_c'
553
+ The parser attribute holding the bulk volume fraction of crowders,
554
+ added as a column of that name and rounded into
555
+ ``phi_bulk_c_round``.
556
+
557
+ Returns
558
+ -------
559
+ pd.DataFrame
560
+ The profiles of every file, concatenated in filename order, with
561
+ ``<prop> = <direction><quantity><species>`` and these columns added
562
+ to each file's own:
563
+
564
+ - ``<prop>-scaler``: the diameter :math:`a` for ``'Phi'`` and
565
+ :math:`a^2` for ``'Rho'``; ``<prop>-sumrule_constant`` repeats
566
+ it.
567
+ - ``<prop>-scale``: ``<prop>-mean`` divided by :math:`a` for
568
+ ``'Phi'`` and times :math:`a^2` for ``'Rho'``.
569
+ - ``<prop>-scale-normalized_curve``: ``<prop>-scale`` divided by
570
+ its sum over the bins; for ``'z'``, folded by summing mirror
571
+ bins, so it is the distribution of :math:`|z|` and still sums
572
+ to one.
573
+ - ``<prop>-normalizer`` and ``<prop>-norm``: from
574
+ :func:`normalize_r` or, for ``'z'``, :func:`normalize_z` with
575
+ folding, so that a ``'z'`` profile has ``ceil(n / 2)`` rows.
576
+ - ``<prop>-norm-sem``, when the file has it and `normalizer` is
577
+ None: carried through from the file and, for ``'z'``, folded
578
+ like every other numeric column.
579
+ :mod:`polyphys.analyze.pipeline` writes it as the standard error
580
+ over the wholes of each whole's profile divided by its own value
581
+ in the bin the default normalizer names: the SEM across profiles
582
+ normalized separately for each whole. Normalization is
583
+ nonlinear, so it is not generally an uncertainty estimate for
584
+ ``<prop>-norm``, the normalized ensemble mean. With an explicit
585
+ `normalizer` the column no longer matches ``<prop>-norm``'s
586
+ reference bin, so it is dropped with a warning.
587
+ - ``bin_center-norm``: ``bin_center`` divided by its maximum.
588
+ - ``ensemble_long``, the file's state-point name; one column per
589
+ entry of `physical_attrs`; `crowder_attr`; and
590
+ ``phi_bulk_c_round``.
591
+ - For each crowder of `crowders` with size attribute ``c``:
592
+ ``bin_center-<c>``, which is :math:`2 b / a_c` for the bin center
593
+ :math:`b`, the bin center in crowder radii. For ``'r'`` in a
594
+ cylinder of diameter :math:`D` also
595
+ ``bin_center-<c>-recentered``, which is
596
+ :math:`2 b / a_c - (D \\bmod a_c) / a_c`, so that the wall sits at
597
+ an integer number of crowder diameters and the layers of
598
+ different state points overlay, and ``bin_center-recentered-norm``
599
+ (``bin_center-recentered-norm-<c>`` with several crowders), which
600
+ is :math:`b - (D \\bmod a_c)` divided by its maximum.
601
+
602
+ Raises
603
+ ------
604
+ ValueError
605
+ If `quantity` is not ``'Rho'`` or ``'Phi'``; if the parser's
606
+ geometry and `direction` have no entry in
607
+ :data:`PROFILE_NORMALIZERS`, such as ``'z'`` in a cube; if no file
608
+ matches; or if `normalizer` is none of the accepted forms.
609
+ KeyError
610
+ If `species` or a crowder is not a key of `species_sizes`.
611
+
612
+ Warns
613
+ -----
614
+ UserWarning
615
+ If a file's ``<prop>-mean`` sums to one within ``1e-6``, which marks
616
+ a profile normalized by its sum; the file is processed regardless,
617
+ since a raw profile can sum to one by coincidence. If a file has a
618
+ ``<prop>-norm-sem`` column and `normalizer` is not None; the column
619
+ is dropped. Also if a file's reference value is zero; its
620
+ ``<prop>-norm`` is then zero.
621
+
622
+ Examples
623
+ --------
624
+ One ensemble-averaged radial volume fraction of crowders of diameter
625
+ ``ac2.0`` in a cylinder, normalized by its axis value:
626
+
627
+ >>> import tempfile
628
+ >>> from pathlib import Path
629
+ >>> from polyphys.tests.synthetic import ChainCyl
630
+ >>> tmp = tempfile.TemporaryDirectory()
631
+ >>> database = Path(tmp.name) / 'analysis' / 'N20-all-ensAvg'
632
+ >>> database.mkdir(parents=True)
633
+ >>> state = 'N20r5.5lz25.0ac2.0nc100dt0.005bdump1000adump5000'
634
+ >>> pd.DataFrame({'bin_center': [0.5, 1.5, 2.5],
635
+ ... 'rPhiCrd-mean': [0.2, 0.3, 0.1]}).to_csv(
636
+ ... database / f'{state}-all-rPhiCrd-ensAvg.csv', index=False)
637
+ >>> space = space_profiles(str(database) + '/', 'Phi', 'r', 'Crd',
638
+ ... ChainCyl, 'N*', ['dcyl'], 'all',
639
+ ... {'Mon': 'dmon', 'Crd': 'dcrowd'})
640
+ >>> space[['bin_center', 'rPhiCrd-scale', 'rPhiCrd-norm',
641
+ ... 'bin_center-dcrowd-recentered']]
642
+ bin_center rPhiCrd-scale rPhiCrd-norm bin_center-dcrowd-recentered
643
+ 0 0.5 0.10 1.0 0.5
644
+ 1 1.5 0.15 1.5 1.5
645
+ 2 2.5 0.05 0.5 2.5
646
+ >>> space['dcyl'].tolist()
647
+ [10.0, 10.0, 10.0]
648
+ >>> tmp.cleanup()
649
+
650
+ Notes
651
+ -----
652
+ - Files are selected by globbing ``input_database + hierarchy +
653
+ '-<group>-<prop>-ensAvg.csv'`` and sorted alphanumerically by name.
654
+ - Let n_files be the number of matched files, n_bins the number of rows
655
+ of each, and n_cols the number of columns of the result. Time is
656
+ :math:`O(n_\\text{files} \\, n_\\text{bins} \\, n_\\text{cols} +
657
+ n_\\text{files} \\log n_\\text{files})`; one file is in memory at a
658
+ time while the finished frames accumulate, so peak memory is about
659
+ twice the result, :math:`O(n_\\text{files} \\, n_\\text{bins} \\,
660
+ n_\\text{cols})`.
661
+ - The default normalizers follow the research code: the value at
662
+ :math:`r = 0` in a cylinder, where the outermost bins are the crowder
663
+ layers on the wall and no far field exists, and the maximum
664
+ otherwise. The sum rule is a local relation and holds in the
665
+ moderate-compaction regime to about 15 percent (Jeon et al., 2016,
666
+ Section 3.1); under cylindrical confinement its radial form holds
667
+ only where the wall layering is weak, which the ratio
668
+ :math:`\\kappa = (D - a_c) / a_c` controls (Sadeghi Isfahani, 2024,
669
+ Chapter 3).
670
+ - ``<prop>-norm`` is the normalized ensemble average, the published
671
+ estimator (Jeon et al., 2016, p. 9788: a time average within each
672
+ run, then over runs, then normalized by the far-field value), not the
673
+ average of per-whole normalized profiles. The file's
674
+ ``<prop>-norm-sem`` is the SEM across profiles normalized separately
675
+ for each whole at the same bin. It is not generally an uncertainty
676
+ estimate for the normalized ensemble mean.
677
+
678
+ References
679
+ ----------
680
+ - C. Jeon, C. Hyeon, Y. Jung, and B.-Y. Ha, "How are molecular crowding
681
+ and the spatial organization of a biopolymer interrelated", Soft
682
+ Matter 12, 9786-9796 (2016). https://doi.org/10.1039/c6sm01924b
683
+ - J. Kim, C. Jeon, H. Jeong, Y. Jung, and B.-Y. Ha, "A polymer in a
684
+ crowded and confined space: effects of crowder size and
685
+ poly-dispersity", Soft Matter 11, 1877-1888 (2015).
686
+ https://doi.org/10.1039/c4sm02198c
687
+ - A. H. Sadeghi Isfahani, PhD thesis, University of Waterloo (2024),
688
+ Chapter 3.
689
+
690
+ .. versionadded:: 0.6.0
691
+ .. versionchanged:: 0.7.0
692
+ Carry ``<prop>-norm-sem`` through, and warn on a profile that sums
693
+ to one.
694
+ """
695
+ invalid_keyword(quantity, ["Rho", "Phi"])
696
+ if species not in species_sizes:
697
+ raise KeyError(
698
+ f"Species {species!r} is not in 'species_sizes' "
699
+ f"{sorted(species_sizes)}."
700
+ )
701
+ missing = [label for label in crowders if label not in species_sizes]
702
+ if missing:
703
+ raise KeyError(
704
+ f"Crowders {missing} are not in 'species_sizes' "
705
+ f"{sorted(species_sizes)}."
706
+ )
707
+ geometry = parser._geometry
708
+ try:
709
+ default = PROFILE_NORMALIZERS[(geometry, direction)]
710
+ except KeyError:
711
+ raise ValueError(
712
+ f"No density profile along {direction!r} in a {geometry} "
713
+ f"geometry; the (geometry, direction) pairs are "
714
+ f"{sorted(PROFILE_NORMALIZERS)}."
715
+ ) from None
716
+ if normalizer is not None:
717
+ logger.info(
718
+ "Normalizing %s%s%s profiles with %r instead of the %s %s "
719
+ "default %r.", direction, quantity, species, normalizer, geometry,
720
+ direction, default,
721
+ )
722
+
723
+ prop = f"{direction}{quantity}{species}"
724
+ prop_ext = f"-{group}-{prop}-ensAvg.csv"
725
+ ens_avg_csvs = sort_filenames(
726
+ glob(input_database + hierarchy + prop_ext), ["csv"]
727
+ )
728
+ if not ens_avg_csvs:
729
+ raise ValueError(
730
+ f"No '{hierarchy}{prop_ext}' file in '{input_database}'."
731
+ )
732
+ size_attr = species_sizes[species]
733
+ prop_csvs = []
734
+
735
+ for ens_avg_csv in ens_avg_csvs:
736
+ ens_avg = pd.read_csv(ens_avg_csv[0], header=0)
737
+ prop_info = parser(ens_avg_csv[0], "ensemble_long", group, ispath=True)
738
+
739
+ # A profile normalized by its sum adds up to one; the sum rule needs
740
+ # the raw profile, so warn, but go on: a raw one can sum to one too.
741
+ if abs(ens_avg[f"{prop}-mean"].sum() - 1.0) <= 1e-6:
742
+ warnings.warn(
743
+ f"The '{prop}-mean' column of '{ens_avg_csv[0]}' sums to one"
744
+ " within 1e-6, as a profile normalized by its sum does; the"
745
+ " sum rule needs the raw ensemble-averaged profile.",
746
+ UserWarning,
747
+ )
748
+
749
+ # The pipeline's per-whole error belongs to the default normalizer;
750
+ # with another, it would not describe <prop>-norm.
751
+ norm_sem = f"{prop}-norm-sem"
752
+ if norm_sem in ens_avg.columns and normalizer is not None:
753
+ warnings.warn(
754
+ f"Dropping '{norm_sem}' of '{ens_avg_csv[0]}': it is the"
755
+ f" error under the default normalizer {default!r}, not under"
756
+ f" {normalizer!r}.",
757
+ UserWarning,
758
+ )
759
+ ens_avg = ens_avg.drop(columns=norm_sem)
760
+
761
+ # One term of the sum rule: phi / a, or rho * a**2
762
+ size = getattr(prop_info, size_attr)
763
+ if quantity == "Phi":
764
+ scaler = size
765
+ ens_avg[f"{prop}-scale"] = ens_avg[f"{prop}-mean"] / scaler
766
+ else:
767
+ scaler = size ** 2
768
+ ens_avg[f"{prop}-scale"] = ens_avg[f"{prop}-mean"] * scaler
769
+ ens_avg[f"{prop}-scaler"] = scaler
770
+ ens_avg[f"{prop}-scale-normalized_curve"] = (
771
+ ens_avg[f"{prop}-scale"] / ens_avg[f"{prop}-scale"].sum()
772
+ )
773
+
774
+ # Normalize by the reference value; fold a z profile about z = 0
775
+ file_normalizer: NormalizerT
776
+ if normalizer is None:
777
+ file_normalizer = default
778
+ elif callable(normalizer):
779
+ file_normalizer = _with_parser(normalizer, prop_info)
780
+ else:
781
+ file_normalizer = normalizer
782
+ if direction == "z":
783
+ ens_avg = normalize_z(prop, ens_avg, file_normalizer)
784
+ else:
785
+ ens_avg = normalize_r(prop, ens_avg, file_normalizer)
786
+ ens_avg[f"{prop}-sumrule_constant"] = scaler
787
+ ens_avg["bin_center-norm"] = (
788
+ ens_avg["bin_center"] / ens_avg["bin_center"].max()
789
+ )
790
+
791
+ # Add the state point's name and physical attributes
792
+ ens_avg["ensemble_long"] = getattr(prop_info, "ensemble_long")
793
+ for attr_name in physical_attrs:
794
+ ens_avg[attr_name] = getattr(prop_info, attr_name)
795
+ ens_avg[crowder_attr] = getattr(prop_info, crowder_attr)
796
+ ens_avg["phi_bulk_c_round"] = ens_avg[crowder_attr].apply(
797
+ lambda x: round_to_multiple(x, divisor, round_to)
798
+ )
799
+
800
+ # Bin centers in crowder units, recentered on the wall in a cylinder
801
+ for label in crowders:
802
+ crowder_size_attr = species_sizes[label]
803
+ crowder_size = getattr(prop_info, crowder_size_attr)
804
+ ens_avg[f"bin_center-{crowder_size_attr}"] = (
805
+ 2 * ens_avg["bin_center"] / crowder_size
806
+ )
807
+ if geometry == "cylindrical" and direction == "r":
808
+ leftover = getattr(prop_info, "dcyl") % crowder_size
809
+ ens_avg[f"bin_center-{crowder_size_attr}-recentered"] = (
810
+ ens_avg[f"bin_center-{crowder_size_attr}"]
811
+ - leftover / crowder_size
812
+ )
813
+ recentered = ens_avg["bin_center"] - leftover
814
+ suffix = f"-{crowder_size_attr}" if len(crowders) > 1 else ""
815
+ ens_avg[f"bin_center-recentered-norm{suffix}"] = (
816
+ recentered / recentered.max()
817
+ )
818
+
819
+ prop_csvs.append(ens_avg)
820
+
821
+ prop_db = pd.concat(prop_csvs, axis=0)
822
+ prop_db = prop_db.reset_index(drop=True)
823
+
824
+ if is_save:
825
+ save_to_space = make_database(
826
+ input_database, "analysis", stage="space", group=group
827
+ )
828
+ space = save_to_space.split("/")[-2].split("-")[0]
829
+ filepath = (
830
+ save_to_space
831
+ + f"{create_fullname(space, group, prop)}"
832
+ + "-normalizedRescaled-space.csv"
833
+ )
834
+ prop_db.to_csv(filepath, index=False)
835
+ logger.info("Saved %s.", filepath)
836
+
837
+ return prop_db
838
+
839
+
840
+ def space_profiles_from_spec(
841
+ input_database: Pathish,
842
+ spec: ProjectSpec,
843
+ quantity: Literal["Rho", "Phi"],
844
+ direction: DirectionT,
845
+ species: EntityT,
846
+ *,
847
+ group: GroupT = "all",
848
+ crowders: Sequence[EntityT] = ("Crd",),
849
+ normalizer: (
850
+ Literal["first", "last", "max"] | float
851
+ | Callable[[pd.DataFrame, Any], float] | None
852
+ ) = None,
853
+ round_to: int = 3,
854
+ save: bool = False,
855
+ ) -> pd.DataFrame:
856
+ """
857
+ Scale and normalize the ensemble-averaged density profiles of one
858
+ species across a space for the density sum rule, with the columns, the
859
+ species sizes, and the rounding step the study's spec declares.
860
+
861
+ The spec supplies every study choice of :func:`space_profiles`: the
862
+ parser, the ``ensemble_long`` glob prefix (`spec.hierarchy`), the
863
+ attributes that become columns (`spec.physical_attrs`), the parser
864
+ attribute holding the particle diameter :math:`a_i` of each species
865
+ (`spec.species_sizes`), the rounding step :math:`\\Delta` of the bulk
866
+ volume fraction of crowders :math:`\\phi_c` (`spec.divisor`), and the
867
+ parser attribute that holds :math:`\\phi_c` (`spec.crowder_attr`). No
868
+ study default is taken from the library.
869
+
870
+ Parameters
871
+ ----------
872
+ input_database : path-like
873
+ The ``ensAvg`` directory of the space, which holds the files
874
+ ``<ensemble_long>-<group>-<direction><quantity><species>-ensAvg.csv``
875
+ of the raw ensemble-averaged profiles, as :func:`space_profiles`
876
+ requires.
877
+ spec : ~polyphys.spec.ProjectSpec
878
+ The study's spec.
879
+ quantity : {'Rho', 'Phi'}
880
+ The number density or the volume fraction.
881
+ direction : {'r', 'z'}
882
+ The coordinate of the profile; ``'z'`` exists in a cylinder only.
883
+ species : EntityT
884
+ The species label of the profile, such as ``'Mon'`` or ``'Crd'``; a
885
+ key of `spec.species_sizes`.
886
+ group : GroupT, default 'all'
887
+ The particle group of the profiles. A density profile covers every
888
+ particle of its species, so it is in the ``'all'`` group, not the
889
+ polymer group `spec.group`.
890
+ crowders : sequence of EntityT, default ('Crd',)
891
+ The crowder species, each a key of `spec.species_sizes`, as in
892
+ :func:`space_profiles`.
893
+ normalizer : NormalizerT or callable or None, default None
894
+ The reference value of the normalization, as in
895
+ :func:`space_profiles`; None takes the default of
896
+ :data:`PROFILE_NORMALIZERS` for the spec's geometry and `direction`.
897
+ round_to : int, default 3
898
+ The number of decimal places of the rounded :math:`\\phi_c`.
899
+ save : bool, default False
900
+ Also write the table to
901
+ ``<space>-<group>-<direction><quantity><species>-normalizedRescaled-space.csv``
902
+ in the ``analysis`` phase's ``space`` directory next to
903
+ `input_database`.
904
+
905
+ Returns
906
+ -------
907
+ pd.DataFrame
908
+ The table of :func:`space_profiles`, with a `spec.crowder_attr`
909
+ column and ``phi_bulk_c_round``, that attribute rounded to a
910
+ multiple of `spec.divisor`.
911
+
912
+ Raises
913
+ ------
914
+ ValueError, KeyError
915
+ As :func:`space_profiles` raises them.
916
+
917
+ Warns
918
+ -----
919
+ UserWarning
920
+ As :func:`space_profiles` warns.
921
+
922
+ Notes
923
+ -----
924
+ The cost is that of :func:`space_profiles`:
925
+ :math:`O(n_\\text{files} \\, n_\\text{bins} \\, n_\\text{cols} +
926
+ n_\\text{files} \\log n_\\text{files})` time and
927
+ :math:`O(n_\\text{files} \\, n_\\text{bins} \\, n_\\text{cols})` memory
928
+ for n_files matched files of n_bins rows and n_cols columns of the
929
+ result.
930
+
931
+ Examples
932
+ --------
933
+ In a cylinder of diameter :math:`D = 10` and length :math:`Z = 50`
934
+ with :math:`n_c = 100` crowders of diameter :math:`a_c = 2`,
935
+ :math:`\\phi_c = 2 n_c a_c^3 / (3 Z (D - a_c)^2) = 1/6`, which the
936
+ spec's step of 0.1 rounds to 0.2:
937
+
938
+ >>> import tempfile
939
+ >>> from pathlib import Path
940
+ >>> from polyphys.tests.synthetic import ChainCyl
941
+ >>> from polyphys.spec import ProjectSpec
942
+ >>> spec = ProjectSpec(
943
+ ... parser=ChainCyl, group='bug', divisor=0.1,
944
+ ... crowder_attr='phi_bulk_c', physical_attrs=('dcyl',),
945
+ ... species_sizes={'Mon': 'dmon', 'Crd': 'dcrowd'},
946
+ ... tseries_properties=('gyrTMon',))
947
+ >>> tmp = tempfile.TemporaryDirectory()
948
+ >>> database = Path(tmp.name) / 'analysis' / 'N20-all-ensAvg'
949
+ >>> database.mkdir(parents=True)
950
+ >>> state = 'N20r5.5lz25.0ac2.0nc100dt0.005bdump1000adump5000'
951
+ >>> pd.DataFrame({'bin_center': [0.5, 1.5, 2.5],
952
+ ... 'rPhiCrd-mean': [0.2, 0.3, 0.1]}).to_csv(
953
+ ... database / f'{state}-all-rPhiCrd-ensAvg.csv', index=False)
954
+ >>> space = space_profiles_from_spec(database, spec, 'Phi', 'r', 'Crd')
955
+ >>> space[['bin_center', 'rPhiCrd-norm', 'dcyl', 'phi_bulk_c_round']]
956
+ bin_center rPhiCrd-norm dcyl phi_bulk_c_round
957
+ 0 0.5 1.0 10.0 0.2
958
+ 1 1.5 1.5 10.0 0.2
959
+ 2 2.5 0.5 10.0 0.2
960
+ >>> tmp.cleanup()
961
+
962
+ .. versionadded:: 0.9.0
963
+ """
964
+ return space_profiles(
965
+ os.path.join(os.fspath(input_database), ""), quantity, direction,
966
+ species, spec.parser, spec.hierarchy, list(spec.physical_attrs),
967
+ group, spec.species_sizes, crowders=crowders, normalizer=normalizer,
968
+ divisor=spec.divisor, round_to=round_to, is_save=save,
969
+ crowder_attr=spec.crowder_attr)
970
+
971
+
972
+ def sum_rule_profiles(
973
+ profiles: Mapping[EntityT, pd.DataFrame],
974
+ prop: str,
975
+ geometry: GeometryT,
976
+ direction: DirectionT,
977
+ *,
978
+ by: Sequence[str] = ("ensemble_long",),
979
+ normalizer: NormalizerT | None = None,
980
+ ) -> pd.DataFrame:
981
+ """
982
+ Sum the scaled density profiles of several species, state point by
983
+ state point, and normalize the sum by its own reference value: the
984
+ left side of the density sum rule.
985
+
986
+ With the species' scaled profiles :math:`\\phi_i(x) / a_i` from
987
+ :func:`space_profiles`, the sum :math:`\\sum_i \\phi_i(x) / a_i` is
988
+ constant in :math:`x` where the sum rule holds (Jeon et al., 2016,
989
+ eqn (5); eqn (7) for several crowders). Normalized by its own value at
990
+ the reference, the sum is one there and its departure from one
991
+ elsewhere measures the rule's failure.
992
+
993
+ The species' profiles must descend from the raw ensemble averages, as
994
+ :func:`space_profiles` requires: the sum weights each species by its
995
+ physical scale, and a species normalized on its own, by its sum, its
996
+ integral, or its maximum, enters with an arbitrary weight. Nothing here
997
+ can detect that, so it is the caller's contract.
998
+
999
+ Parameters
1000
+ ----------
1001
+ profiles : mapping of EntityT to pd.DataFrame
1002
+ Each species label mapped to its :func:`space_profiles` result for
1003
+ the same `prop`; each has the columns of `by`, ``bin_center``, and
1004
+ ``<prop><species>-scale``, in the same row order, with ``-scale``
1005
+ built from the raw ensemble-averaged profile.
1006
+ prop : str
1007
+ The direction and quantity, such as ``'rPhi'`` or ``'zRho'``.
1008
+ geometry : {'cubic', 'cylindrical'}
1009
+ The geometry of the study, which with `direction` chooses the
1010
+ default normalizer.
1011
+ direction : {'r', 'z'}
1012
+ The coordinate of the profiles.
1013
+ by : sequence of str, default ('ensemble_long',)
1014
+ The columns that identify a state point; the sum is normalized
1015
+ within each. An empty sequence treats the whole input as one.
1016
+ normalizer : :py:obj:`~.NormalizerT` or None, \
1017
+ default None
1018
+ The reference value of each state point's sum. None takes the
1019
+ default of :data:`SUM_NORMALIZERS`: the value at :math:`r = 0` for
1020
+ ``'r'`` in a cylinder, the far-field (last-bin) value otherwise,
1021
+ where the monomer term vanishes and the sum is the crowder term
1022
+ :math:`\\phi_c(\\infty) / a_c`. A function is called with the state
1023
+ point's frame of the columns below. An explicit value is logged at
1024
+ INFO with the default it replaces.
1025
+
1026
+ Returns
1027
+ -------
1028
+ pd.DataFrame
1029
+ One row per bin and state point, in the input order: the `by`
1030
+ columns, ``bin_center``, ``<prop>Sum-scale`` (the sum over species),
1031
+ ``<prop>Sum-normalizer`` (the reference value of the state point),
1032
+ and ``<prop>Sum-norm``. When every species carries
1033
+ ``<prop><species>-norm-sem``, also ``<prop>Sum-norm-sem``, the
1034
+ species' ``-norm-sem`` values :math:`s_i` added in quadrature,
1035
+ :math:`\\sqrt{\\sum_i s_i^2}`, which assumes that the species'
1036
+ errors are independent. Each :math:`s_i` is the SEM across profiles
1037
+ normalized separately for each whole, not generally an uncertainty
1038
+ estimate for a normalized ensemble mean, so neither is the sum. It
1039
+ refers to the sum of the separately normalized species,
1040
+ :math:`\\sum_i \\phi_i / (a_i N_i)` for each species' own normalizer
1041
+ :math:`N_i`, and to ``<prop>Sum-norm``, :math:`\\sum_i \\phi_i /
1042
+ a_i` over the sum's normalizer, only when every species shares one
1043
+ normalizer value.
1044
+
1045
+ Raises
1046
+ ------
1047
+ ValueError
1048
+ If `profiles` is empty; if the species disagree on the `by` columns
1049
+ or on ``bin_center`` in any row; if a row has NaN in a `by` column;
1050
+ if (`geometry`, `direction`) has no entry in
1051
+ :data:`SUM_NORMALIZERS`; or if `normalizer` is none of the accepted
1052
+ forms.
1053
+
1054
+ Warns
1055
+ -----
1056
+ UserWarning
1057
+ If a state point's reference value is zero; its ``<prop>Sum-norm``
1058
+ is then zero.
1059
+
1060
+ Examples
1061
+ --------
1062
+ Two species over three radial bins of one state point in a cube: the
1063
+ monomer term falls to zero where the crowder term reaches its far-field
1064
+ value, and the sum is one where the rule holds:
1065
+
1066
+ >>> keys = {'ensemble_long': ['s1'] * 3, 'bin_center': [0.5, 1.5, 2.5]}
1067
+ >>> monomers = pd.DataFrame({**keys, 'rPhiMon-scale': [0.8, 0.4, 0.0]})
1068
+ >>> crowders = pd.DataFrame({**keys, 'rPhiCrd-scale': [0.2, 0.6, 1.0]})
1069
+ >>> total = sum_rule_profiles({'Mon': monomers, 'Crd': crowders},
1070
+ ... 'rPhi', 'cubic', 'r')
1071
+ >>> total[['bin_center', 'rPhiSum-scale', 'rPhiSum-norm']]
1072
+ bin_center rPhiSum-scale rPhiSum-norm
1073
+ 0 0.5 1.0 1.0
1074
+ 1 1.5 1.0 1.0
1075
+ 2 2.5 1.0 1.0
1076
+ >>> total['rPhiSum-normalizer'].tolist()
1077
+ [1.0, 1.0, 1.0]
1078
+
1079
+ Notes
1080
+ -----
1081
+ Time and space are :math:`O(n_\\text{species} \\, n_\\text{rows})` for
1082
+ the sum, the checks, and the error, plus :math:`O(n_\\text{rows})` per
1083
+ state point for the normalization. See :func:`space_profiles` for the
1084
+ references. The species' errors are correlated within a whole by
1085
+ excluded volume, which the quadrature sum ignores; the per-bin
1086
+ estimator is an open question.
1087
+
1088
+ .. versionadded:: 0.6.0
1089
+ .. versionchanged:: 0.7.0
1090
+ Added ``<prop>Sum-norm-sem``.
1091
+ """
1092
+ if not profiles:
1093
+ raise ValueError("'profiles' is empty.")
1094
+ try:
1095
+ default = SUM_NORMALIZERS[(geometry, direction)]
1096
+ except KeyError:
1097
+ raise ValueError(
1098
+ f"No density profile along {direction!r} in a {geometry} "
1099
+ f"geometry; the (geometry, direction) pairs are "
1100
+ f"{sorted(SUM_NORMALIZERS)}."
1101
+ ) from None
1102
+ if normalizer is None:
1103
+ normalizer = default
1104
+ else:
1105
+ logger.info(
1106
+ "Normalizing the %s sum with %r instead of the %s %s default "
1107
+ "%r.", prop, normalizer, geometry, direction, default,
1108
+ )
1109
+ keys = [*by, "bin_center"]
1110
+ sum_prop = f"{prop}Sum"
1111
+
1112
+ # The key columns of the first species fix the row order; every other
1113
+ # species must match them row for row.
1114
+ labels = list(profiles)
1115
+ first = profiles[labels[0]]
1116
+ total = first[keys].reset_index(drop=True)
1117
+ total[f"{sum_prop}-scale"] = 0.0
1118
+ # The species' normalized errors add in quadrature when every species
1119
+ # has one; the column is left out otherwise.
1120
+ with_error = all(
1121
+ f"{prop}{label}-norm-sem" in profiles[label].columns
1122
+ for label in labels
1123
+ )
1124
+ if with_error:
1125
+ total[f"{sum_prop}-norm-sem"] = 0.0
1126
+ for label in labels:
1127
+ frame = profiles[label]
1128
+ if len(frame) != len(first) or not (
1129
+ frame[list(by)].to_numpy().tolist()
1130
+ == first[list(by)].to_numpy().tolist()
1131
+ and np.allclose(frame["bin_center"], first["bin_center"])
1132
+ ):
1133
+ raise ValueError(
1134
+ f"The {label!r} profiles do not match the {labels[0]!r} "
1135
+ f"profiles on {keys}."
1136
+ )
1137
+ total[f"{sum_prop}-scale"] += frame[f"{prop}{label}-scale"].to_numpy()
1138
+ if with_error:
1139
+ total[f"{sum_prop}-norm-sem"] += (
1140
+ frame[f"{prop}{label}-norm-sem"].to_numpy() ** 2
1141
+ )
1142
+ if with_error:
1143
+ total[f"{sum_prop}-norm-sem"] = np.sqrt(total[f"{sum_prop}-norm-sem"])
1144
+
1145
+ # Normalize each state point's sum by its own reference value; a row
1146
+ # with no state point would be dropped by the group-by, and the groups
1147
+ # are put back in the input row order.
1148
+ if by:
1149
+ missing = int(total[list(by)].isna().any(axis=1).sum())
1150
+ if missing:
1151
+ rows = "row has" if missing == 1 else "rows have"
1152
+ raise ValueError(
1153
+ f"{missing} {rows} no value in the 'by' columns "
1154
+ f"{list(by)}; the group-by would drop them.")
1155
+ groups = [group for _, group in total.groupby(list(by), sort=False)]
1156
+ else:
1157
+ groups = [total]
1158
+ normalized = [_normalize(sum_prop, group, normalizer) for group in groups]
1159
+ return pd.concat(normalized, axis=0).sort_index().reset_index(drop=True)