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.
- polyphys/__init__.py +7 -0
- polyphys/__version__.py +22 -0
- polyphys/analyze/__init__.py +51 -0
- polyphys/analyze/clusters.py +1738 -0
- polyphys/analyze/correlations.py +995 -0
- polyphys/analyze/density.py +1138 -0
- polyphys/analyze/measurer.py +1338 -0
- polyphys/analyze/pipeline.py +1868 -0
- polyphys/analyze/stats.py +810 -0
- polyphys/analyze/sumrule.py +1159 -0
- polyphys/manage/__init__.py +11 -0
- polyphys/manage/organizer.py +2188 -0
- polyphys/manage/parser.py +1264 -0
- polyphys/manage/types.py +171 -0
- polyphys/manage/utils.py +777 -0
- polyphys/probe/__init__.py +25 -0
- polyphys/probe/binders.py +711 -0
- polyphys/probe/chain_size.py +583 -0
- polyphys/probe/core.py +437 -0
- polyphys/probe/histograms.py +863 -0
- polyphys/probe/pairs.py +555 -0
- polyphys/simulate/__init__.py +48 -0
- polyphys/simulate/build.py +1231 -0
- polyphys/simulate/core.py +237 -0
- polyphys/simulate/data.py +977 -0
- polyphys/simulate/dump.py +752 -0
- polyphys/simulate/log.py +788 -0
- polyphys/spec.py +1359 -0
- polyphys/tests/analyze/test_clusters.py +1058 -0
- polyphys/tests/analyze/test_correlations.py +708 -0
- polyphys/tests/analyze/test_density.py +813 -0
- polyphys/tests/analyze/test_measurer.py +785 -0
- polyphys/tests/analyze/test_pipeline.py +1881 -0
- polyphys/tests/analyze/test_stats.py +732 -0
- polyphys/tests/analyze/test_sumrule.py +917 -0
- polyphys/tests/conftest.py +753 -0
- polyphys/tests/data/lammps_build/make_starts.py +70 -0
- polyphys/tests/manage/test_organizer.py +1495 -0
- polyphys/tests/manage/test_parser.py +1222 -0
- polyphys/tests/manage/test_utils.py +498 -0
- polyphys/tests/probe/conftest.py +407 -0
- polyphys/tests/probe/test_binders.py +686 -0
- polyphys/tests/probe/test_chain_size.py +557 -0
- polyphys/tests/probe/test_core.py +391 -0
- polyphys/tests/probe/test_histograms.py +779 -0
- polyphys/tests/probe/test_pairs.py +475 -0
- polyphys/tests/simulate/test_build.py +1042 -0
- polyphys/tests/simulate/test_core.py +106 -0
- polyphys/tests/simulate/test_data.py +858 -0
- polyphys/tests/simulate/test_dump.py +487 -0
- polyphys/tests/simulate/test_log.py +667 -0
- polyphys/tests/strategies.py +120 -0
- polyphys/tests/synthetic.py +410 -0
- polyphys/tests/test_conftest.py +414 -0
- polyphys/tests/test_docs.py +82 -0
- polyphys/tests/test_packaging.py +97 -0
- polyphys/tests/test_spec.py +772 -0
- polyphys/tests/viz/conftest.py +32 -0
- polyphys/tests/viz/test_colors.py +113 -0
- polyphys/tests/viz/test_helpers.py +292 -0
- polyphys/viz/__init__.py +15 -0
- polyphys/viz/colors.py +204 -0
- polyphys/viz/helpers.py +549 -0
- polyphys-1.0.0.dist-info/METADATA +339 -0
- polyphys-1.0.0.dist-info/RECORD +69 -0
- polyphys-1.0.0.dist-info/WHEEL +5 -0
- polyphys-1.0.0.dist-info/licenses/AUTHORS.rst +13 -0
- polyphys-1.0.0.dist-info/licenses/LICENSE +22 -0
- polyphys-1.0.0.dist-info/top_level.txt +1 -0
polyphys/manage/utils.py
ADDED
|
@@ -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
|