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,498 @@
1
+ """Tests for :mod:`polyphys.manage.utils`.
2
+
3
+ The string and filename expectations are worked out by hand from each
4
+ function's stated rule. The bulk-quantity expectations are closed forms: the
5
+ number density ``n / v_avail``, with ``v_avail = (l - d)^3`` in a cube and
6
+ ``pi (l - d) (D - d)^2 / 4`` in a cylinder (``l`` in place of ``l - d``
7
+ under periodic boundaries), and the volume fraction ``rho pi d^3 / 6``. They
8
+ are compared at ``rel=1e-12``, far above the float64 round-off of the few
9
+ operations behind them (about 1e-16).
10
+ """
11
+ import gzip
12
+ import re
13
+ from pathlib import PosixPath
14
+
15
+ import numpy as np
16
+ import pytest
17
+
18
+ from polyphys.manage.utils import (
19
+ read_camel_case,
20
+ to_float_if_possible,
21
+ split_alphanumeric,
22
+ normalize_exts,
23
+ sort_filenames,
24
+ openany,
25
+ openany_context,
26
+ round_down_first_non_zero,
27
+ round_to_multiple,
28
+ invalid_keyword,
29
+ number_density_cube,
30
+ volume_fraction_cube,
31
+ number_density_cylinder,
32
+ volume_fraction_cylinder
33
+ )
34
+
35
+
36
+ @pytest.fixture
37
+ def make_text_file(tmp_path):
38
+ """Return a factory that writes text to a file in ``tmp_path`` and
39
+ returns its path; a name ending in ``.gz`` is written gzipped."""
40
+ def _make(name, content):
41
+ path = tmp_path / name
42
+ opener = gzip.open if name.endswith(".gz") else open
43
+ with opener(path, "wt") as handle:
44
+ handle.write(content)
45
+ return str(path)
46
+ return _make
47
+
48
+
49
+ @pytest.mark.parametrize(
50
+ "string,expected",
51
+ [
52
+ pytest.param("camelCase", ["camel", "Case"], id="lower-camel"),
53
+ pytest.param(
54
+ "CamelCaseString", ["Camel", "Case", "String"], id="upper-camel"
55
+ ),
56
+ pytest.param("CAMELCase", ["CAMEL", "Case"], id="leading-acronym"),
57
+ pytest.param("CamelCASE", ["Camel", "CASE"], id="trailing-acronym"),
58
+ pytest.param("camel", ["camel"], id="one-lower-word"),
59
+ pytest.param("CAMEL", ["CAMEL"], id="one-acronym"),
60
+ ],
61
+ )
62
+ def test_read_camel_case_splits_at_case_changes(string, expected):
63
+ """A camel-case string splits into its words, and a run of capitals is
64
+ kept as one word."""
65
+ assert read_camel_case(string) == expected
66
+
67
+
68
+ @pytest.mark.parametrize(
69
+ "string,expected",
70
+ [
71
+ pytest.param("3.14", 3.14, id="decimal"),
72
+ pytest.param("42", 42.0, id="integer"),
73
+ pytest.param("abc", "abc", id="not-a-number"),
74
+ ],
75
+ )
76
+ def test_to_float_if_possible_converts_numbers_only(string, expected):
77
+ """A numeric string becomes a float and any other string comes back
78
+ unchanged."""
79
+ assert to_float_if_possible(string) == expected
80
+
81
+
82
+ @pytest.mark.parametrize(
83
+ "string,expected",
84
+ [
85
+ pytest.param(
86
+ "file20.5name10", ["file", 20.5, "name", 10], id="float-and-int"
87
+ ),
88
+ pytest.param("abc123def", ["abc", 123, "def"], id="int-inside"),
89
+ pytest.param("123.45file", [123.45, "file"], id="leading-float"),
90
+ ],
91
+ )
92
+ def test_split_alphanumeric_separates_numbers_from_letters(string, expected):
93
+ """Runs of digits become ints or floats and the letters between them stay
94
+ strings, in order."""
95
+ assert split_alphanumeric(string) == expected
96
+
97
+
98
+ @pytest.mark.parametrize(
99
+ "exts,expected",
100
+ [
101
+ pytest.param("npy", (".npy",), id="str-without-dot"),
102
+ pytest.param(".trj", (".trj",), id="str-with-dot"),
103
+ pytest.param(
104
+ ("trj", ".lammpstrj"), (".trj", ".lammpstrj"), id="tuple-mixed"
105
+ ),
106
+ ],
107
+ )
108
+ def test_normalize_exts_gives_dot_prefixed_tuple(exts, expected):
109
+ """An extension or a tuple of them becomes a tuple of extensions, each
110
+ with exactly one leading dot."""
111
+ assert normalize_exts(exts) == expected
112
+
113
+
114
+ @pytest.mark.parametrize(
115
+ "filenames,formats,expected",
116
+ [
117
+ pytest.param(
118
+ ["file2.trj", "file1.trj", "file1.data", "file2.data",
119
+ "file3.lammpstrj", "file3.data"],
120
+ ["data", ("trj", "lammpstrj")],
121
+ [
122
+ (PosixPath("file1.data"), PosixPath("file1.trj")),
123
+ (PosixPath("file2.data"), PosixPath("file2.trj")),
124
+ (PosixPath("file3.data"), PosixPath("file3.lammpstrj")),
125
+ ],
126
+ id="equivalent-extensions",
127
+ ),
128
+ pytest.param(
129
+ [], ["data", ("trj", "lammpstrj")], [], id="empty-input"
130
+ ),
131
+ pytest.param(
132
+ ["file1.data", "file2.data", "file3.data"],
133
+ ["data"],
134
+ [
135
+ (PosixPath("file1.data"),),
136
+ (PosixPath("file2.data"),),
137
+ (PosixPath("file3.data"),),
138
+ ],
139
+ id="one-format",
140
+ ),
141
+ pytest.param(
142
+ ["file1.data", "file1.trj", "file1.npy",
143
+ "file2.npy", "file2.trj", "file2.data"],
144
+ ["data", "trj", "npy"],
145
+ [
146
+ (PosixPath("file1.data"), PosixPath("file1.trj"),
147
+ PosixPath("file1.npy")),
148
+ (PosixPath("file2.data"), PosixPath("file2.trj"),
149
+ PosixPath("file2.npy")),
150
+ ],
151
+ id="slot-order-follows-formats",
152
+ ),
153
+ pytest.param(
154
+ ["sample1.0.tar.gz", "sample1.0.gz",
155
+ "sample2.0.tar.gz", "sample2.0.gz"],
156
+ ["tar.gz", "gz"],
157
+ [
158
+ (PosixPath("sample1.0.tar.gz"), PosixPath("sample1.0.gz")),
159
+ (PosixPath("sample2.0.tar.gz"), PosixPath("sample2.0.gz")),
160
+ ],
161
+ id="compound-extension-distinct",
162
+ ),
163
+ pytest.param(
164
+ ["a.data", "b.DATA"],
165
+ ["DATA"],
166
+ [(PosixPath("b.DATA"),)],
167
+ id="case-sensitive",
168
+ ),
169
+ ],
170
+ )
171
+ def test_sort_filenames_pairs_by_basename_in_natural_order(
172
+ filenames, formats, expected
173
+ ):
174
+ """Files sharing a basename are grouped one per format, in the order of
175
+ the formats, and the groups are sorted naturally by basename."""
176
+ assert sort_filenames(filenames, formats) == expected
177
+
178
+
179
+ def test_sort_filenames_drops_incomplete_basename_with_warning():
180
+ """A basename missing one of its formats is left out, with a warning that
181
+ names the missing counterpart."""
182
+ files = ["file2.trj", "file1.trj", "file1.data", "file3.lammpstrj",
183
+ "file3.data"]
184
+ expected_message = (
185
+ "'file2.trj' does not have matching 'file2.data' so it is ignored"
186
+ )
187
+ with pytest.warns(UserWarning, match=re.escape(expected_message)):
188
+ result = sort_filenames(files, ["data", ("trj", "lammpstrj")])
189
+ assert result == [
190
+ (PosixPath("file1.data"), PosixPath("file1.trj")),
191
+ (PosixPath("file3.data"), PosixPath("file3.lammpstrj")),
192
+ ]
193
+
194
+
195
+ def test_sort_filenames_missing_format_group_gives_empty_with_warning():
196
+ """When no file matches one format group, every basename is incomplete,
197
+ so the result is empty and each file draws a warning."""
198
+ files = ["file1.data", "file2.data", "file3.data"]
199
+ with pytest.warns(UserWarning) as record:
200
+ result = sort_filenames(files, ["data", ("trj", "lammpstrj")])
201
+ assert result == []
202
+ assert [str(warning.message) for warning in record] == [
203
+ "'file1.data' does not have matching 'file1.trj' so it is ignored",
204
+ "'file2.data' does not have matching 'file2.trj' so it is ignored",
205
+ "'file3.data' does not have matching 'file3.trj' so it is ignored",
206
+ ]
207
+
208
+
209
+ def test_sort_filenames_rejects_two_files_in_one_slot():
210
+ """Two equivalent extensions for one basename fill the same slot twice,
211
+ which is rejected."""
212
+ files = ["file1.trj", "file1.data", "file1.lammpstrj"]
213
+ with pytest.raises(
214
+ ValueError, match="Duplicate files for basename 'file1'"
215
+ ):
216
+ sort_filenames(files, ["data", ("trj", "lammpstrj")])
217
+
218
+
219
+ def test_sort_filenames_rejects_extension_in_two_format_groups():
220
+ """An extension listed in two format groups is ambiguous and
221
+ rejected."""
222
+ formats = ["data", ("trj", "data")]
223
+ files = ["file1.trj", "file1.data"]
224
+ expected = (
225
+ "Extension '.data' appears in multiple format groups: "
226
+ "('.data',) and ('.trj', '.data')"
227
+ )
228
+ with pytest.raises(ValueError, match=re.escape(expected)):
229
+ sort_filenames(files, formats)
230
+
231
+
232
+ def test_sort_filenames_keeps_names_that_differ_after_last_dot():
233
+ """Two names that differ only after their last dot, as every HnsCyl name
234
+ ending in ``epshc1.0nc<n>`` does, stay two groups instead of colliding
235
+ on the part before the dot, with the format the space collectors pass."""
236
+ files = ["N200epshc1.0nc500-nucleoid-rhoMon-ensAvg.csv",
237
+ "N200epshc1.0nc0-nucleoid-rhoMon-ensAvg.csv"]
238
+
239
+ result = sort_filenames(files, ["csv"])
240
+
241
+ assert result == [
242
+ (PosixPath("N200epshc1.0nc0-nucleoid-rhoMon-ensAvg.csv"),),
243
+ (PosixPath("N200epshc1.0nc500-nucleoid-rhoMon-ensAvg.csv"),),
244
+ ]
245
+
246
+
247
+ def test_sort_filenames_pairs_formats_by_full_name():
248
+ """A .data file pairs with the .lammpstrj file of the same full name, not
249
+ with one that shares only the part before the last dot."""
250
+ files = ["N200D10.0ac2.0nc500.lammpstrj", "N200D10.0ac2.0nc0.data",
251
+ "N200D10.0ac2.0nc500.data", "N200D10.0ac2.0nc0.lammpstrj"]
252
+
253
+ result = sort_filenames(files, ["data", "lammpstrj"])
254
+
255
+ assert result == [
256
+ (PosixPath("N200D10.0ac2.0nc0.data"),
257
+ PosixPath("N200D10.0ac2.0nc0.lammpstrj")),
258
+ (PosixPath("N200D10.0ac2.0nc500.data"),
259
+ PosixPath("N200D10.0ac2.0nc500.lammpstrj")),
260
+ ]
261
+
262
+
263
+ def test_sort_filenames_does_not_pair_different_full_names():
264
+ """A .data and a .lammpstrj file whose names differ after the last dot
265
+ are both incomplete: no pair, and a warning for each."""
266
+ files = ["N200D10.0ac2.0nc0.data", "N200D10.0ac2.0nc500.lammpstrj"]
267
+
268
+ with pytest.warns(UserWarning) as record:
269
+ result = sort_filenames(files, ["data", "lammpstrj"])
270
+
271
+ assert result == []
272
+ assert [str(warning.message) for warning in record] == [
273
+ "'N200D10.0ac2.0nc0.data' does not have matching "
274
+ "'N200D10.0ac2.0nc0.lammpstrj' so it is ignored",
275
+ "'N200D10.0ac2.0nc500.lammpstrj' does not have matching "
276
+ "'N200D10.0ac2.0nc500.data' so it is ignored",
277
+ ]
278
+
279
+
280
+ @pytest.mark.parametrize(
281
+ "name,content",
282
+ [
283
+ pytest.param("plain.txt", "test content", id="plain"),
284
+ pytest.param("packed.txt.gz", "gzipped content", id="gzipped"),
285
+ ],
286
+ )
287
+ def test_openany_reads_plain_and_gzipped_files(make_text_file, name, content):
288
+ """A plain file and a gzipped one both read back as their text."""
289
+ path = make_text_file(name, content)
290
+ with openany(path) as handle:
291
+ text = handle.read()
292
+ assert text == content
293
+
294
+
295
+ @pytest.mark.parametrize(
296
+ "name,content",
297
+ [
298
+ pytest.param("plain.txt", "test content", id="plain"),
299
+ pytest.param("packed.txt.gz", "gzipped content", id="gzipped"),
300
+ ],
301
+ )
302
+ def test_openany_context_reads_plain_and_gzipped_files(
303
+ make_text_file, name, content
304
+ ):
305
+ """Inside the context, a plain file and a gzipped one both read back as
306
+ their text."""
307
+ path = make_text_file(name, content)
308
+ with openany_context(path) as handle:
309
+ text = handle.read()
310
+ assert text == content
311
+
312
+
313
+ def test_openany_context_lets_body_error_propagate(make_text_file, capsys):
314
+ """An error raised in the with-block propagates unchanged, unprinted."""
315
+ path = make_text_file("plain.txt", "test content")
316
+ with pytest.raises(RuntimeError, match="raised in the body"):
317
+ with openany_context(path):
318
+ raise RuntimeError("raised in the body")
319
+ assert capsys.readouterr().out == ""
320
+
321
+
322
+ @pytest.mark.parametrize(
323
+ "num,expected",
324
+ [
325
+ pytest.param(1234, 1000, id="integer"),
326
+ pytest.param(0.0456, 0.04, id="fraction"),
327
+ pytest.param(0, 0, id="zero"),
328
+ ],
329
+ )
330
+ def test_round_down_first_non_zero_keeps_leading_digit(num, expected):
331
+ """A number rounds down to its leading digit times its power of ten, and
332
+ zero stays zero."""
333
+ result = round_down_first_non_zero(num)
334
+ # float64 round-off left after rounding; atol for the zero case:
335
+ assert result == pytest.approx(expected, rel=1e-12, abs=1e-12)
336
+
337
+
338
+ @pytest.mark.parametrize(
339
+ "dividend,divider,round_to,expected",
340
+ [
341
+ pytest.param(52, 10, 0, 50.0, id="integers-down"),
342
+ pytest.param(49.5, 0.1, 1, 49.5, id="already-a-multiple"),
343
+ pytest.param(7, 3, 1, 6.0, id="integers-down-to-6"),
344
+ pytest.param(0.0874, 0.025, 3, 0.075, id="phi-below-midpoint"),
345
+ pytest.param(0.0876, 0.025, 3, 0.1, id="phi-above-midpoint"),
346
+ pytest.param(0.1001, 0.025, 3, 0.1, id="phi-just-above-multiple"),
347
+ pytest.param(0.11, 0.025, 3, 0.1, id="label-below-value"),
348
+ ],
349
+ )
350
+ def test_round_to_multiple_rounds_to_nearest_multiple(
351
+ dividend, divider, round_to, expected
352
+ ):
353
+ """The result is the multiple of divider nearest to dividend, which can
354
+ lie below dividend."""
355
+ result = round_to_multiple(dividend, divider, round_to)
356
+ # float64 round-off left after rounding to round_to decimals:
357
+ assert result == pytest.approx(expected, rel=0, abs=1e-12)
358
+
359
+
360
+ @pytest.mark.parametrize(
361
+ "dividend,divider,round_to,expected",
362
+ [
363
+ pytest.param(2.5, 1.0, 0, 2.0, id="tie-down-to-even"),
364
+ pytest.param(3.5, 1.0, 0, 4.0, id="tie-up-to-even"),
365
+ pytest.param(0.0625, 0.025, 3, 0.05, id="phi-tie-to-even"),
366
+ ],
367
+ )
368
+ def test_round_to_multiple_breaks_ties_to_even(
369
+ dividend, divider, round_to, expected
370
+ ):
371
+ """A dividend exactly halfway between two multiples goes to the even
372
+ multiple, as Python's round does."""
373
+ result = round_to_multiple(dividend, divider, round_to)
374
+ # float64 round-off left after rounding to round_to decimals:
375
+ assert result == pytest.approx(expected, rel=0, abs=1e-12)
376
+
377
+
378
+ def test_invalid_keyword_accepts_valid_keyword():
379
+ """A keyword in the valid list passes without raising."""
380
+ assert invalid_keyword("opt1", ["opt1", "opt2"]) is None
381
+
382
+
383
+ def test_invalid_keyword_raises_with_custom_message():
384
+ """A keyword outside the valid list raises with the given message
385
+ appended to the quoted keyword."""
386
+ with pytest.raises(ValueError, match=re.escape("'invalid' is wrong.")):
387
+ invalid_keyword("invalid", ["opt1", "opt2"], message=" is wrong.")
388
+
389
+
390
+ @pytest.mark.parametrize(
391
+ "pbc,expected",
392
+ [
393
+ pytest.param(False, 1000 / 9**3, id="walls"),
394
+ pytest.param(True, 1000 / 10**3, id="periodic"),
395
+ ],
396
+ )
397
+ def test_number_density_cube_divides_by_available_volume(pbc, expected):
398
+ """1000 particles of diameter 1 in a cube of side 10 have the density
399
+ 1000 / 9^3 with walls and 1000 / 10^3 under periodic boundaries."""
400
+ result = number_density_cube(1000, 1.0, 10.0, pbc=pbc)
401
+ # float64 round-off of a cube and a division:
402
+ assert result == pytest.approx(expected, rel=1e-12)
403
+
404
+
405
+ @pytest.mark.parametrize(
406
+ "pbc,expected",
407
+ [
408
+ pytest.param(False, 1000 / 9**3 * np.pi / 6, id="walls"),
409
+ pytest.param(True, 1000 / 10**3 * np.pi / 6, id="periodic"),
410
+ ],
411
+ )
412
+ def test_volume_fraction_cube_is_density_times_bead_volume(pbc, expected):
413
+ """The volume fraction is the number density times pi d^3 / 6."""
414
+ result = volume_fraction_cube(1000, 1.0, 10.0, pbc=pbc)
415
+ # float64 round-off of a few products and divisions:
416
+ assert result == pytest.approx(expected, rel=1e-12)
417
+
418
+
419
+ def test_volume_fraction_cube_allows_nominal_phi_above_one():
420
+ """1000 unit beads in a cube of side 5 with walls give the nominal
421
+ fraction 1000 / 4^3 * pi / 6, above one, instead of raising."""
422
+ result = volume_fraction_cube(n_atom=1000, d_atom=1.0, l_cube=5.0)
423
+ # float64 round-off of a few products and divisions:
424
+ assert result == pytest.approx(1000 / 4**3 * np.pi / 6, rel=1e-12)
425
+
426
+
427
+ @pytest.mark.parametrize(
428
+ "d_atom,l_cube,pbc",
429
+ [
430
+ pytest.param(5.0, 5.0, False, id="as-wide-as-box"),
431
+ pytest.param(6.0, 5.0, False, id="wider-than-box"),
432
+ ],
433
+ )
434
+ def test_number_density_cube_rejects_box_without_room(d_atom, l_cube, pbc):
435
+ """A particle that leaves its center no room in a walled cube raises
436
+ ValueError instead of returning a meaningless density."""
437
+ with pytest.raises(ValueError, match="no room for particle centers"):
438
+ number_density_cube(10, d_atom, l_cube, pbc=pbc)
439
+
440
+
441
+ @pytest.mark.parametrize(
442
+ "pbc,expected",
443
+ [
444
+ pytest.param(False, 100 / (np.pi * 9 * 4**2 / 4), id="walls"),
445
+ pytest.param(True, 100 / (np.pi * 10 * 4**2 / 4), id="periodic"),
446
+ ],
447
+ )
448
+ def test_number_density_cylinder_divides_by_available_volume(pbc, expected):
449
+ """100 unit beads in a cylinder of length 10 and diameter 5 have the
450
+ density 100 / (pi 9 4^2 / 4) with end walls and 100 / (pi 10 4^2 / 4)
451
+ under periodic boundaries along the axis."""
452
+ result = number_density_cylinder(100, 1.0, 10.0, 5.0, pbc=pbc)
453
+ # float64 round-off of a few products and a division:
454
+ assert result == pytest.approx(expected, rel=1e-12)
455
+
456
+
457
+ @pytest.mark.parametrize(
458
+ "pbc,expected",
459
+ [
460
+ pytest.param(False, 100 / 216, id="walls"),
461
+ pytest.param(True, 100 / 240, id="periodic"),
462
+ ],
463
+ )
464
+ def test_volume_fraction_cylinder_is_density_times_bead_volume(pbc, expected):
465
+ """The volume fraction is the number density times pi d^3 / 6, which
466
+ cancels pi: 100 / (9 * 4^2 * 6 / 4) with end walls and
467
+ 100 / (10 * 4^2 * 6 / 4) under periodic boundaries."""
468
+ result = volume_fraction_cylinder(100, 1.0, 10.0, 5.0, pbc=pbc)
469
+ # float64 round-off of a few products and divisions:
470
+ assert result == pytest.approx(expected, rel=1e-12)
471
+
472
+
473
+ def test_volume_fraction_cylinder_allows_nominal_phi_above_one():
474
+ """500 unit beads in a cylinder of length 5 and diameter 3 with end walls
475
+ give the nominal fraction 500 / (4 * 2^2 * 6 / 4) = 500 / 24, above one,
476
+ instead of raising."""
477
+ result = volume_fraction_cylinder(
478
+ n_atom=500, d_atom=1.0, l_cyl=5.0, d_cyl=3.0)
479
+ # float64 round-off of a few products and divisions:
480
+ assert result == pytest.approx(500 / 24, rel=1e-12)
481
+
482
+
483
+ @pytest.mark.parametrize(
484
+ "d_atom,l_cyl,d_cyl,pbc",
485
+ [
486
+ pytest.param(5.0, 20.0, 5.0, True, id="as-wide-as-cylinder"),
487
+ pytest.param(6.0, 20.0, 5.0, True, id="wider-than-cylinder"),
488
+ pytest.param(5.0, 20.0, 5.0, False, id="as-wide-with-end-walls"),
489
+ pytest.param(1.0, 1.0, 5.0, False, id="as-long-as-cylinder"),
490
+ ],
491
+ )
492
+ def test_number_density_cylinder_rejects_cylinder_without_room(
493
+ d_atom, l_cyl, d_cyl, pbc):
494
+ """A particle that leaves its center no room across or along the
495
+ cylinder raises ValueError instead of dividing by zero or returning a
496
+ density from a squared negative diameter."""
497
+ with pytest.raises(ValueError, match="no room for particle centers"):
498
+ number_density_cylinder(10, d_atom, l_cyl, d_cyl, pbc=pbc)