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,777 @@
1
+ """\
2
+ ===============================================================
3
+ Miscellaneous utility codes --- :mod:`polyphys.manage.utils`
4
+ ===============================================================
5
+
6
+ The :mod:`~polyphys.manage.utils` module provides shared utility functions
7
+ used across various parts of the PolyPhys package. These functions handle
8
+ string processing, file handling, numeric rounding, error checking, and
9
+ conversions between particle counts, number densities, and volume fractions.
10
+
11
+ Contents
12
+ ========
13
+
14
+ String parsing and filename sorting:
15
+ :func:`read_camel_case`, :func:`to_float_if_possible`,
16
+ :func:`split_alphanumeric`, :func:`normalize_exts`, and
17
+ :func:`sort_filenames`.
18
+
19
+ File handling:
20
+ :func:`openany` and :func:`openany_context`.
21
+
22
+ Numeric rounding and validation:
23
+ :func:`round_down_first_non_zero`, :func:`round_to_multiple`, and
24
+ :func:`invalid_keyword`.
25
+
26
+ Number-density and volume-fraction conversions:
27
+ :func:`number_density_cube`, :func:`volume_fraction_cube`,
28
+ :func:`number_density_cylinder`, and :func:`volume_fraction_cylinder`.
29
+
30
+ Dependencies
31
+ ============
32
+ - Python standard library: `re`, `gzip`, `contextlib`, `typing`
33
+ - Third-party: `numpy`
34
+
35
+ See also: :mod:`~polyphys.manage.types` for the definition of
36
+ :py:obj:`~polyphys.manage.types.InputType`.
37
+ """
38
+
39
+ import re
40
+ from pathlib import Path
41
+ import gzip
42
+ from typing import Generator, Optional, List, Union, Tuple, Sequence, cast
43
+ import warnings
44
+ from contextlib import contextmanager
45
+ import numpy as np
46
+ from .types import InputType, PathIter
47
+
48
+ __all__ = [
49
+ 'invalid_keyword',
50
+ 'normalize_exts',
51
+ 'number_density_cube',
52
+ 'number_density_cylinder',
53
+ 'openany',
54
+ 'openany_context',
55
+ 'read_camel_case',
56
+ 'round_down_first_non_zero',
57
+ 'round_to_multiple',
58
+ 'sort_filenames',
59
+ 'split_alphanumeric',
60
+ 'to_float_if_possible',
61
+ 'volume_fraction_cube',
62
+ 'volume_fraction_cylinder',
63
+ ]
64
+
65
+
66
+ def read_camel_case(string: str) -> List[Union[str, Tuple[str]]]:
67
+ """
68
+ Split a *camelCase* or *CamelCase* `string` into its component substrings.
69
+
70
+ Parameters
71
+ ----------
72
+ string : str
73
+ The *camelCase* or *CamelCase* `string` to be split.
74
+
75
+ Returns
76
+ -------
77
+ List[str]
78
+ The split `string` with substrings as elements.
79
+
80
+ Examples
81
+ --------
82
+ >>> read_camel_case("camelCase")
83
+ ['camel', 'Case']
84
+ >>> read_camel_case("CamelCaseString")
85
+ ['Camel', 'Case', 'String']
86
+ """
87
+ return re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z]|$)", string)
88
+
89
+
90
+ def to_float_if_possible(string: str) -> Union[float, str]:
91
+ """
92
+ Convert `string` to a float, or return it unchanged if it is not a
93
+ number.
94
+
95
+ Parameters
96
+ ----------
97
+ string : str
98
+ The `string` to attempt to convert to a float.
99
+
100
+ Returns
101
+ -------
102
+ Union[float, str]
103
+ The converted float if the input can be converted; otherwise,
104
+ the original `string`.
105
+
106
+ Examples
107
+ --------
108
+ >>> to_float_if_possible("3.14")
109
+ 3.14
110
+ >>> to_float_if_possible("not_a_float")
111
+ 'not_a_float'
112
+ >>> to_float_if_possible("42")
113
+ 42.0
114
+ """
115
+ try:
116
+ return float(string)
117
+ except ValueError:
118
+ return string
119
+
120
+
121
+ def split_alphanumeric(alphanumeric: str) -> List[Union[int, str, float]]:
122
+ """
123
+ Split an *alphanumeric* string into a list of strings, integers, and
124
+ floats.
125
+
126
+ This function identifies contiguous sections of digits, letters, and
127
+ decimal numbers, returning them as separate elements in a list, making the
128
+ string suitable for alphanumeric sorting.
129
+
130
+ Parameters
131
+ ----------
132
+ alphanumeric : str
133
+ An alphanumeric string to be split.
134
+
135
+ Returns
136
+ -------
137
+ List[Union[int, str, float]]
138
+ A list of components including integers, floats, and strings.
139
+
140
+ Examples
141
+ --------
142
+ >>> split_alphanumeric("file20.5name10")
143
+ ['file', 20.5, 'name', 10]
144
+ """
145
+ number_pattern = re.compile(r"(\d+\.*\d*)")
146
+ parts = number_pattern.split(alphanumeric)
147
+ return [
148
+ int(part) if part.isdigit() else to_float_if_possible(part)
149
+ for part in parts
150
+ if part
151
+ ]
152
+
153
+
154
+ def normalize_exts(exts: Union[str, Tuple[str, ...]]) -> Tuple[str, ...]:
155
+ """
156
+ Normalize a format specifier into a tuple of extensions.
157
+
158
+ Ensures each extension starts with a leading dot ('.').
159
+ A single extension (with or without a dot) is converted to a
160
+ 1-tuple; a tuple is normalized element-wise.
161
+
162
+ Parameters
163
+ ----------
164
+ exts : str or tuple of str
165
+ Extension specifier. Can be:
166
+ - A string representing a single extension (e.g., ``"npy"`` or
167
+ ``".npy"``).
168
+ - A tuple of equivalent extensions (e.g., ``("trj", "lammpstrj")``).
169
+
170
+ Returns
171
+ -------
172
+ tuple of str
173
+ Normalized tuple of extensions, all dot-prefixed.
174
+
175
+ Examples
176
+ --------
177
+ >>> normalize_exts("npy")
178
+ ('.npy',)
179
+ >>> normalize_exts(("trj", ".lammpstrj"))
180
+ ('.trj', '.lammpstrj')
181
+ """
182
+ if isinstance(exts, str):
183
+ exts = (exts,)
184
+ return tuple(e if e.startswith(".") else f".{e}" for e in exts)
185
+
186
+
187
+ def sort_filenames(
188
+ filenames: PathIter,
189
+ formats: Sequence[Union[str, Tuple[str, ...]]],
190
+ ) -> List[Tuple[Path, ...]]:
191
+ """
192
+ Group and alphanumerically sort `filenames` by basename across `formats`.
193
+
194
+ Files are paired by the same basename, the full file name with the
195
+ matched extension removed, such that each tuple contains exactly one
196
+ file for each format group. A format group may list multiple,
197
+ equivalent extensions, e.g. ``("trj", "lammpstrj")``. Only complete
198
+ basenames (with all slots present) are returned.
199
+
200
+ Parameters
201
+ ----------
202
+ filenames : sequence of path-like or str
203
+ Filenames/paths to sort and group.
204
+ formats : sequence of str or tuple of str
205
+ Each entry specifies one output slot. It can be a single extension
206
+ (e.g. ``"data"``) or a tuple of equivalent extensions
207
+ (e.g. ``("trj", "lammpstrj")``). Extensions are matched exactly
208
+ (except a leading dot is added if missing).
209
+
210
+ Returns
211
+ -------
212
+ list of tuple of Path
213
+ List of tuples, one per matched basename, ordered by natural
214
+ (alphanumeric) sort of the basename. Each tuple has length
215
+ ``len(formats)`` and contains `Path` objects in the same order
216
+ as `formats`.
217
+
218
+ Notes
219
+ -----
220
+ - **Case sensitivity:** extension matching is exact and case-sensitive.
221
+ For example, ``".data"`` ≠ ``".DATA"``. If you want case-insensitive
222
+ behavior, normalize your inputs beforehand.
223
+ - **Slot order preserved:** the files in each returned tuple **follow the
224
+ order of `formats`**. For example, with
225
+ ``formats = ['data', 'trj', 'npy']`` the tuple is
226
+ ``(file.data, file.trj, file.npy)``.
227
+ - **Compound extensions:** ``".tar.gz"`` is **different** from ``".gz"``.
228
+ They are only treated as equivalent if you explicitly group them in
229
+ the same slot, e.g. ``('tar.gz', 'gz')``.
230
+ - Only basenames that have a file in every slot are returned, so if
231
+ any format group matches no files, no basename is complete and the
232
+ returned list is empty.
233
+ - Incomplete basenames are **ignored** and a warning is emitted for each
234
+ present file mentioning its missing counterpart(s).
235
+ - Grouping is by **basename**: the full file name without the matched
236
+ extension, dots included, never the part before the last dot, so
237
+ ``...epshc1.0nc0`` and ``...epshc1.0nc500`` are two basenames.
238
+ Directory paths are not used.
239
+ - Let n be the number of files and k the number of extensions in
240
+ `formats`. Time is O(n k) to match the extensions plus
241
+ O(n log n) to sort the basenames; memory is O(n).
242
+
243
+ Raises
244
+ ------
245
+ ValueError
246
+ If an extension is assigned to multiple format groups, or if there
247
+ are duplicate files for the same basename and format slot. A file
248
+ whose name ends in extensions of two groups, such as ``.tar.gz`` and
249
+ ``.gz``, goes to the group of the longer one.
250
+
251
+ Examples
252
+ --------
253
+ >>> from pathlib import Path
254
+ >>> result = sort_filenames(
255
+ ... ['file1.data', 'file2.trj', 'file1.trj', 'file2.data'],
256
+ ... ['data', ('lammpstrj', 'trj')]
257
+ ... )
258
+ >>> result == [
259
+ ... (Path('file1.data'), Path('file1.trj')),
260
+ ... (Path('file2.data'), Path('file2.trj')),
261
+ ... ]
262
+ True
263
+ """
264
+ # Normalize inputs
265
+ paths = [Path(p) for p in filenames]
266
+ norm_formats: List[Tuple[str, ...]] = \
267
+ [normalize_exts(spec) for spec in formats]
268
+
269
+ # Build extension → slot map once; validate no overlaps across slots
270
+ ext_to_slot: dict[str, int] = {}
271
+ for i, exts in enumerate(norm_formats):
272
+ for e in exts:
273
+ prev = ext_to_slot.get(e)
274
+ if prev is not None and prev != i:
275
+ raise ValueError(
276
+ f"Extension '{e}' appears in multiple format groups: "
277
+ f"{norm_formats[prev]} and {norm_formats[i]}"
278
+ )
279
+ ext_to_slot[e] = i
280
+
281
+ # basename -> {slot_index -> Path}
282
+ by_base: dict[str, dict[int, Path]] = {}
283
+
284
+ for p in paths:
285
+ name = p.name # case-sensitive
286
+ # Collect all matching (ext, slot)
287
+ matches = [(e, s) for e, s in ext_to_slot.items() if name.endswith(e)]
288
+ if not matches:
289
+ continue
290
+ # Longest-suffix-wins disambiguation. Two matching extensions of
291
+ # equal length are the same string, and an extension sits in one
292
+ # slot only, so the longest match is unique.
293
+ win_ext, slot = max(matches, key=lambda match: len(match[0]))
294
+
295
+ # The basename is the full name without the winning extension; it
296
+ # keeps every dot of the name, such as the one in 'epshc1.0nc500'.
297
+ base = name[:-len(win_ext)]
298
+
299
+ slot_map = by_base.setdefault(base, {})
300
+ if slot in slot_map:
301
+ raise ValueError(
302
+ f"Duplicate files for basename '{base}' in slot {slot} "
303
+ f"{norm_formats[slot]}: '{slot_map[slot].name}' and '{p.name}'"
304
+ )
305
+ slot_map[slot] = p
306
+
307
+ # Identify complete basenames (all slots present)
308
+ nslots = len(norm_formats)
309
+ comp_bases = \
310
+ [base for base, slots in by_base.items() if len(slots) == nslots]
311
+
312
+ # Warn about incomplete basenames and ignore them
313
+ for base, slots in by_base.items():
314
+ if base in comp_bases:
315
+ continue
316
+ missing_slot_idxs = [i for i in range(nslots) if i not in slots]
317
+ for present_path in slots.values():
318
+ for miss_idx in missing_slot_idxs:
319
+ want_ext = norm_formats[miss_idx][0]
320
+ warnings.warn(
321
+ f"'{present_path.name}' does not have matching "
322
+ f"'{base}{want_ext}' so it is ignored",
323
+ UserWarning,
324
+ )
325
+
326
+ # Natural (alphanumeric) sort by basename
327
+ comp_bases.sort(key=split_alphanumeric)
328
+ # Build result tuples in the order of `formats`
329
+ result: List[Tuple[Path, ...]] = [
330
+ tuple(by_base[base][i] for i in range(nslots)) for base in comp_bases
331
+ ]
332
+ return result
333
+
334
+
335
+ def openany(filepath: str, mode: str = "rt") -> InputType:
336
+ """
337
+ Open a regular or gzipped file.
338
+
339
+ Parameters
340
+ ----------
341
+ filepath : str
342
+ Path to the file.
343
+ mode : str, default 'rt'
344
+ File-opening mode, passed to :func:`gzip.open` for a path ending in
345
+ ``.gz`` and to :func:`open` otherwise.
346
+
347
+ Returns
348
+ -------
349
+ :py:obj:`~.InputType`
350
+ The open file object, a :class:`gzip.GzipFile` or a regular file.
351
+ The caller must close it; :func:`openany_context` does that.
352
+
353
+ Examples
354
+ --------
355
+ >>> import gzip, os, tempfile
356
+ >>> with tempfile.TemporaryDirectory() as tmp:
357
+ ... path = os.path.join(tmp, 'log.txt.gz')
358
+ ... with gzip.open(path, 'wt') as out:
359
+ ... _ = out.write('step 0\\n')
360
+ ... handle = openany(path)
361
+ ... line = handle.readline()
362
+ ... handle.close()
363
+ >>> line
364
+ 'step 0\\n'
365
+ """
366
+ open_func = gzip.open if filepath.endswith(".gz") else open
367
+ return open_func(filepath, mode=mode)
368
+
369
+
370
+ @contextmanager
371
+ def openany_context(
372
+ filepath: str, mode: str = "rt"
373
+ ) -> Generator[InputType, None, None]:
374
+ """
375
+ Open a regular or gzipped file, providing a file-like object.
376
+
377
+ Parameters
378
+ ----------
379
+ filepath : str
380
+ Path to the file.
381
+ mode : str, default 'rt'
382
+ File-opening mode, passed to :func:`gzip.open` for a path ending in
383
+ ``.gz`` and to :func:`open` otherwise.
384
+
385
+ Yields
386
+ ------
387
+ :py:obj:`~.InputType`
388
+ The open file object, a :class:`gzip.GzipFile` or a regular file.
389
+ The context manager closes it on exit, also after an exception.
390
+
391
+ Examples
392
+ --------
393
+ >>> import os, tempfile
394
+ >>> with tempfile.TemporaryDirectory() as tmp:
395
+ ... path = os.path.join(tmp, 'log.txt')
396
+ ... with open(path, 'w') as out:
397
+ ... _ = out.write('step 0\\n')
398
+ ... with openany_context(path) as handle:
399
+ ... line = handle.readline()
400
+ >>> line, handle.closed
401
+ ('step 0\\n', True)
402
+ """
403
+ open_func = gzip.open if filepath.endswith(".gz") else open
404
+ file = open_func(filepath, mode=mode)
405
+ try:
406
+ yield file
407
+ finally:
408
+ file.close()
409
+
410
+
411
+ def round_down_first_non_zero(num: float) -> float:
412
+ """
413
+ Round down number `num` to its first non-zero digit.
414
+
415
+ Parameters
416
+ ----------
417
+ num : float
418
+ Number which is rounded.
419
+
420
+ Returns
421
+ -------
422
+ float
423
+ The number `num` rounded down to its first non-zero digit.
424
+
425
+ Examples
426
+ --------
427
+ >>> float(round_down_first_non_zero(0.0372))
428
+ 0.03
429
+ >>> float(round_down_first_non_zero(472.0))
430
+ 400.0
431
+ """
432
+ if num == 0:
433
+ return num
434
+ exponent = np.floor(np.log10(abs(num)))
435
+ non_zero = 10**exponent
436
+ # cast: numpy's stubs return Any. The value is a NumPy floating scalar
437
+ # (np.float32 for float32 input), not always a float; the cast only
438
+ # restates the existing annotation and changes nothing at runtime.
439
+ return cast(
440
+ float, round(np.floor(num / non_zero) * non_zero, int(abs(exponent)))
441
+ )
442
+
443
+
444
+ def round_to_multiple(dividend: float, divider: float, round_to: int) -> float:
445
+ """
446
+ Round `dividend` to the nearest multiple of `divider`, then to
447
+ `round_to` decimal places.
448
+
449
+ The result can lie below `dividend`. The ``phi_bulk_c_round`` labels that
450
+ :func:`polyphys.manage.organizer.space_tseries` and
451
+ :func:`polyphys.manage.organizer.space_hists` add are made with it.
452
+
453
+ Parameters
454
+ ----------
455
+ dividend : float
456
+ The number to round.
457
+ divider : float
458
+ The step of the grid of multiples, ``k * divider`` for integer ``k``.
459
+ round_to : int
460
+ The number of decimal places of the result; it removes the float
461
+ round-off of ``k * divider``.
462
+
463
+ Returns
464
+ -------
465
+ float
466
+ The multiple of `divider` closest to `dividend`, rounded to
467
+ `round_to` decimal places. When `dividend` lies exactly halfway
468
+ between two multiples, the even multiple is chosen, as Python's
469
+ built-in :func:`round` does.
470
+
471
+ Notes
472
+ -----
473
+ Ties are decided on the float64 quotient ``dividend / divider``, which is
474
+ deterministic: IEEE 754 division is correctly rounded on every platform.
475
+ A value that is halfway in decimal, such as 0.0375 with a `divider` of
476
+ 0.025, has a quotient (1.4999999999999998) that is not an exact tie, so it
477
+ rounds to the side the quotient falls on rather than to the even multiple.
478
+
479
+ References
480
+ ----------
481
+ Python documentation, built-in function :func:`round`:
482
+ https://docs.python.org/3/builtins/functions.html#round
483
+
484
+ Examples
485
+ --------
486
+ Crowder volume fractions on a grid of 0.025, rounded to 3 decimals:
487
+
488
+ >>> round_to_multiple(0.0874, 0.025, 3)
489
+ 0.075
490
+ >>> round_to_multiple(0.0876, 0.025, 3)
491
+ 0.1
492
+ >>> round_to_multiple(0.11, 0.025, 3)
493
+ 0.1
494
+
495
+ An exact tie goes to the even multiple:
496
+
497
+ >>> round_to_multiple(2.5, 1.0, 0), round_to_multiple(3.5, 1.0, 0)
498
+ (2.0, 4.0)
499
+ """
500
+ return round(round(dividend / divider) * divider, round_to)
501
+
502
+
503
+ def invalid_keyword(
504
+ keyword: str,
505
+ valid_keywords: Sequence[Optional[str]],
506
+ message: Optional[str] = None,
507
+ ) -> None:
508
+ """
509
+ Raise an error if `keyword` is not in `valid_keywords`.
510
+
511
+ Parameters
512
+ ----------
513
+ keyword : str
514
+ The keyword to check.
515
+ valid_keywords : sequence of str or None
516
+ The accepted keywords.
517
+ message : str, optional
518
+ The text that follows the quoted `keyword` in the
519
+ :exc:`ValueError` message; by default, a list of `valid_keywords`.
520
+
521
+ Raises
522
+ ------
523
+ ValueError
524
+ If `keyword` is not in `valid_keywords`.
525
+
526
+ Examples
527
+ --------
528
+ >>> invalid_keyword('ring', ['ring', 'cube'])
529
+ >>> invalid_keyword('loop', ['ring', 'cube'])
530
+ Traceback (most recent call last):
531
+ ...
532
+ ValueError: 'loop' is an invalid option. Please select one of: ring, cube.
533
+ """
534
+ if message is None:
535
+ message = (
536
+ " is an invalid option. Please select one of: "
537
+ + f"{', '.join(map(str, valid_keywords))}."
538
+ )
539
+ if keyword not in valid_keywords:
540
+ raise ValueError(f"'{keyword}'" + message)
541
+
542
+
543
+ def number_density_cube(
544
+ n_atom: float, d_atom: float, l_cube: float, pbc: bool = False
545
+ ) -> float:
546
+ """
547
+ Compute the bulk number density of a species in a cubic box.
548
+
549
+ Parameters
550
+ ----------
551
+ n_atom : float
552
+ Number of particles.
553
+ d_atom : float
554
+ Diameter of the particle of the species.
555
+ l_cube : float
556
+ Length of one side of the cubic box.
557
+ pbc : bool
558
+ Periodic boundary conditions along all box sides. If `True`,
559
+ :math:`v_{avail} = l_{cube}^3`; otherwise,
560
+ :math:`v_{avail} = (l_{cube} - d_{atom})^3`. Defaults to `False`.
561
+
562
+ Returns
563
+ -------
564
+ float
565
+ Bulk number density of the species in the cubic box.
566
+
567
+ Raises
568
+ ------
569
+ ValueError
570
+ If the side available to particle centers, :math:`l_{cube}`, or
571
+ :math:`l_{cube} - d_{atom}` without `pbc`, is not positive.
572
+
573
+ Notes
574
+ -----
575
+ The bulk number density is calculated as :math:`n_{atom} / v_{avail}`,
576
+ where `v_{avail}` is the available volume to the center of geometry of a
577
+ particle based on the presence of periodic boundary conditions.
578
+
579
+ Examples
580
+ --------
581
+ >>> number_density_cube(1000, 1.0, 10.0)
582
+ 1.3717421124828533
583
+ >>> number_density_cube(1000, 1.0, 10.0, pbc=True)
584
+ 1.0
585
+ """
586
+ l_avail = l_cube - int(not pbc) * d_atom
587
+ if l_avail <= 0:
588
+ raise ValueError(
589
+ f"no room for particle centers: a particle of diameter {d_atom} "
590
+ f"in a cube of side {l_cube} (pbc={pbc}) leaves its center the "
591
+ f"side {l_avail}, which must be positive.")
592
+ v_avail = l_avail ** 3
593
+ return n_atom / v_avail
594
+
595
+
596
+ def volume_fraction_cube(
597
+ n_atom: float, d_atom: float, l_cube: float, pbc: bool = False
598
+ ) -> float:
599
+ """
600
+ Compute the volume fraction of a species in a cubic box.
601
+
602
+ Parameters
603
+ ----------
604
+ n_atom : float
605
+ Number of particles.
606
+ d_atom : float
607
+ Diameter of the particle of the species.
608
+ l_cube : float
609
+ Length of one side of the cubic box.
610
+ pbc : bool
611
+ Periodic boundary conditions along all box sides. If `True`,
612
+ :math:`v_{avail} = l_{cube}^3`; otherwise,
613
+ :math:`v_{avail} = (l_{cube} - d_{atom})^3`. Defaults to `False`.
614
+
615
+ Returns
616
+ -------
617
+ float
618
+ Volume fraction of the species in the cubic box.
619
+
620
+ Raises
621
+ ------
622
+ ValueError
623
+ If the side available to particle centers is not positive, as
624
+ :func:`number_density_cube` raises.
625
+
626
+ Notes
627
+ -----
628
+ The available volume accounts for the excluded-volume boundary effect if
629
+ `pbc=False`, meaning particles cannot cross the box boundary. If
630
+ `pbc=True`, particles are assumed to wrap around, and the full box volume
631
+ is used.
632
+
633
+ The fraction is nominal: the particle volume over the volume available
634
+ to particle centers. It can exceed one, such as for a species nearly as
635
+ wide as the box, and is returned as it is.
636
+
637
+ Examples
638
+ --------
639
+ >>> volume_fraction_cube(1000, 1.0, 10.0)
640
+ 0.718242490532646
641
+
642
+ >>> volume_fraction_cube(1000, 1.0, 10.0, pbc=True)
643
+ 0.5235987755982988
644
+ """
645
+ rho = number_density_cube(n_atom, d_atom, l_cube, pbc)
646
+ return rho * np.pi * d_atom**3 / 6
647
+
648
+
649
+ def number_density_cylinder(
650
+ n_atom: float, d_atom: float, l_cyl: float, d_cyl: float, pbc: bool = False
651
+ ) -> float:
652
+ """
653
+ Compute the bulk number density of a species in a cylindrical confinement.
654
+
655
+ Parameters
656
+ ----------
657
+ n_atom : float
658
+ Number of particles.
659
+ d_atom : float
660
+ Diameter of the particle of the species.
661
+ l_cyl : float
662
+ Length of the cylindrical confinement.
663
+ d_cyl : float
664
+ Diameter of the cylindrical confinement.
665
+ pbc : bool
666
+ Periodic boundary conditions along the longitudinal axis. If `True`,
667
+ :math:`v_{avail} = \\pi * l_{cyl} * (d_{cyl} - d_{atom})^2 / 4`;
668
+ otherwise, :math:`v_{avail} = \\pi * (l_{cyl} - d_{atom}) * (d_{cyl}
669
+ - d_{atom})^2 / 4`. Defaults to `False`.
670
+
671
+ Returns
672
+ -------
673
+ float
674
+ Bulk number density of the species in the cylindrical confinement.
675
+
676
+ Raises
677
+ ------
678
+ ValueError
679
+ If the effective diameter :math:`d_{cyl} - d_{atom}`, or the length
680
+ available to particle centers, :math:`l_{cyl}`, or
681
+ :math:`l_{cyl} - d_{atom}` without `pbc`, is not positive.
682
+
683
+ Notes
684
+ -----
685
+ The bulk number density is calculated as :math:`n_{atom} / v_{avail}`,
686
+ where `v_{avail}` is the available volume to the center of geometry of a
687
+ particle based on the presence of periodic boundary conditions.
688
+
689
+ Examples
690
+ --------
691
+ >>> number_density_cylinder(100, 1.0, 10.0, 5.0)
692
+ 0.8841941282883075
693
+ >>> number_density_cylinder(100, 1.0, 10.0, 5.0, pbc=True)
694
+ 0.7957747154594768
695
+ """
696
+ l_avail = l_cyl - int(not pbc) * d_atom
697
+ d_avail = d_cyl - d_atom
698
+ if l_avail <= 0 or d_avail <= 0:
699
+ raise ValueError(
700
+ f"no room for particle centers: a particle of diameter {d_atom} "
701
+ f"in a cylinder of length {l_cyl} and diameter {d_cyl} "
702
+ f"(pbc={pbc}) leaves its center the length {l_avail} and the "
703
+ f"diameter {d_avail}, which must both be positive.")
704
+ v_avail = np.pi * l_avail * d_avail ** 2 / 4
705
+ return n_atom / v_avail
706
+
707
+
708
+ def volume_fraction_cylinder(
709
+ n_atom: float, d_atom: float, l_cyl: float, d_cyl: float, pbc: bool = False
710
+ ) -> float:
711
+ """
712
+ Compute the volume fraction of spherical particles in a cylindrical
713
+ confinement.
714
+
715
+ Parameters
716
+ ----------
717
+ n_atom : float
718
+ Number of particles.
719
+ d_atom : float
720
+ Diameter of a single particle.
721
+ l_cyl : float
722
+ Length of the cylindrical confinement (along the longitudinal axis).
723
+ d_cyl : float
724
+ Diameter of the cylindrical confinement (transverse direction).
725
+ pbc : bool, default False
726
+ Whether periodic boundary conditions are applied along the cylinder
727
+ axis. If `True`, the available volume is:
728
+
729
+ .. math::
730
+
731
+ V = \\frac{\\pi}{4} l_{cyl} (d_{cyl} - d_{atom})^2
732
+
733
+ If `False`, the available volume is:
734
+
735
+ .. math::
736
+
737
+ V = \\frac{\\pi}{4} (l_{cyl} - d_{atom}) (d_{cyl} - d_{atom})^2
738
+
739
+ Returns
740
+ -------
741
+ float
742
+ Volume fraction of the particles, defined as:
743
+
744
+ .. math::
745
+
746
+ \\phi = \\rho \\cdot \\frac{\\pi d^3}{6}
747
+
748
+ where :math:`\\rho` is the number density of particles in the
749
+ available cylindrical volume.
750
+
751
+ Raises
752
+ ------
753
+ ValueError
754
+ If the effective diameter or the available length is not positive,
755
+ as :func:`number_density_cylinder` raises.
756
+
757
+ Notes
758
+ -----
759
+ The available volume excludes regions where the particle centers cannot
760
+ access due to finite size. When `pbc=False`, particles are excluded from
761
+ the cylinder ends; when `pbc=True`, they are not.
762
+
763
+ The fraction is nominal: the particle volume over the volume available
764
+ to particle centers. It can exceed one, such as for large monomers in
765
+ single file in a cylinder barely wider than they are, and is returned as
766
+ it is.
767
+
768
+ Examples
769
+ --------
770
+ >>> volume_fraction_cylinder(100, 1.0, 10.0, 5.0)
771
+ 0.46296296296296297
772
+
773
+ >>> volume_fraction_cylinder(100, 1.0, 10.0, 5.0, pbc=True)
774
+ 0.4166666666666667
775
+ """
776
+ rho = number_density_cylinder(n_atom, d_atom, l_cyl, d_cyl, pbc)
777
+ return rho * np.pi * d_atom**3 / 6