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,1222 @@
1
+ """Tests for :mod:`polyphys.manage.parser`.
2
+
3
+ The parsers here are the synthetic ones of :mod:`polyphys.tests.synthetic`;
4
+ each published study tests its own parser in ``projects/<study>/``.
5
+ """
6
+ import importlib
7
+ import os
8
+ import re
9
+ from collections import OrderedDict
10
+ from operator import attrgetter
11
+ from pathlib import Path
12
+ from typing import Any, NamedTuple
13
+
14
+ import pytest
15
+ from hypothesis import given, settings
16
+ from hypothesis import strategies as st
17
+
18
+ from polyphys.manage.parser import KeywordSpec, ParserBase, Species
19
+ from polyphys.manage.utils import (
20
+ number_density_cube,
21
+ number_density_cylinder,
22
+ volume_fraction_cube,
23
+ volume_fraction_cylinder,
24
+ )
25
+ from polyphys.tests import synthetic
26
+ from polyphys.tests.strategies import lineage_names, stored_values
27
+ from polyphys.tests.synthetic import (
28
+ ChainCyl,
29
+ RingCub,
30
+ RingCubBinders,
31
+ RingCubTwoCrowders,
32
+ )
33
+
34
+
35
+ class ParseCase(NamedTuple):
36
+ """A hand-written artifact name and what its parser must give."""
37
+ parser_class: type[ParserBase]
38
+ lineage: Any
39
+ group: Any
40
+ artifact: str
41
+ name: str
42
+ values: dict[str, Any]
43
+ physical: list[str]
44
+
45
+
46
+ class ValidCase(NamedTuple):
47
+ """A valid ``ensemble_long`` artifact name for one parser class."""
48
+ parser_class: type[ParserBase]
49
+ artifact: str
50
+ lineage: Any
51
+ group: Any
52
+
53
+
54
+ @pytest.fixture(params=[
55
+ pytest.param(
56
+ ParseCase(
57
+ ChainCyl, 'segment', 'bug',
58
+ artifact='N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000'
59
+ 'adump10000ens2.j02.bug',
60
+ name='N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000'
61
+ 'ens2.j02',
62
+ values=dict(nmon=2000, dcyl=20, lcyl=600.0, dcrowd=1.0,
63
+ ncrowd=117000, dt=0.005, bdump=5000, adump=10000,
64
+ ensemble_id=2, segment_id=2),
65
+ physical=['dmon', 'phi_bulk_m', 'rho_bulk_m', 'phi_bulk_c',
66
+ 'rho_bulk_c']),
67
+ id='ChainCyl-segment'),
68
+ pytest.param(
69
+ ParseCase(
70
+ ChainCyl, 'whole', 'bug',
71
+ artifact='N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000'
72
+ 'adump10000ens2.bug',
73
+ name='N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000'
74
+ 'ens2',
75
+ values=dict(nmon=2000, dcyl=20, lcyl=600.0, dcrowd=1.0,
76
+ ncrowd=117000, dt=0.005, bdump=5000, adump=10000,
77
+ ensemble_id=2),
78
+ physical=['dmon', 'phi_bulk_m', 'rho_bulk_m', 'phi_bulk_c',
79
+ 'rho_bulk_c']),
80
+ id='ChainCyl-whole'),
81
+ pytest.param(
82
+ ParseCase(
83
+ ChainCyl, 'ensemble_long', 'bug',
84
+ artifact='N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000'
85
+ 'adump10000',
86
+ name='N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000',
87
+ values=dict(nmon=2000, dcyl=20, lcyl=600.0, dcrowd=1.0,
88
+ ncrowd=117000, dt=0.005, bdump=5000, adump=10000),
89
+ physical=['dmon', 'phi_bulk_m', 'rho_bulk_m', 'phi_bulk_c',
90
+ 'rho_bulk_c']),
91
+ id='ChainCyl-ensemble_long'),
92
+ pytest.param(
93
+ ParseCase(
94
+ ChainCyl, 'ensemble', 'bug',
95
+ artifact='N2000D20.0ac1.0nc117000',
96
+ name='N2000D20.0ac1.0nc117000',
97
+ values=dict(dmon=1.0, nmon=2000, dcrowd=1.0, ncrowd=117000,
98
+ dcyl=20),
99
+ physical=['dmon']),
100
+ id='ChainCyl-ensemble'),
101
+ pytest.param(
102
+ ParseCase(
103
+ ChainCyl, 'space', 'bug',
104
+ artifact='N2000D20.0ac1.0',
105
+ name='N2000D20.0ac1.0',
106
+ values=dict(dmon=1.0, nmon=2000, dcrowd=1.0, dcyl=20),
107
+ physical=['dmon']),
108
+ id='ChainCyl-space'),
109
+ pytest.param(
110
+ ParseCase(
111
+ RingCub, 'segment', 'all',
112
+ artifact='al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000'
113
+ 'ens4.j10.ring.all',
114
+ name='al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000ens4.'
115
+ 'j10.ring',
116
+ values=dict(dmon_large=5.0, nmon_large=5, mmon_large=125.0,
117
+ nmon_small=400, dcrowd=3.0, ncrowd=7735, lcube=90.0,
118
+ dt=0.005, bdump=2000, adump=5000, ensemble_id=4,
119
+ segment_id=10),
120
+ physical=['dmon_small', 'mmon_small', 'mcrowd', 'phi_bulk_m_small',
121
+ 'rho_bulk_m_small', 'phi_bulk_m_large',
122
+ 'rho_bulk_m_large', 'phi_bulk_m', 'rho_bulk_m',
123
+ 'phi_bulk_c', 'rho_bulk_c']),
124
+ id='RingCub-segment'),
125
+ pytest.param(
126
+ ParseCase(
127
+ RingCub, 'whole', 'all',
128
+ artifact='al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000'
129
+ 'ens4.ring.all',
130
+ name='al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000ens4.'
131
+ 'ring',
132
+ values=dict(dmon_large=5.0, nmon_large=5, mmon_large=125.0,
133
+ nmon_small=400, dcrowd=3.0, ncrowd=7735, lcube=90.0,
134
+ dt=0.005, bdump=2000, adump=5000, ensemble_id=4),
135
+ physical=['dmon_small', 'mmon_small', 'mcrowd', 'phi_bulk_m_small',
136
+ 'rho_bulk_m_small', 'phi_bulk_m_large',
137
+ 'rho_bulk_m_large', 'phi_bulk_m', 'rho_bulk_m',
138
+ 'phi_bulk_c', 'rho_bulk_c']),
139
+ id='RingCub-whole'),
140
+ pytest.param(
141
+ ParseCase(
142
+ RingCub, 'ensemble_long', 'all',
143
+ artifact='al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000',
144
+ name='al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000',
145
+ values=dict(dmon_large=5.0, nmon_large=5, mmon_large=125.0,
146
+ nmon_small=400, dcrowd=3.0, ncrowd=7735, lcube=90.0,
147
+ dt=0.005, bdump=2000, adump=5000),
148
+ physical=['dmon_small', 'mmon_small', 'mcrowd', 'phi_bulk_m_small',
149
+ 'rho_bulk_m_small', 'phi_bulk_m_large',
150
+ 'rho_bulk_m_large', 'phi_bulk_m', 'rho_bulk_m',
151
+ 'phi_bulk_c', 'rho_bulk_c']),
152
+ id='RingCub-ensemble_long'),
153
+ pytest.param(
154
+ ParseCase(
155
+ RingCub, 'ensemble', 'all',
156
+ artifact='ns400nl5al5ac3nc7735',
157
+ name='ns400nl5al5ac3nc7735',
158
+ values=dict(dmon_large=5.0, nmon_large=5, nmon_small=400,
159
+ dcrowd=3.0, ncrowd=7735),
160
+ physical=['dmon_small', 'mmon_small', 'mcrowd']),
161
+ id='RingCub-ensemble'),
162
+ pytest.param(
163
+ ParseCase(
164
+ RingCub, 'space', 'all',
165
+ artifact='ns400nl5al5ac3',
166
+ name='ns400nl5al5ac3',
167
+ values=dict(dmon_large=5.0, nmon_large=5, nmon_small=400,
168
+ dcrowd=3.0),
169
+ physical=['dmon_small', 'mmon_small', 'mcrowd']),
170
+ id='RingCub-space'),
171
+ ])
172
+ def parse_case(request: pytest.FixtureRequest) -> ParseCase:
173
+ """
174
+ One hand-written name per parser class and lineage, with the values its
175
+ parser must give: the digits in the name, converted as the class
176
+ docstring states (``lcube = 2 l``, ``lcyl = 2 lz``, ``dcyl = 2 r - 1``).
177
+ """
178
+ case: ParseCase = request.param
179
+ return case
180
+
181
+
182
+ @pytest.fixture
183
+ def parsed(parse_case: ParseCase) -> ParserBase:
184
+ """The parser built from the name of `parse_case`."""
185
+ return parse_case.parser_class(parse_case.artifact, parse_case.lineage,
186
+ parse_case.group)
187
+
188
+
189
+ @pytest.fixture(params=[
190
+ pytest.param(
191
+ ValidCase(ChainCyl,
192
+ 'N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000',
193
+ 'ensemble_long', 'bug'),
194
+ id='ChainCyl'),
195
+ pytest.param(
196
+ ValidCase(RingCub,
197
+ 'al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000',
198
+ 'ensemble_long', 'bug'),
199
+ id='RingCub'),
200
+ ])
201
+ def valid_case(request: pytest.FixtureRequest) -> ValidCase:
202
+ """A valid ``ensemble_long`` name for each parser class."""
203
+ case: ValidCase = request.param
204
+ return case
205
+
206
+
207
+ @pytest.fixture(params=[
208
+ pytest.param((parser_class, lineage),
209
+ id=f"{parser_class.__name__}-{lineage}")
210
+ for parser_class in (ChainCyl, RingCub, RingCubTwoCrowders,
211
+ RingCubBinders)
212
+ for lineage in ('segment', 'whole', 'ensemble_long', 'ensemble', 'space')
213
+ ])
214
+ def declared_case(
215
+ request: pytest.FixtureRequest
216
+ ) -> tuple[type[ParserBase], Any, str, dict[str, Any]]:
217
+ """
218
+ A name built from a class's declared pattern for one lineage, with 10.5
219
+ as the value of every float keyword and 2 as the value of every integer
220
+ keyword, and the values the parser must store for them. With these
221
+ values every volume fraction stays below 1 in both geometries.
222
+ """
223
+ parser_class, lineage = request.param
224
+ specs = [(keyword, parser_class._keywords[keyword])
225
+ for keyword in parser_class._patterns[lineage]]
226
+ name = ''.join(f"{keyword}{'10.5' if spec.kind is float else '2'}"
227
+ for keyword, spec in specs)
228
+ expected = {spec.attr: (spec.scale * 10.5 + spec.offset
229
+ if spec.kind is float else 2)
230
+ for _, spec in specs}
231
+ return parser_class, lineage, name, expected
232
+
233
+
234
+ @pytest.fixture
235
+ def chain_declaration() -> dict[str, Any]:
236
+ """The class attributes of a valid parser for a chain in a cube."""
237
+ return {
238
+ '_geometry': 'cubic',
239
+ '_topology': 'linear',
240
+ '_groups': ['bug', 'all'],
241
+ '_dump_keywords': {'bug': 'bdump', 'all': 'adump'},
242
+ '_keywords': {
243
+ 'N': KeywordSpec('nmon', int),
244
+ 'ac': KeywordSpec('dcrowd', float),
245
+ 'nc': KeywordSpec('ncrowd', int),
246
+ 'l': KeywordSpec('lcube', float, scale=2.0),
247
+ 'ens': KeywordSpec('ensemble_id', int),
248
+ 'j': KeywordSpec('segment_id', int),
249
+ },
250
+ '_patterns': {
251
+ 'segment': ('N', 'ac', 'nc', 'l', 'ens', 'j'),
252
+ 'whole': ('N', 'ac', 'nc', 'l', 'ens'),
253
+ 'ensemble_long': ('N', 'ac', 'nc', 'l'),
254
+ 'ensemble': ('N', 'ac', 'nc'),
255
+ 'space': ('N', 'ac'),
256
+ },
257
+ '_defaults': {'dmon': 1.0, 'bdump': 1000, 'adump': 5000},
258
+ '_species': (Species('m', 'nmon', 'dmon'),
259
+ Species('c', 'ncrowd', 'dcrowd')),
260
+ '_project_attributes': {
261
+ 'segment': ['dmon'],
262
+ 'whole': ['dmon'],
263
+ 'ensemble_long': ['dmon'],
264
+ 'ensemble': ['dmon'],
265
+ 'space': ['dmon'],
266
+ },
267
+ }
268
+
269
+
270
+ # Parsing hand-written names
271
+
272
+
273
+ def test_parser_sets_values_from_name(parse_case, parsed):
274
+ """Each lineage's name parses to its digits, converted as documented."""
275
+ actual = attrgetter(*parse_case.values)(parsed)
276
+ # Values are parsed from decimal text; equal to float64 round-off.
277
+ assert actual == pytest.approx(tuple(parse_case.values.values()),
278
+ rel=1e-12, abs=1e-12)
279
+
280
+
281
+ def test_parser_reduces_artifact_to_lineage_name(parse_case, parsed):
282
+ """The name drops the group and topology suffixes after the lineage."""
283
+ assert parsed.name == parse_case.name
284
+
285
+
286
+ def test_parser_lists_physical_attributes(parse_case, parsed):
287
+ """Each lineage lists the fixed and computed attributes it sets."""
288
+ assert parsed.physical_attributes == parse_case.physical
289
+
290
+
291
+ def test_parser_names_its_project(parse_case, parsed):
292
+ """The project name is the parser class's name."""
293
+ assert parsed.project_name == parse_case.parser_class.__name__
294
+
295
+
296
+ def test_parser_without_path_reports_no_path(parse_case, parsed):
297
+ """A parser built from a name keeps it and has no filepath or ext."""
298
+ assert (parsed.filename, parsed.filepath, parsed.ext, parsed.ispath,
299
+ parsed.lineage, parsed.group) == (
300
+ parse_case.artifact, None, None, False, parse_case.lineage,
301
+ parse_case.group)
302
+
303
+
304
+ def test_from_name_reports_no_path():
305
+ """A parser built with from_name has no filepath or ext."""
306
+ parser = ChainCyl.from_name('N200D10.0ac2.0', 'space', 'bug')
307
+
308
+ assert (parser.filepath, parser.ext, parser.ispath) == (None, None, False)
309
+
310
+
311
+ def test_parser_reads_name_from_file_path(parse_case, make_temp_file):
312
+ """A parser built from a file path parses the file's stem."""
313
+ full_path = make_temp_file(parse_case.artifact + '.txt')
314
+
315
+ parser = parse_case.parser_class(full_path, parse_case.lineage,
316
+ parse_case.group, ispath=True)
317
+
318
+ assert (parser.filename, parser.filepath, parser.ext, parser.ispath,
319
+ parser.name) == (
320
+ parse_case.artifact + '.txt', Path(os.path.dirname(full_path)),
321
+ '.txt', True, parse_case.name)
322
+
323
+
324
+ def test_parser_reports_empty_ext_for_file_without_suffix(make_temp_file):
325
+ """A file path with no suffix gives an empty ext, not None."""
326
+ full_path = make_temp_file('N200D10ac2')
327
+
328
+ parser = ChainCyl(full_path, 'space', 'bug', ispath=True)
329
+
330
+ assert (parser.ext, parser.filepath, parser.name) == (
331
+ '', Path(os.path.dirname(full_path)), 'N200D10ac2')
332
+
333
+
334
+ @pytest.mark.parametrize('filename, name, ext, dcrowd', [
335
+ pytest.param('N20D10ac1.5', 'N20D10ac1.5', '', 1.5, id='trailing-float'),
336
+ pytest.param('N20D10ac1.5.csv', 'N20D10ac1.5', '.csv', 1.5,
337
+ id='trailing-float-with-extension'),
338
+ pytest.param('N20D10ac1.', 'N20D10ac1.', '', 1.0, id='trailing-dot'),
339
+ ])
340
+ def test_parser_strips_only_an_extension_with_a_letter(
341
+ make_temp_file, filename, name, ext, dcrowd):
342
+ """With ispath, a suffix made of digits, or a lone dot, stays part of the
343
+ name instead of being dropped as an extension."""
344
+ full_path = make_temp_file(filename)
345
+
346
+ parser = ChainCyl(full_path, 'space', 'bug', ispath=True)
347
+
348
+ assert (parser.name, parser.ext, parser.dcrowd) == (name, ext, dcrowd)
349
+
350
+
351
+ @pytest.mark.parametrize('parser_class, lineage, group, artifact, expected', [
352
+ pytest.param(ChainCyl, 'ensemble', 'bug', 'N2000D20.0ac1.0nc117000',
353
+ ['nmon', 'dcyl', 'dcrowd', 'ncrowd'],
354
+ id='ChainCyl-ensemble'),
355
+ ])
356
+ def test_parser_lists_lineage_attributes_in_pattern_order(
357
+ parser_class, lineage, group, artifact, expected):
358
+ """The parsed attributes are listed in the order the name gives them."""
359
+ parser = parser_class(artifact, lineage, group)
360
+
361
+ assert parser.lineage_attributes == expected
362
+
363
+
364
+ def test_name_through_unknown_keyword_raises_value_error():
365
+ """Cutting a name at a keyword it lacks raises ValueError naming it."""
366
+ parser = ChainCyl('N2000D20.0ac1.0', 'space', 'bug')
367
+
368
+ with pytest.raises(ValueError,
369
+ match="'zz' keyword not found in 'N2000D20.0ac1.0'"):
370
+ parser._name_through('zz')
371
+
372
+
373
+ def test_parser_lists_each_attribute_once(parsed):
374
+ """No attribute appears twice in `attributes`."""
375
+ assert len(parsed.attributes) == len(set(parsed.attributes))
376
+
377
+
378
+ def test_parser_sets_every_listed_attribute(parsed):
379
+ """Every attribute in `attributes` is set on the instance."""
380
+ assert set(parsed.attributes) <= vars(parsed).keys()
381
+
382
+
383
+ @pytest.mark.parametrize('lineage, artifact, expected', [
384
+ pytest.param('segment',
385
+ 'N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000'
386
+ 'ens2.j02.bug',
387
+ ['segment', 'whole', 'ensemble_long', 'ensemble', 'space'],
388
+ id='segment'),
389
+ pytest.param('whole',
390
+ 'N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000'
391
+ 'ens2.bug',
392
+ ['whole', 'ensemble_long', 'ensemble', 'space'],
393
+ id='whole'),
394
+ pytest.param('ensemble_long',
395
+ 'N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000',
396
+ ['ensemble_long', 'ensemble', 'space'], id='ensemble_long'),
397
+ pytest.param('ensemble', 'N2000D20.0ac1.0nc117000',
398
+ ['ensemble', 'space'], id='ensemble'),
399
+ pytest.param('space', 'N2000D20.0ac1.0', ['space'], id='space'),
400
+ ])
401
+ def test_lineage_genealogy_lists_lineage_and_parents(lineage, artifact,
402
+ expected):
403
+ """A lineage's genealogy is itself followed by its parent lineages."""
404
+ parser = ChainCyl(artifact, lineage, 'bug')
405
+
406
+ assert parser.lineage_genealogy == expected
407
+
408
+
409
+ @pytest.mark.parametrize(
410
+ 'parser_class, artifact, group, geometry, topology, groups', [
411
+ pytest.param(ChainCyl, 'N2000D20.0ac1.0', 'bug', 'cylindrical',
412
+ 'linear', ['bug', 'all'], id='ChainCyl'),
413
+ pytest.param(RingCub, 'ns400nl5al5ac3', 'all', 'cubic', 'ring',
414
+ ['bug', 'all'], id='RingCub'),
415
+ ])
416
+ def test_parser_reports_geometry_topology_and_groups(
417
+ parser_class, artifact, group, geometry, topology, groups):
418
+ """Each parser reports its project's geometry, topology, and groups."""
419
+ parser = parser_class(artifact, 'space', group)
420
+
421
+ assert (parser.geometry, parser.topology, parser.groups) == (
422
+ geometry, topology, groups)
423
+
424
+
425
+ @pytest.mark.parametrize('parser_class, artifact, group, dump_keywords', [
426
+ pytest.param(ChainCyl, 'N2000D20.0ac1.0', 'bug',
427
+ {'bug': 'bdump', 'all': 'adump'}, id='ChainCyl'),
428
+ pytest.param(RingCub, 'ns400nl5al5ac3', 'all',
429
+ {'bug': 'bdump', 'all': 'adump'}, id='RingCub'),
430
+ ])
431
+ def test_parser_maps_each_group_to_its_dump_keyword(
432
+ parser_class, artifact, group, dump_keywords):
433
+ """Each group's dump keyword names the period of its own trajectory."""
434
+ parser = parser_class(artifact, 'space', group)
435
+
436
+ assert parser.dump_keywords == dump_keywords
437
+
438
+
439
+ @pytest.mark.parametrize('parser_class, artifact, group, keyword, period', [
440
+ pytest.param(ChainCyl,
441
+ 'N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000ens1',
442
+ 'bug', 'bdump', 2000, id='ChainCyl-bug'),
443
+ pytest.param(ChainCyl,
444
+ 'N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000ens1',
445
+ 'all', 'adump', 5000, id='ChainCyl-all'),
446
+ ])
447
+ def test_dump_keyword_reads_the_group_dump_period(
448
+ parser_class, artifact, group, keyword, period):
449
+ """dump_keyword names the attribute holding the group's dump period."""
450
+ parser = parser_class(artifact, 'whole', group)
451
+
452
+ assert (parser.dump_keyword, getattr(parser, parser.dump_keyword)) == (
453
+ keyword, period)
454
+
455
+
456
+ @pytest.mark.parametrize('parser_class, artifact, group, attrs, expected', [
457
+ pytest.param(RingCub,
458
+ 'al5nl5ml125.ns400ac3nc7735l45dt0.005bdump2000adump5000',
459
+ 'bug', ('mmon_large', 'nmon_small'), (125.0, 400),
460
+ id='RingCub'),
461
+ ])
462
+ def test_parser_reads_value_with_trailing_dot(parser_class, artifact, group,
463
+ attrs, expected):
464
+ """A value written with a trailing dot, such as ``1.``, parses."""
465
+ parser = parser_class(artifact, 'ensemble_long', group)
466
+
467
+ # float('1.') is exact.
468
+ assert attrgetter(*attrs)(parser) == pytest.approx(expected, rel=1e-12)
469
+
470
+
471
+ def test_declared_pattern_round_trips(declared_case):
472
+ """
473
+ A name built from a declared pattern parses back to the declared types
474
+ and conversions, so float keywords keep non-integer values (property).
475
+ """
476
+ parser_class, lineage, name, expected = declared_case
477
+
478
+ actual = attrgetter(*expected)(
479
+ parser_class(name, lineage, parser_class._groups[0]))
480
+
481
+ # One multiplication and one addition in float64.
482
+ assert actual == pytest.approx(tuple(expected.values()), rel=1e-12)
483
+ assert tuple(map(type, actual)) == tuple(map(type, expected.values()))
484
+
485
+
486
+ # Error paths of parsing
487
+
488
+
489
+ @pytest.mark.parametrize('parser_class, artifact, group, keyword, attr', [
490
+ pytest.param(ChainCyl,
491
+ 'N2000r10.5lz300.0ac1.0nc117000XX0.005bdump5000adump10000',
492
+ 'bug', 'dt', 'dt', id='ChainCyl-replaced'),
493
+ pytest.param(RingCub,
494
+ 'al5nl5ml125ns400ac3XX7735l45dt0.005bdump2000adump5000',
495
+ 'bug', 'nc', 'ncrowd', id='RingCub-replaced'),
496
+ pytest.param(ChainCyl,
497
+ '2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000',
498
+ 'bug', 'N', 'nmon', id='ChainCyl-heading-digit'),
499
+ pytest.param(RingCub,
500
+ '5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000',
501
+ 'bug', 'al', 'dmon_large', id='RingCub-heading-digit'),
502
+ pytest.param(RingCubTwoCrowders,
503
+ 'al4nl5ns400ac1ab5nc139229XX1671l45dt0.005bdump2000'
504
+ 'adump5000',
505
+ 'bug', 'nb', 'ncrowd_two', id='RingCubTwoCrowders-replaced'),
506
+ ])
507
+ def test_missing_keyword_raises_value_error(parser_class, artifact, group,
508
+ keyword, attr):
509
+ """A name without a pattern keyword raises ValueError naming it."""
510
+ message = (f"keyword '{keyword}' for attribute '{attr}' not found in "
511
+ f"'{artifact}' (lineage 'ensemble_long')")
512
+
513
+ with pytest.raises(ValueError, match=re.escape(message)):
514
+ parser_class(artifact, 'ensemble_long', group)
515
+
516
+
517
+ @pytest.mark.parametrize('parser_class, artifact, group, keyword', [
518
+ pytest.param(ChainCyl,
519
+ 'N.r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000',
520
+ 'bug', 'N', id='ChainCyl'),
521
+ pytest.param(RingCub,
522
+ 'al.nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000',
523
+ 'bug', 'al', id='RingCub'),
524
+ ])
525
+ def test_non_numeric_value_raises_value_error(parser_class, artifact, group,
526
+ keyword):
527
+ """A keyword whose value is not a number raises ValueError naming it."""
528
+ message = (f"value of keyword '{keyword}' in '{artifact}' "
529
+ "(lineage 'ensemble_long'): '.' is not a number")
530
+
531
+ with pytest.raises(ValueError, match=re.escape(message)):
532
+ parser_class(artifact, 'ensemble_long', group)
533
+
534
+
535
+ @pytest.mark.parametrize('parser_class, artifact, group, keyword, token', [
536
+ pytest.param(ChainCyl,
537
+ 'N2.5r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000',
538
+ 'bug', 'N', '2.5', id='ChainCyl-N'),
539
+ ])
540
+ def test_non_integral_integer_raises_value_error(parser_class, artifact,
541
+ group, keyword, token):
542
+ """A non-integral value of an integer keyword raises ValueError."""
543
+ message = (f"value of keyword '{keyword}' in '{artifact}' "
544
+ f"(lineage 'ensemble_long'): '{token}' is not an integer")
545
+
546
+ with pytest.raises(ValueError, match=re.escape(message)):
547
+ parser_class(artifact, 'ensemble_long', group)
548
+
549
+
550
+ def test_empty_artifact_raises_value_error(valid_case):
551
+ """An empty artifact name raises ValueError."""
552
+ with pytest.raises(ValueError,
553
+ match="'artifact' cannot be an empty string."):
554
+ valid_case.parser_class('', valid_case.lineage, valid_case.group)
555
+
556
+
557
+ def test_invalid_group_raises_value_error(valid_case):
558
+ """A group the project does not have raises ValueError."""
559
+ with pytest.raises(ValueError, match='is an invalid option.'):
560
+ valid_case.parser_class(valid_case.artifact, valid_case.lineage,
561
+ 'invalid')
562
+
563
+
564
+ def test_invalid_lineage_raises_value_error(valid_case):
565
+ """A lineage outside the five lineages raises ValueError."""
566
+ with pytest.raises(ValueError, match='is an invalid option.'):
567
+ valid_case.parser_class(valid_case.artifact, 'invalid',
568
+ valid_case.group)
569
+
570
+
571
+ # Text representations
572
+
573
+
574
+ def test_str_summarizes_artifact(valid_case):
575
+ """`str` lists the name, geometry, group, lineage, topology, project."""
576
+ parser = valid_case.parser_class(valid_case.artifact, valid_case.lineage,
577
+ valid_case.group)
578
+
579
+ assert str(parser) == (
580
+ f"Artifact:\n"
581
+ f" Name: '{valid_case.artifact}',\n"
582
+ f" Geometry: '{parser.geometry}',\n"
583
+ f" Group: '{valid_case.group}',\n"
584
+ f" Lineage: '{valid_case.lineage}',\n"
585
+ f" Topology: '{parser.topology}',\n"
586
+ f" Project: '{valid_case.parser_class.__name__}'")
587
+
588
+
589
+ def test_repr_names_artifact(valid_case):
590
+ """`repr` names the file, geometry, group, lineage, topology, project."""
591
+ parser = valid_case.parser_class(valid_case.artifact, valid_case.lineage,
592
+ valid_case.group)
593
+
594
+ assert repr(parser) == (
595
+ f"Artifact(filename='{valid_case.artifact}', "
596
+ f"geometry='{parser.geometry}', group='{valid_case.group}', "
597
+ f"lineage='{valid_case.lineage}', topology='{parser.topology}', "
598
+ f"project='{valid_case.parser_class.__name__}')")
599
+
600
+
601
+ # Bulk values
602
+
603
+
604
+ @pytest.mark.parametrize('parser_class, artifact, suffix, number, size', [
605
+ pytest.param(RingCub,
606
+ 'al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000',
607
+ 'm_small', 'nmon_small', 'dmon_small', id='RingCub-m_small'),
608
+ pytest.param(RingCub,
609
+ 'al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000',
610
+ 'm_large', 'nmon_large', 'dmon_large', id='RingCub-m_large'),
611
+ pytest.param(RingCub,
612
+ 'al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000',
613
+ 'c', 'ncrowd', 'dcrowd', id='RingCub-c'),
614
+ pytest.param(RingCubTwoCrowders,
615
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
616
+ 'adump5000',
617
+ 'm_small', 'nmon_small', 'dmon_small',
618
+ id='RingCubTwoCrowders-m_small'),
619
+ pytest.param(RingCubTwoCrowders,
620
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
621
+ 'adump5000',
622
+ 'm_large', 'nmon_large', 'dmon_large',
623
+ id='RingCubTwoCrowders-m_large'),
624
+ pytest.param(RingCubTwoCrowders,
625
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
626
+ 'adump5000',
627
+ 'c_one', 'ncrowd_one', 'dcrowd_one',
628
+ id='RingCubTwoCrowders-c_one'),
629
+ pytest.param(RingCubTwoCrowders,
630
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
631
+ 'adump5000',
632
+ 'c_two', 'ncrowd_two', 'dcrowd_two',
633
+ id='RingCubTwoCrowders-c_two'),
634
+ ])
635
+ def test_cube_bulk_values_match_utils(parser_class, artifact, suffix, number,
636
+ size):
637
+ """Cubic bulk values are the cube functions of the parsed values."""
638
+ parser = parser_class(artifact, 'ensemble_long', parser_class._groups[0])
639
+ n, d, lcube = attrgetter(number, size, 'lcube')(parser)
640
+
641
+ actual = attrgetter(f'rho_bulk_{suffix}', f'phi_bulk_{suffix}')(parser)
642
+
643
+ # The same functions on the same inputs; float64 round-off only.
644
+ assert actual == pytest.approx(
645
+ (number_density_cube(n, d, lcube, pbc=True),
646
+ volume_fraction_cube(n, d, lcube, pbc=True)), rel=1e-12)
647
+
648
+
649
+ @pytest.mark.parametrize('parser_class, artifact, suffix, number, size', [
650
+ pytest.param(ChainCyl,
651
+ 'N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000',
652
+ 'm', 'nmon', 'dmon', id='ChainCyl-m'),
653
+ pytest.param(ChainCyl,
654
+ 'N2000r10.5lz300.0ac1.0nc117000dt0.005bdump5000adump10000',
655
+ 'c', 'ncrowd', 'dcrowd', id='ChainCyl-c'),
656
+ ])
657
+ def test_cylinder_bulk_values_match_utils(parser_class, artifact, suffix,
658
+ number, size):
659
+ """Cylindrical bulk values are the cylinder functions of the values."""
660
+ parser = parser_class(artifact, 'ensemble_long', parser_class._groups[0])
661
+ n, d, lcyl, dcyl = attrgetter(number, size, 'lcyl', 'dcyl')(parser)
662
+
663
+ actual = attrgetter(f'rho_bulk_{suffix}', f'phi_bulk_{suffix}')(parser)
664
+
665
+ # The same functions on the same inputs; float64 round-off only.
666
+ assert actual == pytest.approx(
667
+ (number_density_cylinder(n, d, lcyl, dcyl, pbc=True),
668
+ volume_fraction_cylinder(n, d, lcyl, dcyl, pbc=True)), rel=1e-12)
669
+
670
+
671
+ @pytest.mark.parametrize('parser_class, artifact', [
672
+ pytest.param(RingCub,
673
+ 'al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000',
674
+ id='RingCub'),
675
+ pytest.param(RingCubTwoCrowders,
676
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
677
+ 'adump5000',
678
+ id='RingCubTwoCrowders'),
679
+ ])
680
+ def test_monomer_bulk_values_are_sums_over_sizes(parser_class, artifact):
681
+ """All-monomer bulk values are the sums of the small and large ones."""
682
+ parser = parser_class(artifact, 'ensemble_long', 'bug')
683
+ small = attrgetter('rho_bulk_m_small', 'phi_bulk_m_small')(parser)
684
+ large = attrgetter('rho_bulk_m_large', 'phi_bulk_m_large')(parser)
685
+
686
+ actual = attrgetter('rho_bulk_m', 'phi_bulk_m')(parser)
687
+
688
+ # One float64 addition each.
689
+ assert actual == pytest.approx(
690
+ (small[0] + large[0], small[1] + large[1]), rel=1e-12)
691
+
692
+
693
+ def test_crowder_bulk_values_are_sums_over_species():
694
+ """All-crowder bulk values are the sums of the two species' values."""
695
+ parser = RingCubTwoCrowders(
696
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000adump5000',
697
+ 'ensemble_long', 'bug')
698
+ one = attrgetter('rho_bulk_c_one', 'phi_bulk_c_one')(parser)
699
+ two = attrgetter('rho_bulk_c_two', 'phi_bulk_c_two')(parser)
700
+
701
+ actual = attrgetter('rho_bulk_c', 'phi_bulk_c')(parser)
702
+
703
+ # One float64 addition each.
704
+ assert actual == pytest.approx((one[0] + two[0], one[1] + two[1]),
705
+ rel=1e-12)
706
+
707
+
708
+ @pytest.mark.parametrize('parser_class, artifact', [
709
+ pytest.param(RingCub,
710
+ 'al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000',
711
+ id='RingCub'),
712
+ ])
713
+ def test_crowder_mass_is_size_cubed(parser_class, artifact):
714
+ """At a long lineage the crowder mass is its size cubed, m = d^3."""
715
+ parser = parser_class(artifact, 'ensemble_long', 'bug')
716
+
717
+ # One float64 power.
718
+ assert parser.mcrowd == pytest.approx(parser.dcrowd ** 3, rel=1e-12)
719
+
720
+
721
+ @pytest.mark.parametrize('parser_class, artifact', [
722
+ pytest.param(RingCub, 'ns400nl5al5ac3nc7735', id='RingCub'),
723
+ ])
724
+ def test_crowder_mass_is_placeholder_at_ensemble(parser_class, artifact):
725
+ """At the ensemble lineage the crowder mass keeps the placeholder -1."""
726
+ parser = parser_class(artifact, 'ensemble', 'bug')
727
+
728
+ assert parser.mcrowd == -1
729
+
730
+
731
+ # Lineage-name rules
732
+
733
+
734
+ @pytest.mark.parametrize(
735
+ 'parser_class, group, lineage, artifact, ensemble, space',
736
+ [
737
+ pytest.param(ChainCyl, 'all', 'segment',
738
+ 'N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000'
739
+ 'ens1.j03',
740
+ 'N200D20ac2nc1000', 'N200D20ac2',
741
+ id='ChainCyl-converted-dcyl'),
742
+ ],
743
+ )
744
+ def test_short_name_prints_whole_number_float_as_integer(
745
+ parser_class, group, lineage, artifact, ensemble, space):
746
+ """A whole-number float, zero included, prints as an integer."""
747
+ parser = parser_class(artifact, lineage, group)
748
+
749
+ assert (parser.ensemble, parser.space) == (ensemble, space)
750
+
751
+
752
+ @pytest.mark.parametrize(
753
+ 'parser_class, group, lineage, artifact, ensemble, space',
754
+ [
755
+ pytest.param(ChainCyl, 'all', 'segment',
756
+ 'N200r10.75lz25.0ac1.000001nc1000dt0.005bdump2000'
757
+ 'adump5000ens1.j03',
758
+ 'N200D20.5ac1.000001nc1000', 'N200D20.5ac1.000001',
759
+ id='ChainCyl-converted-dcyl'),
760
+ ],
761
+ )
762
+ def test_short_name_prints_fractional_float_as_repr(
763
+ parser_class, group, lineage, artifact, ensemble, space):
764
+ """A fractional float prints in full, as repr, in short names."""
765
+ parser = parser_class(artifact, lineage, group)
766
+
767
+ assert (parser.ensemble, parser.space) == (ensemble, space)
768
+
769
+
770
+ @pytest.mark.parametrize('token, expected', [
771
+ pytest.param('4.1', 7.2, id='r4.1'),
772
+ pytest.param('3.3', 5.6, id='r3.3'),
773
+ pytest.param('5.5', 10.0, id='r5.5'),
774
+ ])
775
+ def test_keyword_spec_converts_decimal_token_exactly(token, expected):
776
+ """The conversion 2r - 1 of a decimal token gives the float nearest the
777
+ exact decimal result, not one carrying binary round-off."""
778
+ spec = KeywordSpec('dcyl', float, scale=2.0, offset=-1.0)
779
+
780
+ value = spec.parse(token)
781
+
782
+ assert value == expected
783
+
784
+
785
+ @pytest.mark.parametrize('parser_class, group, artifact, ensemble', [
786
+ pytest.param(ChainCyl, 'bug',
787
+ 'N20r4.1lz10ac1nc100dt0.005bdump1000adump5000',
788
+ 'N20D7.2ac1nc100', id='ChainCyl-r4.1'),
789
+ pytest.param(ChainCyl, 'bug',
790
+ 'N20r5.5lz10ac1nc100dt0.005bdump1000adump5000',
791
+ 'N20D10ac1nc100', id='ChainCyl-r5.5'),
792
+ ])
793
+ def test_short_name_prints_cylinder_size_from_r_as_decimal(
794
+ parser_class, group, artifact, ensemble):
795
+ """The size D = 2r - 1 converted from a decimal r prints as the decimal
796
+ result in the ensemble name, so it matches the files named with it."""
797
+ parser = parser_class(artifact, 'ensemble_long', group)
798
+
799
+ assert parser.ensemble == ensemble
800
+
801
+
802
+ @pytest.mark.parametrize('short_lineage', [
803
+ pytest.param('ensemble', id='ensemble'),
804
+ pytest.param('space', id='space'),
805
+ ])
806
+ def test_short_name_reparses_to_itself(valid_case, short_lineage):
807
+ """A short name parsed at its own lineage rebuilds the same name."""
808
+ parser = valid_case.parser_class(valid_case.artifact, valid_case.lineage,
809
+ valid_case.group)
810
+ name = getattr(parser, short_lineage)
811
+
812
+ reparsed = valid_case.parser_class(name, short_lineage, valid_case.group)
813
+
814
+ assert getattr(reparsed, short_lineage) == name
815
+
816
+
817
+ @pytest.mark.parametrize('artifact, lineage, group, expected', [
818
+ pytest.param('runs/al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
819
+ 'adump5000ens1.j20.ring.all.lammpstrj',
820
+ 'segment', 'all',
821
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
822
+ 'adump5000ens1.j20.ring', id='segment-trajectory'),
823
+ pytest.param('runs/al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
824
+ 'adump5000ens1.ring.bug.data',
825
+ 'whole', 'bug',
826
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
827
+ 'adump5000ens1.ring', id='whole-data'),
828
+ pytest.param('runs/al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
829
+ 'adump5000ens1.ring.bug.restart.lammpstrj',
830
+ 'whole', 'bug',
831
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
832
+ 'adump5000ens1.ring', id='whole-restart'),
833
+ ])
834
+ def test_parser_reduces_file_path_to_lineage_name(artifact, lineage, group,
835
+ expected):
836
+ """A project file path reduces to its name before the group keyword."""
837
+ parser = RingCubTwoCrowders(artifact, lineage, group, ispath=True)
838
+
839
+ assert parser.name == expected
840
+
841
+
842
+ def test_segment_names_its_parents():
843
+ """A segment gives every parent name, short ones without a trailing .0."""
844
+ parser = RingCubTwoCrowders(
845
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000adump5000'
846
+ 'ens1.j20.ring.all', 'segment', 'all')
847
+
848
+ assert (parser.segment, parser.whole, parser.ensemble_long,
849
+ parser.ensemble, parser.space) == (
850
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000adump5000ens1.j20',
851
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000adump5000ens1',
852
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000adump5000',
853
+ 'al4nl5ns400ac1ab5nc139229nb1671',
854
+ 'al4nl5ns400ac1ab5')
855
+
856
+
857
+ @pytest.mark.parametrize(
858
+ 'parser_class, group, child_lineage, child_name, parent_lineage',
859
+ [
860
+ pytest.param(ChainCyl, 'all', 'segment',
861
+ 'N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000'
862
+ 'ens1.j01', 'whole',
863
+ id='ChainCyl-segment-whole'),
864
+ pytest.param(ChainCyl, 'all', 'whole',
865
+ 'N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000'
866
+ 'ens1', 'ensemble_long',
867
+ id='ChainCyl-whole-ensemble_long'),
868
+ pytest.param(RingCub, 'all', 'whole',
869
+ 'al5nl5ml125ns400ac3nc7735l45dt0.005bdump2000adump5000'
870
+ 'ens4.ring.all', 'ensemble_long',
871
+ id='RingCub-whole-ensemble_long'),
872
+ pytest.param(RingCubTwoCrowders, 'all', 'segment',
873
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
874
+ 'adump5000ens1.j20.ring.all', 'whole',
875
+ id='RingCubTwoCrowders-segment-whole'),
876
+ pytest.param(RingCubTwoCrowders, 'bug', 'whole',
877
+ 'al4nl5ns400ac1ab5nc139229nb1671l45dt0.005bdump2000'
878
+ 'adump5000ens1.ring.bug', 'ensemble_long',
879
+ id='RingCubTwoCrowders-whole-ensemble_long'),
880
+ ],
881
+ )
882
+ def test_parser_rebuilt_parent_name_keeps_attributes(
883
+ parser_class, group, child_lineage, child_name, parent_lineage):
884
+ """A rebuilt parent name parses to its child's attribute values."""
885
+ child = parser_class(child_name, child_lineage, group)
886
+ parent = parser_class(getattr(child, parent_lineage), parent_lineage,
887
+ group)
888
+ values = attrgetter(*parser_class._genealogy_attributes[parent_lineage])
889
+
890
+ # Both sides parse the same digits, so they agree to float64 round-off.
891
+ assert values(parent) == pytest.approx(values(child), rel=1e-12,
892
+ abs=1e-12)
893
+
894
+
895
+ @pytest.mark.parametrize(
896
+ 'parser_class, group, lineage, artifact, parent_lineage, expected',
897
+ [
898
+ pytest.param(ChainCyl, 'all', 'segment',
899
+ 'N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000'
900
+ 'ens1.j01', 'segment',
901
+ 'N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000'
902
+ 'ens1.j01', id='ChainCyl-segment'),
903
+ pytest.param(ChainCyl, 'all', 'segment',
904
+ 'N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000'
905
+ 'ens1.j01', 'whole',
906
+ 'N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000'
907
+ 'ens1', id='ChainCyl-whole'),
908
+ pytest.param(ChainCyl, 'all', 'segment',
909
+ 'N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000'
910
+ 'ens1.j01', 'ensemble_long',
911
+ 'N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000',
912
+ id='ChainCyl-ensemble_long'),
913
+ ],
914
+ )
915
+ def test_parser_rebuilt_parent_name_is_name_prefix(
916
+ parser_class, group, lineage, artifact, parent_lineage, expected):
917
+ """A long-form parent name is the artifact name cut after its last key."""
918
+ parser = parser_class(artifact, lineage, group)
919
+
920
+ assert getattr(parser, parent_lineage) == expected
921
+
922
+
923
+ # Declarations
924
+
925
+
926
+ @pytest.mark.parametrize('parser_class, lineage, expected', [
927
+ pytest.param(ChainCyl, 'whole',
928
+ OrderedDict([('nmon', 'N'), ('dcyl', 'r'), ('lcyl', 'lz'),
929
+ ('dcrowd', 'ac'), ('ncrowd', 'nc'),
930
+ ('dt', 'dt'), ('bdump', 'bdump'),
931
+ ('adump', 'adump'), ('ensemble_id', 'ens')]),
932
+ id='ChainCyl-whole'),
933
+ pytest.param(ChainCyl, 'ensemble',
934
+ OrderedDict([('nmon', 'N'), ('dcyl', 'D'),
935
+ ('dcrowd', 'ac'), ('ncrowd', 'nc')]),
936
+ id='ChainCyl-ensemble'),
937
+ ])
938
+ def test_genealogy_attributes_follow_pattern_order(parser_class, lineage,
939
+ expected):
940
+ """Each lineage maps attributes to keywords in the pattern's order."""
941
+ table = parser_class._genealogy_attributes[lineage]
942
+
943
+ assert (type(table), list(table.items())) == (OrderedDict,
944
+ list(expected.items()))
945
+
946
+
947
+ @pytest.mark.parametrize('parser_class', [
948
+ pytest.param(ParserBase, id='ParserBase'),
949
+ pytest.param(type('PatternlessParser', (ParserBase,), {}),
950
+ id='subclass-without-patterns'),
951
+ ])
952
+ def test_parser_without_patterns_cannot_be_instantiated(parser_class):
953
+ """A parser class that declares no patterns raises TypeError."""
954
+ with pytest.raises(TypeError, match='declares no lineage patterns'):
955
+ parser_class('N200', 'space', 'bug')
956
+
957
+
958
+ # Class-level reads of the declaration
959
+
960
+
961
+ def test_pattern_returns_declared_keywords():
962
+ """pattern gives a lineage's keywords in the order its name writes them."""
963
+ keywords = ChainCyl.pattern('space')
964
+
965
+ # The ChainCyl declaration: 'space': ('N', 'D', 'ac').
966
+ assert keywords == ('N', 'D', 'ac')
967
+
968
+
969
+ @pytest.mark.parametrize('lineage, expected', [
970
+ pytest.param(
971
+ 'ensemble_long',
972
+ {'nmon', 'dcyl', 'lcyl', 'dcrowd', 'ncrowd', 'dt', 'bdump', 'adump',
973
+ 'dmon', 'segment', 'whole', 'ensemble_long', 'ensemble', 'space',
974
+ 'rho_bulk_m', 'phi_bulk_m', 'rho_bulk_c', 'phi_bulk_c'},
975
+ id='ensemble_long-with-bulk-values'),
976
+ pytest.param(
977
+ 'space',
978
+ {'nmon', 'dcyl', 'dcrowd', 'dmon', 'segment', 'whole',
979
+ 'ensemble_long', 'ensemble', 'space'},
980
+ id='space-without-bulk-values'),
981
+ ])
982
+ def test_attributes_at_lists_declared_attributes(lineage, expected):
983
+ """attributes_at gives the parsed, default, lineage, and bulk names."""
984
+ names = ChainCyl.attributes_at(lineage)
985
+
986
+ # From the ChainCyl declaration: pattern attributes, 'dmon' in
987
+ # _defaults, the five lineage names, and at ensemble_long the bulk
988
+ # values of the species 'm' and 'c'.
989
+ assert names == frozenset(expected)
990
+
991
+
992
+ def test_attributes_at_adds_unlisted_bulk_values(chain_declaration):
993
+ """attributes_at adds bulk values that _project_attributes omits."""
994
+ chain_cub = type('ChainCub', (ParserBase,), chain_declaration)
995
+
996
+ names = chain_cub.attributes_at('ensemble_long')
997
+
998
+ # The declaration lists only 'dmon' as a project attribute; its species
999
+ # 'm' and 'c' still get bulk values at ensemble_long.
1000
+ assert {'rho_bulk_m', 'phi_bulk_m', 'rho_bulk_c', 'phi_bulk_c'} <= names
1001
+
1002
+
1003
+ def test_attributes_at_matches_parsed_instance(parse_case, parsed):
1004
+ """attributes_at names exactly the public attributes a parser sets."""
1005
+ public = {name for name in vars(parsed) if not name.startswith('_')}
1006
+
1007
+ names = parse_case.parser_class.attributes_at(parse_case.lineage)
1008
+
1009
+ assert names == public
1010
+
1011
+
1012
+ @pytest.mark.parametrize('method', [
1013
+ pytest.param('pattern', id='pattern'),
1014
+ pytest.param('attributes_at', id='attributes_at'),
1015
+ ])
1016
+ def test_class_reads_need_patterns(method):
1017
+ """pattern and attributes_at raise TypeError on a patternless class."""
1018
+ with pytest.raises(TypeError, match='declares no lineage patterns'):
1019
+ getattr(ParserBase, method)('space')
1020
+
1021
+
1022
+ @pytest.mark.parametrize('method', [
1023
+ pytest.param('pattern', id='pattern'),
1024
+ pytest.param('attributes_at', id='attributes_at'),
1025
+ ])
1026
+ def test_class_reads_reject_unknown_lineage(method):
1027
+ """pattern and attributes_at raise ValueError on an unknown lineage."""
1028
+ with pytest.raises(ValueError, match='invalid option'):
1029
+ getattr(ChainCyl, method)('galaxy')
1030
+
1031
+
1032
+ def test_declared_parser_parses_its_names(chain_declaration):
1033
+ """A class built from a valid declaration parses names of its grammar."""
1034
+ chain_cub = type('ChainCub', (ParserBase,), chain_declaration)
1035
+
1036
+ parser = chain_cub('N100ac2.0nc50l10.0ens1.bug', 'whole', 'bug')
1037
+
1038
+ assert (parser.nmon, parser.dcrowd, parser.ncrowd, parser.lcube,
1039
+ parser.ensemble_long, parser.ensemble, parser.space) == (
1040
+ 100, 2.0, 50, 20.0, 'N100ac2.0nc50l10.0', 'N100ac2nc50',
1041
+ 'N100ac2')
1042
+
1043
+
1044
+ @pytest.mark.parametrize('override, match', [
1045
+ pytest.param({'_geometry': None}, 'must declare', id='no-geometry'),
1046
+ pytest.param({'_dump_keywords': None}, "'_dump_keywords'",
1047
+ id='no-dump-keywords'),
1048
+ pytest.param({'_dump_keywords': {'bug': 'bdump'}},
1049
+ "_dump_keywords must have one entry per group",
1050
+ id='group-no-dump-keyword'),
1051
+ pytest.param(
1052
+ {'_dump_keywords': {'bug': 'bdump', 'all': 'adump',
1053
+ 'nucleoid': 'ndump'}},
1054
+ "_dump_keywords must have one entry per group",
1055
+ id='dump-keyword-of-undeclared-group'),
1056
+ pytest.param(
1057
+ {'_dump_keywords': {'bug': 'tdump', 'all': 'adump'}},
1058
+ "the dump keyword 'tdump' of group 'bug' is not set by the "
1059
+ "'segment' pattern or '_defaults'",
1060
+ id='dump-keyword-not-set'),
1061
+ pytest.param(
1062
+ {'_patterns': {'segment': ('N', 'ac', 'nc', 'l', 'ens', 'j'),
1063
+ 'whole': ('N', 'ac', 'nc', 'l', 'ens'),
1064
+ 'ensemble_long': ('N', 'ac', 'nc', 'l'),
1065
+ 'ensemble': ('N', 'ac', 'nc')}},
1066
+ "_patterns must have one entry per lineage", id='lineage-no-pattern'),
1067
+ pytest.param(
1068
+ {'_project_attributes': {'segment': [], 'whole': []}},
1069
+ "_project_attributes must have one entry per lineage",
1070
+ id='lineage-no-project-attributes'),
1071
+ pytest.param(
1072
+ {'_patterns': {'segment': ('N', 'ac', 'nc', 'l', 'ens', 'j'),
1073
+ 'whole': ('N', 'ac', 'nc', 'l', 'ens'),
1074
+ 'ensemble_long': ('N', 'ac', 'nc', 'l'),
1075
+ 'ensemble': ('N', 'ac', 'nc'),
1076
+ 'space': ('N', 'xx')}},
1077
+ "keyword 'xx' in the 'space' pattern has no KeywordSpec",
1078
+ id='undeclared-keyword'),
1079
+ pytest.param(
1080
+ {'_patterns': {'segment': ('N', 'ac', 'nc', 'l', 'ens', 'j'),
1081
+ 'whole': ('N', 'ac', 'nc', 'l', 'ens'),
1082
+ 'ensemble_long': ('N', 'ac', 'nc', 'l'),
1083
+ 'ensemble': ('N', 'ac', 'nc'),
1084
+ 'space': ('N', 'ac', 'N')}},
1085
+ "attribute 'nmon' appears more than once in the 'space' pattern",
1086
+ id='duplicate-attribute'),
1087
+ pytest.param(
1088
+ {'_totals': {'all': ('m', 'x')}},
1089
+ "total 'all' sums 'x', which is not a declared species",
1090
+ id='total-of-undeclared-species'),
1091
+ pytest.param(
1092
+ {'_patterns': {'segment': ('N', 'ac', 'nc', 'l', 'ens', 'j'),
1093
+ 'whole': ('N', 'ac', 'nc', 'l', 'ens'),
1094
+ 'ensemble_long': ('N', 'ac', 'nc'),
1095
+ 'ensemble': ('N', 'ac', 'nc'),
1096
+ 'space': ('N', 'ac')}},
1097
+ "the bulk values need 'lcube', which the 'ensemble_long' pattern",
1098
+ id='bulk-input-not-parsed'),
1099
+ ])
1100
+ def test_invalid_declaration_raises_type_error_at_class_creation(
1101
+ chain_declaration, override, match):
1102
+ """An incomplete or inconsistent declaration fails when it is defined."""
1103
+ with pytest.raises(TypeError, match=re.escape(match)):
1104
+ type('BadParser', (ParserBase,), {**chain_declaration, **override})
1105
+
1106
+
1107
+ # KeywordSpec
1108
+
1109
+
1110
+ @pytest.mark.parametrize('spec, token, expected', [
1111
+ pytest.param(KeywordSpec('nmon', int), '200', 200, id='int'),
1112
+ pytest.param(KeywordSpec('ncrowd', int), '1000.', 1000,
1113
+ id='int-trailing-dot'),
1114
+ pytest.param(KeywordSpec('dcrowd', float), '2.5', 2.5, id='float'),
1115
+ pytest.param(KeywordSpec('lcube', float, scale=2.0), '25.0', 50.0,
1116
+ id='half-side'),
1117
+ pytest.param(KeywordSpec('dcyl', float, scale=2.0, offset=-1.0), '10.5',
1118
+ 20.0, id='wall-radius'),
1119
+ ])
1120
+ def test_keyword_spec_converts_token(spec, token, expected):
1121
+ """A token converts to `scale * value + offset`, typed as declared."""
1122
+ actual = spec.parse(token)
1123
+
1124
+ # The conversions are exact in float64 for these tokens.
1125
+ assert (actual, type(actual)) == (pytest.approx(expected, rel=1e-12),
1126
+ type(expected))
1127
+
1128
+
1129
+ @pytest.mark.parametrize('spec, token, match', [
1130
+ pytest.param(KeywordSpec('dcrowd', float), '.', "'.' is not a number",
1131
+ id='dot'),
1132
+ pytest.param(KeywordSpec('dcrowd', float), '', "'' is not a number",
1133
+ id='empty'),
1134
+ pytest.param(KeywordSpec('nmon', int), '1.5', "'1.5' is not an integer",
1135
+ id='non-integral'),
1136
+ ])
1137
+ def test_keyword_spec_rejects_bad_token(spec, token, match):
1138
+ """A token that is not a number, or not whole for int, is rejected."""
1139
+ with pytest.raises(ValueError, match=re.escape(match)):
1140
+ spec.parse(token)
1141
+
1142
+
1143
+ @pytest.mark.parametrize('kwargs, match', [
1144
+ pytest.param({'attr': 'name', 'kind': str}, "must be int or float",
1145
+ id='str-kind'),
1146
+ pytest.param({'attr': 'nmon', 'kind': int, 'offset': 1.0},
1147
+ "only a float keyword", id='int-with-offset'),
1148
+ ])
1149
+ def test_keyword_spec_rejects_bad_declaration(kwargs, match):
1150
+ """A KeywordSpec of another type, or an int with a conversion, fails."""
1151
+ with pytest.raises(TypeError, match=match):
1152
+ KeywordSpec(**kwargs)
1153
+
1154
+
1155
+ # Study parsers that left the module
1156
+
1157
+
1158
+ @pytest.mark.parametrize('name, message', [
1159
+ pytest.param('HnsCub', 'projects/hns_cub/parser.py', id='HnsCub'),
1160
+ pytest.param('HnsCyl', 'projects/hns_cyl/parser.py', id='HnsCyl'),
1161
+ pytest.param('TransFociCub', 'projects/trans_foci_cub/parser.py',
1162
+ id='TransFociCub'),
1163
+ pytest.param('TransFociCyl', 'projects/trans_foci_cyl/parser.py',
1164
+ id='TransFociCyl'),
1165
+ pytest.param('SumRuleCyl', 'removed from PolyPhys in 1.0.0',
1166
+ id='SumRuleCyl'),
1167
+ pytest.param('SumRuleCubHeteroRing', 'removed from PolyPhys in 1.0.0',
1168
+ id='SumRuleCubHeteroRing'),
1169
+ pytest.param('SumRuleCubHeteroLinear', 'removed from PolyPhys in 1.0.0',
1170
+ id='SumRuleCubHeteroLinear'),
1171
+ pytest.param('SumRuleCubHeteroRingBiDisperse',
1172
+ 'removed from PolyPhys in 1.0.0',
1173
+ id='SumRuleCubHeteroRingBiDisperse'),
1174
+ pytest.param('TwoMonDepCub', 'removed from PolyPhys in 1.0.0',
1175
+ id='TwoMonDepCub'),
1176
+ ])
1177
+ def test_moved_study_parser_import_raises_import_error(name, message):
1178
+ """Importing a study parser that left the module says where it went."""
1179
+ module = importlib.import_module('polyphys.manage.parser')
1180
+
1181
+ with pytest.raises(ImportError, match=re.escape(message)):
1182
+ getattr(module, name)
1183
+
1184
+
1185
+ def test_unknown_name_raises_attribute_error():
1186
+ """A name the module never defined raises AttributeError, as usual."""
1187
+ module = importlib.import_module('polyphys.manage.parser')
1188
+
1189
+ with pytest.raises(AttributeError, match="has no attribute 'NoSuchName'"):
1190
+ getattr(module, 'NoSuchName')
1191
+
1192
+
1193
+ # Generated names
1194
+
1195
+
1196
+ @pytest.mark.parametrize('lineage', [
1197
+ pytest.param('segment', id='segment'),
1198
+ pytest.param('whole', id='whole'),
1199
+ pytest.param('ensemble_long', id='ensemble_long'),
1200
+ pytest.param('ensemble', id='ensemble'),
1201
+ pytest.param('space', id='space'),
1202
+ ])
1203
+ @pytest.mark.parametrize('parser_class', [
1204
+ pytest.param(getattr(synthetic, name), id=name)
1205
+ for name in synthetic.__all__
1206
+ ])
1207
+ @settings(max_examples=50, deadline=None)
1208
+ @given(data=st.data())
1209
+ def test_generated_name_parses_to_its_values_and_back(parser_class, lineage,
1210
+ data):
1211
+ """A name built from a parser's own pattern parses to its values and
1212
+ rebuilds to itself: a round-trip property, with no reference value."""
1213
+ name, values = data.draw(lineage_names(parser_class, lineage))
1214
+
1215
+ parsed = parser_class(name, lineage, parser_class._groups[0])
1216
+
1217
+ assert getattr(parsed, lineage) == name
1218
+ # The parser converts in exact decimal arithmetic, the expectation in
1219
+ # float64: a few ulp apart; atol covers a value converted to zero.
1220
+ assert {attr: getattr(parsed, attr)
1221
+ for attr in stored_values(parser_class, values)} == pytest.approx(
1222
+ stored_values(parser_class, values), rel=1e-12, abs=1e-12)