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,1264 @@
1
+ """\
2
+ ==========================================================
3
+ :mod:`polyphys.manage.parser`
4
+ ==========================================================
5
+
6
+ This :mod:`~polyphys.manage.parser` module turns the name of a simulation
7
+ artifact into typed attributes. A name such as
8
+ ``N200r10.5lz25.0ac2.0nc1000dt0.005bdump2000adump5000ens1`` is a
9
+ run of keyword-value pairs; each project has its own keywords, and each level
10
+ of the lineage ``segment``, ``whole``, ``ensemble_long``, ``ensemble``, and
11
+ ``space`` uses its own subset of them, in its own order.
12
+
13
+ A parser class is a declaration. It states the project's geometry, polymer
14
+ topology, and particle groups; one :class:`KeywordSpec` per filename keyword;
15
+ the keyword pattern of each lineage; fixed values; and the particle species
16
+ whose bulk number density and volume fraction the parser computes.
17
+ :class:`ParserBase` does the parsing, builds the parent lineage names, and
18
+ computes the bulk values.
19
+
20
+ Contents
21
+ ========
22
+
23
+ - :class:`KeywordSpec` and :class:`Species`, the two building blocks of a
24
+ declaration.
25
+ - :class:`ParserBase`, the base class every parser derives from.
26
+
27
+ The module ships no study's parser: a study declares its own, as the
28
+ "Studies" page shows.
29
+
30
+ .. versionchanged:: 1.0.0
31
+ The parsers of nine studies left this module. ``TransFociCyl``,
32
+ ``TransFociCub``, ``HnsCub``, and ``HnsCyl`` are in the ``projects/``
33
+ folder of the PolyPhys repository, which is not installed;
34
+ ``SumRuleCyl``, ``SumRuleCubHeteroRing``, ``SumRuleCubHeteroLinear``,
35
+ ``SumRuleCubHeteroRingBiDisperse``, and ``TwoMonDepCub`` were removed.
36
+ Importing any of the nine from here raises :exc:`ImportError` that says
37
+ where it went.
38
+
39
+ Dependencies
40
+ ============
41
+ - Python standard library: `os`, `re`, `collections`, `dataclasses`,
42
+ `decimal`, `pathlib`, `typing`.
43
+ - :mod:`polyphys.manage.utils`: :func:`~polyphys.manage.utils.invalid_keyword`
44
+ and the number-density and volume-fraction functions.
45
+ - :mod:`polyphys.manage.types`: domain type aliases such as
46
+ :py:obj:`~.LineageT`.
47
+
48
+ Examples
49
+ ========
50
+ >>> from polyphys.tests.synthetic import ChainCyl
51
+ >>> artifact = ChainCyl('N200D10.0ac2.0nc0', 'ensemble', 'all')
52
+ >>> print(artifact.dcrowd)
53
+ 2.0
54
+
55
+ >>> import polyphys.manage.parser as parser
56
+ >>> parser.HnsCub # doctest: +ELLIPSIS
57
+ Traceback (most recent call last):
58
+ ...
59
+ ImportError: HnsCub moved out of PolyPhys in 1.0.0, to projects/hns_cub/...
60
+
61
+ Notes
62
+ =====
63
+ The parsed and computed attributes are set on the instance at construction
64
+ with :func:`setattr`, so their names come from the declaration rather than
65
+ from the class body; :attr:`ParserBase.attributes` lists the ones a given
66
+ lineage sets.
67
+ """
68
+ import os
69
+ import re
70
+ from collections import OrderedDict
71
+ from dataclasses import dataclass
72
+ from decimal import Context, Decimal
73
+ from pathlib import Path
74
+ from typing import ClassVar, Optional, Self
75
+ from .utils import (
76
+ invalid_keyword,
77
+ number_density_cube,
78
+ number_density_cylinder,
79
+ volume_fraction_cube,
80
+ volume_fraction_cylinder
81
+ )
82
+ from .types import LineageT, TopologyT, GroupT, GeometryT, Pathish
83
+
84
+ __all__ = [
85
+ 'KeywordSpec',
86
+ 'ParserBase',
87
+ 'Species',
88
+ ]
89
+
90
+ # The study parsers that left this module in 1.0.0: each published one,
91
+ # with its folder under projects/ in the PolyPhys repository, and each
92
+ # unpublished one, with None.
93
+ _MOVED_STUDIES: dict[str, str | None] = {
94
+ 'HnsCub': 'hns_cub',
95
+ 'HnsCyl': 'hns_cyl',
96
+ 'TransFociCub': 'trans_foci_cub',
97
+ 'TransFociCyl': 'trans_foci_cyl',
98
+ 'SumRuleCubHeteroLinear': None,
99
+ 'SumRuleCubHeteroRing': None,
100
+ 'SumRuleCubHeteroRingBiDisperse': None,
101
+ 'SumRuleCyl': None,
102
+ 'TwoMonDepCub': None,
103
+ }
104
+
105
+ # A keyword is a run of letters (or hyphens); its value is the text up to the
106
+ # next keyword.
107
+ _TOKEN_RE = re.compile(r"([a-zA-Z\-]+)")
108
+
109
+ # A file extension, as ``ispath=True`` strips it: a dot and a run of letters
110
+ # and digits with at least one letter, such as ``.csv`` or ``.h5``, so that the
111
+ # fractional part of a trailing value, such as ``.5`` in ``ac1.5``, stays in
112
+ # the name.
113
+ _EXTENSION_RE = re.compile(r"\.[a-zA-Z0-9]*[a-zA-Z][a-zA-Z0-9]*")
114
+
115
+ # The exact decimal arithmetic of a keyword conversion; 50 digits hold the
116
+ # product of any filename value with a scale and an offset without rounding.
117
+ _DECIMAL_CONTEXT = Context(prec=50)
118
+
119
+ # The lineages whose names carry the box size, and so the bulk values.
120
+ _BULK_LINEAGES: tuple[LineageT, ...] = ('segment', 'whole', 'ensemble_long')
121
+
122
+ # The attributes that size the simulation box in each geometry.
123
+ _BOX_ATTRIBUTES: dict[GeometryT, tuple[str, ...]] = {
124
+ 'cubic': ('lcube',),
125
+ 'cylindrical': ('lcyl', 'dcyl'),
126
+ }
127
+
128
+
129
+ def _format_value(value: int | float) -> str:
130
+ """
131
+ Return the text of a keyword value in a short lineage name.
132
+
133
+ A float that is a whole number prints as an integer; any other float
134
+ prints as :func:`repr`, Python's shortest form that reads back as the
135
+ same float, so two different values never print alike. An `int` prints
136
+ unchanged. The cost is :math:`O(1)` per value.
137
+
138
+ Parameters
139
+ ----------
140
+ value : int or float
141
+ A parsed keyword value.
142
+
143
+ Returns
144
+ -------
145
+ str
146
+ The value as it appears after its keyword in an ``ensemble`` or
147
+ ``space`` name.
148
+
149
+ Examples
150
+ --------
151
+ >>> _format_value(2.0), _format_value(0.0), _format_value(40.0)
152
+ ('2', '0', '40')
153
+ >>> _format_value(1.5), _format_value(1.000001), _format_value(200)
154
+ ('1.5', '1.000001', '200')
155
+ """
156
+ if isinstance(value, float) and value.is_integer():
157
+ return str(int(value))
158
+ return repr(value)
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class KeywordSpec:
163
+ """
164
+ How a parser reads the value of one filename keyword.
165
+
166
+ The value :math:`x` that follows the keyword in a name is stored in the
167
+ attribute `attr` as :math:`y = s x + c`, with scale :math:`s` and offset
168
+ :math:`c`; the default :math:`s = 1`, :math:`c = 0` stores the value as
169
+ written.
170
+
171
+ Parameters
172
+ ----------
173
+ attr : str
174
+ Name of the parser attribute that stores the value.
175
+ kind : {int, float}
176
+ Type of the stored value.
177
+ scale : float, default 1.0
178
+ Scale :math:`s` of the conversion. Only a `float` keyword may set it.
179
+ offset : float, default 0.0
180
+ Offset :math:`c` of the conversion. Only a `float` keyword may set
181
+ it.
182
+
183
+ Raises
184
+ ------
185
+ TypeError
186
+ If `kind` is neither `int` nor `float`, or if an `int` keyword sets
187
+ `scale` or `offset`.
188
+
189
+ Examples
190
+ --------
191
+ The keyword ``N`` is a number of monomers:
192
+
193
+ >>> KeywordSpec('nmon', int).parse('200')
194
+ 200
195
+
196
+ The keyword ``r`` is the radius of the cylinder on which the centers of
197
+ the wall beads of size 1 lie, so the cylinder size is :math:`D = 2r - 1`:
198
+
199
+ >>> spec = KeywordSpec('dcyl', float, scale=2.0, offset=-1.0)
200
+ >>> spec.parse('10.5')
201
+ 20.0
202
+ """
203
+ attr: str
204
+ kind: type[int] | type[float]
205
+ scale: float = 1.0
206
+ offset: float = 0.0
207
+
208
+ def __post_init__(self) -> None:
209
+ if self.kind not in (int, float):
210
+ raise TypeError(
211
+ f"KeywordSpec {self.attr!r}: 'kind' must be int or float, "
212
+ f"not {self.kind!r}.")
213
+ if self.kind is int and not self.is_identity:
214
+ raise TypeError(
215
+ f"KeywordSpec {self.attr!r}: only a float keyword can have a "
216
+ "'scale' or an 'offset'.")
217
+
218
+ @property
219
+ def is_identity(self) -> bool:
220
+ """
221
+ Whether the conversion stores the value as written.
222
+ """
223
+ return self.scale == 1.0 and self.offset == 0.0
224
+
225
+ def parse(self, token: str) -> int | float:
226
+ """
227
+ Convert the text that follows a keyword into the stored value.
228
+
229
+ Parameters
230
+ ----------
231
+ token : str
232
+ The text between the keyword and the next keyword, such as
233
+ ``'10.5'`` or ``'1.'``.
234
+
235
+ Returns
236
+ -------
237
+ int or float
238
+ The value as an `int` for an `int` keyword, else
239
+ :math:`s x + c`. A conversion that is not the identity is done in
240
+ exact decimal arithmetic on the text of `token` and of
241
+ :func:`repr` of :math:`s` and :math:`c`, then rounded once to the
242
+ nearest float, so ``'4.1'`` with :math:`s = 2`, :math:`c = -1`
243
+ gives ``7.2`` rather than the binary result
244
+ ``7.199999999999999``. The cost is :math:`O(1)` per value.
245
+
246
+ Raises
247
+ ------
248
+ ValueError
249
+ If `token` is not a number, or if the keyword is an `int` keyword
250
+ and `token` is not a whole number, such as ``'1.5'``.
251
+
252
+ Examples
253
+ --------
254
+ >>> KeywordSpec('ncrowd', int).parse('1000.')
255
+ 1000
256
+ >>> KeywordSpec('lcube', float, scale=2.0).parse('25.0')
257
+ 50.0
258
+ >>> KeywordSpec('dcyl', float, scale=2.0, offset=-1.0).parse('4.1')
259
+ 7.2
260
+ """
261
+ try:
262
+ value = float(token)
263
+ except ValueError:
264
+ raise ValueError(f"{token!r} is not a number") from None
265
+ if self.kind is int:
266
+ if not value.is_integer():
267
+ raise ValueError(f"{token!r} is not an integer")
268
+ return int(value)
269
+ if self.is_identity:
270
+ return value
271
+ context = _DECIMAL_CONTEXT
272
+ exact = context.add(
273
+ context.multiply(Decimal(token), Decimal(repr(self.scale))),
274
+ Decimal(repr(self.offset)))
275
+ return float(exact)
276
+
277
+
278
+ @dataclass(frozen=True)
279
+ class Species:
280
+ """
281
+ A particle species whose bulk number density and volume fraction a parser
282
+ computes.
283
+
284
+ At the ``segment``, ``whole``, and ``ensemble_long`` lineages, a parser
285
+ sets ``rho_bulk_<suffix>``, the number of particles of the species per
286
+ unit volume of the box, and ``phi_bulk_<suffix>``, the fraction of the
287
+ box volume they fill.
288
+
289
+ Parameters
290
+ ----------
291
+ suffix : str
292
+ Suffix of the two attribute names, such as ``'m'`` for monomers or
293
+ ``'c'`` for crowders.
294
+ number : str
295
+ Name of the attribute that holds the number of particles.
296
+ size : str
297
+ Name of the attribute that holds the particle diameter.
298
+
299
+ Examples
300
+ --------
301
+ >>> crowders = Species('c', 'ncrowd', 'dcrowd')
302
+ >>> crowders.suffix
303
+ 'c'
304
+ """
305
+ suffix: str
306
+ number: str
307
+ size: str
308
+
309
+
310
+ class ParserBase:
311
+ """
312
+ Base parser class for extracting information from filenames or file paths
313
+ in a structured project.
314
+
315
+ A subclass declares a project's filename grammar and physics through the
316
+ class attributes listed under "Declaration"; this class parses the name,
317
+ builds the names of the parent lineages, and computes the bulk number
318
+ densities and volume fractions. A declaration is checked when the
319
+ subclass is created; a class whose `_patterns` is ``None`` is abstract and
320
+ cannot be instantiated. :meth:`from_name` constructs a parser from a name
321
+ token instead of a path, with ``filepath`` and ``ext`` set to ``None``.
322
+
323
+ Parameters
324
+ ----------
325
+ artifact : Pathish
326
+ The artifact to be parsed: a name, or, with ``ispath=True``, a
327
+ relative or absolute file path (string or Path).
328
+ lineage : :py:obj:`~.LineageT`
329
+ The lineage of the artifact, specifying the hierarchical level within
330
+ the project. Must be one of: ``'segment'``, ``'whole'``,
331
+ ``'ensemble_long'``, ``'ensemble'``, or ``'space'``.
332
+ group : :py:obj:`~.GroupT`
333
+ The particle group the artifact describes; one of the subclass's
334
+ `groups`.
335
+ ispath : bool, default False
336
+ Whether `artifact` is a file path. A path is expanded (``~`` and
337
+ environment variables) and resolved, and its stem is parsed.
338
+
339
+ Attributes
340
+ ----------
341
+ filepath : pathlib.Path or None
342
+ The resolved absolute directory that holds `artifact` if it is a
343
+ file path, or ``None`` if it is a name.
344
+ filename : str
345
+ The filename extracted from `artifact`, or `artifact` itself.
346
+ ext : str or None
347
+ The file extension, including the leading dot, if `artifact` is a
348
+ file path: :attr:`pathlib.PurePath.suffix`, which is ``''`` for a
349
+ file with no extension. ``None`` if `artifact` is a name.
350
+ name : str
351
+ The lineage name: `filename` without its extension, cut before
352
+ ``.<group>`` (``segment`` and ``whole``) and before the first
353
+ hyphen.
354
+ group : :py:obj:`~.GroupT`
355
+ Particle group type in the project.
356
+ dump_keyword : str
357
+ The attribute that holds the dump period, in time steps, of the
358
+ `group` trajectory: ``dump_keywords[group]``.
359
+ lineage : :py:obj:`~.LineageT`
360
+ The lineage of the artifact.
361
+ project_name : str
362
+ The name of the parser subclass.
363
+ lineage_genealogy : list of :py:obj:`~.LineageT`
364
+ The artifact's `lineage` and its parent lineages.
365
+ lineage_attributes : list of str
366
+ The attributes parsed from `name`, in pattern order.
367
+ physical_attributes : list of str
368
+ The fixed and computed attributes listed for the artifact's
369
+ `lineage`.
370
+ attributes : list of str
371
+ `lineage_attributes` followed by `physical_attributes`.
372
+ segment : str
373
+ The artifact's name at the ``segment`` lineage if it is in
374
+ `lineage_genealogy`, else ``'N/A'``; cut from `name`, with no
375
+ topology suffix.
376
+ whole : str
377
+ As `segment`, at the ``whole`` lineage.
378
+ ensemble_long : str
379
+ As `segment`, at the ``ensemble_long`` lineage.
380
+ ensemble : str
381
+ The artifact's name at the ``ensemble`` lineage if it is in
382
+ `lineage_genealogy`, else ``'N/A'``; built from the parsed values,
383
+ so a whole-number float prints without ``.0``.
384
+ space : str
385
+ As `ensemble`, at the ``space`` lineage.
386
+
387
+ Class Attributes
388
+ ----------------
389
+ lineages : list of :py:obj:`~.LineageT`
390
+ List of valid lineage types.
391
+ genealogy : dict of :py:obj:`~.LineageT` to list of :py:obj:`~.LineageT`
392
+ Each lineage mapped to itself and its parent lineages.
393
+ geometry : :py:obj:`~.GeometryT`
394
+ Specifies the geometry of the system.
395
+ topology : :py:obj:`~.TopologyT`
396
+ Specifies how particles are connected (or not) in the system.
397
+ groups : list of :py:obj:`~.GroupT`
398
+ Allowed particle groups in the system.
399
+ dump_keywords : dict of :py:obj:`~.GroupT` to str
400
+ Each group mapped to the attribute that holds the dump period, in
401
+ time steps, of its trajectory.
402
+ genealogy_attributes : dict of :py:obj:`~.LineageT` to OrderedDict
403
+ Each lineage mapped to an OrderedDict of attribute names and their
404
+ filename keywords, in pattern order; built from `_keywords` and
405
+ `_patterns`.
406
+ project_attributes : dict of :py:obj:`~.LineageT` to list of str
407
+ Each lineage mapped to the attributes that are fixed or computed
408
+ rather than parsed.
409
+
410
+ Declaration
411
+ -----------
412
+ _geometry : :py:obj:`~.GeometryT`
413
+ ``'cubic'`` or ``'cylindrical'``. A cubic project with species
414
+ declares the attribute ``lcube``; a cylindrical one ``lcyl`` and
415
+ ``dcyl``.
416
+ _topology : :py:obj:`~.TopologyT`
417
+ The polymer topology.
418
+ _groups : list of :py:obj:`~.GroupT`
419
+ The particle groups.
420
+ _dump_keywords : dict of :py:obj:`~.GroupT` to str
421
+ As `dump_keywords` above, one entry per group. Each attribute is set
422
+ at ``segment``, ``whole``, and ``ensemble_long``, by the pattern or
423
+ by `_defaults`.
424
+ _keywords : dict of str to :class:`KeywordSpec`
425
+ Each filename keyword mapped to how its value is read.
426
+ _patterns : dict of :py:obj:`~.LineageT` to tuple of str
427
+ Each lineage mapped to its keywords in name order.
428
+ _project_attributes : dict of :py:obj:`~.LineageT` to list of str
429
+ As `project_attributes` above.
430
+ _defaults : dict of str to float, optional
431
+ Attributes set at every lineage before parsing, such as a fixed
432
+ monomer size.
433
+ _species : tuple of :class:`Species`, optional
434
+ The species whose bulk values are computed.
435
+ _totals : dict of str to tuple of str, optional
436
+ A suffix mapped to species suffixes; ``rho_bulk_<suffix>`` and
437
+ ``phi_bulk_<suffix>`` are the sums of theirs.
438
+ _masses : dict of str to str, optional
439
+ A mass attribute mapped to a size attribute; the mass is the size
440
+ cubed, :math:`m = d^3`, for a uniform mass density.
441
+
442
+ Raises
443
+ ------
444
+ TypeError
445
+ On instantiation of a class with no `_patterns`, such as
446
+ `ParserBase` itself; on creation of a subclass whose declaration is
447
+ incomplete or inconsistent.
448
+ ValueError
449
+ If `artifact` is empty; if `lineage` or `group` is not valid; if
450
+ `name` lacks a keyword of the lineage's pattern; or if a keyword's
451
+ value is not a number, or not a whole number for an `int` keyword.
452
+
453
+ Notes
454
+ -----
455
+ Parsing splits `name` once into keyword-value pairs and keeps the first
456
+ value of each keyword, so a name may carry keywords the pattern does not
457
+ use. For a name of length :math:`L` and a pattern of :math:`k` keywords,
458
+ parsing costs :math:`O(L + k)` time and :math:`O(L)` memory.
459
+
460
+ The bulk values are computed only at the ``segment``, ``whole``, and
461
+ ``ensemble_long`` lineages, whose names carry the box size.
462
+
463
+ A value that is a function of parsed values but fits none of the tables
464
+ above, such as a complementary percentage, is set by overriding
465
+ ``_derived_attributes``, which runs at every lineage after the bulk
466
+ values.
467
+
468
+ Examples
469
+ --------
470
+ A parser for a hypothetical study of a linear chain among crowders in a
471
+ periodic cube, whose ``bug`` and ``all`` trajectories are dumped every
472
+ 1000 and 5000 time steps:
473
+
474
+ >>> class ChainCub(ParserBase):
475
+ ... _geometry = 'cubic'
476
+ ... _topology = 'linear'
477
+ ... _groups = ['bug', 'all']
478
+ ... _dump_keywords = {'bug': 'bdump', 'all': 'adump'}
479
+ ... _keywords = {
480
+ ... 'N': KeywordSpec('nmon', int),
481
+ ... 'ac': KeywordSpec('dcrowd', float),
482
+ ... 'nc': KeywordSpec('ncrowd', int),
483
+ ... 'l': KeywordSpec('lcube', float, scale=2.0),
484
+ ... 'ens': KeywordSpec('ensemble_id', int),
485
+ ... 'j': KeywordSpec('segment_id', int),
486
+ ... }
487
+ ... _patterns = {
488
+ ... 'segment': ('N', 'ac', 'nc', 'l', 'ens', 'j'),
489
+ ... 'whole': ('N', 'ac', 'nc', 'l', 'ens'),
490
+ ... 'ensemble_long': ('N', 'ac', 'nc', 'l'),
491
+ ... 'ensemble': ('N', 'ac', 'nc'),
492
+ ... 'space': ('N', 'ac'),
493
+ ... }
494
+ ... _defaults = {'dmon': 1.0, 'bdump': 1000, 'adump': 5000}
495
+ ... _species = (Species('m', 'nmon', 'dmon'),
496
+ ... Species('c', 'ncrowd', 'dcrowd'))
497
+ ... _project_attributes = {
498
+ ... 'segment': ['dmon', 'phi_bulk_m', 'rho_bulk_m',
499
+ ... 'phi_bulk_c', 'rho_bulk_c'],
500
+ ... 'whole': ['dmon', 'phi_bulk_m', 'rho_bulk_m',
501
+ ... 'phi_bulk_c', 'rho_bulk_c'],
502
+ ... 'ensemble_long': ['dmon', 'phi_bulk_m', 'rho_bulk_m',
503
+ ... 'phi_bulk_c', 'rho_bulk_c'],
504
+ ... 'ensemble': ['dmon'],
505
+ ... 'space': ['dmon'],
506
+ ... }
507
+ >>> artifact = ChainCub('N100ac2.0nc50l10.0ens1.bug', 'whole', 'bug')
508
+ >>> artifact.lcube
509
+ 20.0
510
+ >>> artifact.ensemble_long, artifact.ensemble, artifact.space
511
+ ('N100ac2.0nc50l10.0', 'N100ac2nc50', 'N100ac2')
512
+ >>> artifact.filepath is None, artifact.ext is None
513
+ (True, True)
514
+ >>> artifact.dump_keyword, getattr(artifact, artifact.dump_keyword)
515
+ ('bdump', 1000)
516
+ """
517
+ _lineages: ClassVar[list[LineageT]] = \
518
+ ['segment', 'whole', 'ensemble_long', 'ensemble', 'space']
519
+ _genealogy: ClassVar[dict[LineageT, list[LineageT]]] = {
520
+ 'segment': ['segment', 'whole', 'ensemble_long', 'ensemble', 'space'],
521
+ 'whole': ['whole', 'ensemble_long', 'ensemble', 'space'],
522
+ 'ensemble_long': ['ensemble_long', 'ensemble', 'space'],
523
+ 'ensemble': ['ensemble', 'space'],
524
+ 'space': ['space'],
525
+ }
526
+ # Every concrete subclass sets these; __init_subclass__ checks them.
527
+ _geometry: ClassVar[GeometryT]
528
+ _topology: ClassVar[TopologyT]
529
+ _groups: ClassVar[list[GroupT]]
530
+ _dump_keywords: ClassVar[dict[GroupT, str]]
531
+ _keywords: ClassVar[Optional[dict[str, KeywordSpec]]] = None
532
+ _patterns: ClassVar[Optional[dict[LineageT, tuple[str, ...]]]] = None
533
+ _defaults: ClassVar[dict[str, float]] = {}
534
+ _species: ClassVar[tuple[Species, ...]] = ()
535
+ _totals: ClassVar[dict[str, tuple[str, ...]]] = {}
536
+ _masses: ClassVar[dict[str, str]] = {}
537
+ _genealogy_attributes: ClassVar[dict[LineageT, OrderedDict[str, str]]]
538
+ _project_attributes: ClassVar[dict[LineageT, list[str]]]
539
+
540
+ def __init_subclass__(cls) -> None:
541
+ super().__init_subclass__()
542
+ if cls._patterns is None:
543
+ return
544
+ keywords, patterns = cls._check_declaration()
545
+ cls._genealogy_attributes = {
546
+ lineage: OrderedDict(
547
+ (keywords[keyword].attr, keyword)
548
+ for keyword in patterns[lineage])
549
+ for lineage in cls._lineages
550
+ }
551
+
552
+ @classmethod
553
+ def _check_declaration(
554
+ cls
555
+ ) -> tuple[dict[str, KeywordSpec], dict[LineageT, tuple[str, ...]]]:
556
+ """
557
+ Check a subclass's declaration and return its keyword and pattern
558
+ tables.
559
+
560
+ Returns
561
+ -------
562
+ tuple of (dict, dict)
563
+ `_keywords` and `_patterns`.
564
+
565
+ Raises
566
+ ------
567
+ TypeError
568
+ If a required class attribute is unset; if a lineage has no
569
+ pattern or no project attributes; if a group has no dump
570
+ keyword, or a dump keyword names no group; if a pattern keyword
571
+ has no :class:`KeywordSpec`; if an attribute appears twice in
572
+ one pattern; if the bulk recipe or a dump keyword reads an
573
+ attribute a ``segment``, ``whole``, or ``ensemble_long`` name
574
+ does not set; or if a total names an undeclared species.
575
+ """
576
+ name = cls.__name__
577
+ keywords = cls._keywords
578
+ patterns = cls._patterns
579
+ geometry: Optional[GeometryT] = getattr(cls, '_geometry', None)
580
+ if (geometry is None or getattr(cls, '_topology', None) is None
581
+ or getattr(cls, '_groups', None) is None
582
+ or getattr(cls, '_dump_keywords', None) is None
583
+ or keywords is None or patterns is None
584
+ or getattr(cls, '_project_attributes', None) is None):
585
+ raise TypeError(
586
+ f"{name} must declare '_geometry', '_topology', '_groups', "
587
+ "'_dump_keywords', '_keywords', '_patterns', and "
588
+ "'_project_attributes'.")
589
+ for table in ('_patterns', '_project_attributes'):
590
+ if set(getattr(cls, table)) != set(cls._lineages):
591
+ raise TypeError(
592
+ f"{name}.{table} must have one entry per lineage: "
593
+ f"{cls._lineages}.")
594
+ if set(cls._dump_keywords) != set(cls._groups):
595
+ raise TypeError(
596
+ f"{name}._dump_keywords must have one entry per group: "
597
+ f"{cls._groups}.")
598
+ for lineage, pattern in patterns.items():
599
+ for keyword in pattern:
600
+ if keyword not in keywords:
601
+ raise TypeError(
602
+ f"{name}: keyword {keyword!r} in the {lineage!r} "
603
+ "pattern has no KeywordSpec.")
604
+ attrs = [keywords[keyword].attr for keyword in pattern]
605
+ for attr in attrs:
606
+ if attrs.count(attr) > 1:
607
+ raise TypeError(
608
+ f"{name}: attribute {attr!r} appears more than once "
609
+ f"in the {lineage!r} pattern.")
610
+ suffixes = {species.suffix for species in cls._species}
611
+ for total, parts in cls._totals.items():
612
+ for part in parts:
613
+ if part not in suffixes:
614
+ raise TypeError(
615
+ f"{name}: total {total!r} sums {part!r}, which is "
616
+ "not a declared species.")
617
+ needed: list[str] = list(cls._masses.values())
618
+ if cls._species:
619
+ needed.extend(_BOX_ATTRIBUTES[geometry])
620
+ for species in cls._species:
621
+ needed.extend((species.number, species.size))
622
+ for lineage in _BULK_LINEAGES:
623
+ available = set(cls._defaults).union(
624
+ keywords[keyword].attr for keyword in patterns[lineage])
625
+ for attr in needed:
626
+ if attr not in available:
627
+ raise TypeError(
628
+ f"{name}: the bulk values need {attr!r}, which the "
629
+ f"{lineage!r} pattern does not set.")
630
+ for group, attr in cls._dump_keywords.items():
631
+ if attr not in available:
632
+ raise TypeError(
633
+ f"{name}: the dump keyword {attr!r} of group "
634
+ f"{group!r} is not set by the {lineage!r} pattern "
635
+ "or '_defaults'.")
636
+ return keywords, patterns
637
+
638
+ def __init__(
639
+ self,
640
+ artifact: Pathish,
641
+ lineage: LineageT,
642
+ group: GroupT,
643
+ ispath: bool = False
644
+ ) -> None:
645
+ if self._patterns is None:
646
+ raise TypeError(
647
+ f"{type(self).__name__} declares no lineage patterns; "
648
+ "instantiate a subclass that declares a study.")
649
+ artifact_str = os.fspath(artifact)
650
+ if artifact_str == '':
651
+ raise ValueError("'artifact' cannot be an empty string.")
652
+
653
+ invalid_keyword(lineage, self.lineages)
654
+ invalid_keyword(group, self.groups)
655
+
656
+ self._ispath = ispath
657
+ if ispath:
658
+ # Normalize: expand env/~ and convert to absolute path
659
+ p = Path(
660
+ os.path.expandvars(os.path.expanduser(artifact_str))
661
+ ).resolve()
662
+ self._filepath: Optional[Path] = p.parent
663
+ self._filename: str = p.name
664
+ ext = p.suffix if _EXTENSION_RE.fullmatch(p.suffix) else ''
665
+ self._name: str = p.name[:len(p.name) - len(ext)]
666
+ self._ext: Optional[str] = ext
667
+ else:
668
+ self._filepath = None
669
+ self._filename = artifact_str
670
+ self._name = artifact_str
671
+ self._ext = None
672
+
673
+ self._project_name = self.__class__.__name__
674
+ self._lineage: LineageT = lineage
675
+ self._group: GroupT = group
676
+
677
+ # Update the self._name by dropping 'group' and/or any other extra
678
+ # beyond the template defined above.
679
+ self._find_name()
680
+ self._lineage_genealogy: list[LineageT] = self._genealogy[lineage]
681
+ self._lineage_attributes = \
682
+ list(self.genealogy_attributes[lineage].keys())
683
+ self._physical_attributes = self.project_attributes[lineage]
684
+ self._attributes = \
685
+ self._lineage_attributes + self._physical_attributes
686
+
687
+ self._set_defaults()
688
+ self._parse_name()
689
+ self._set_parents()
690
+ if self.lineage in _BULK_LINEAGES:
691
+ self._dependent_attributes()
692
+ self._derived_attributes()
693
+
694
+ def __str__(self) -> str:
695
+ """
696
+ Provide a formatted summary of the parser instance.
697
+ """
698
+ observation = (
699
+ f"Artifact:\n"
700
+ f" Name: '{self.filename}',\n"
701
+ f" Geometry: '{self.geometry}',\n"
702
+ f" Group: '{self.group}',\n"
703
+ f" Lineage: '{self.lineage}',\n"
704
+ f" Topology: '{self.topology}',\n"
705
+ f" Project: '{self.project_name}'"
706
+ )
707
+ return observation
708
+
709
+ def __repr__(self) -> str:
710
+ return (
711
+ f"Artifact(filename='{self.filename}', "
712
+ f"geometry='{self.geometry}', "
713
+ f"group='{self.group}', lineage='{self.lineage}', "
714
+ f"topology='{self.topology}', project='{self.project_name}')"
715
+ )
716
+
717
+ @classmethod
718
+ def from_name(
719
+ cls,
720
+ name: str,
721
+ lineage: LineageT,
722
+ group: GroupT
723
+ ) -> Self:
724
+ """
725
+ Construct a parser from a *name token* (not a path).
726
+
727
+ Sets `filepath` and `ext` to ``None`` and keeps the token as both
728
+ `filename` and `name` before `_find_name()` normalization.
729
+
730
+ Examples
731
+ --------
732
+ >>> from polyphys.tests.synthetic import ChainCyl
733
+ >>> parser = ChainCyl.from_name('N200D10.0ac2.0', 'space', 'bug')
734
+ >>> parser.filepath is None, parser.ext is None
735
+ (True, True)
736
+ """
737
+ return cls(name, lineage, group, ispath=False)
738
+
739
+ @classmethod
740
+ def pattern(cls, lineage: LineageT) -> tuple[str, ...]:
741
+ """
742
+ Return the filename keywords of `lineage`, in name order.
743
+
744
+ Parameters
745
+ ----------
746
+ lineage : :py:obj:`~.LineageT`
747
+ One of ``'segment'``, ``'whole'``, ``'ensemble_long'``,
748
+ ``'ensemble'``, or ``'space'``.
749
+
750
+ Returns
751
+ -------
752
+ tuple of str
753
+ The keywords, such as ``'N'`` or ``'ac'``, that a name of
754
+ `lineage` carries, in the order they appear in it.
755
+
756
+ Raises
757
+ ------
758
+ TypeError
759
+ If the class declares no patterns, such as `ParserBase` itself.
760
+ ValueError
761
+ If `lineage` is not a valid lineage.
762
+
763
+ Examples
764
+ --------
765
+ >>> from polyphys.tests.synthetic import ChainCyl
766
+ >>> ChainCyl.pattern('space')
767
+ ('N', 'D', 'ac')
768
+ """
769
+ patterns = cls._declared_patterns()
770
+ invalid_keyword(lineage, cls._lineages)
771
+ return patterns[lineage]
772
+
773
+ @classmethod
774
+ def attributes_at(cls, lineage: LineageT) -> frozenset[str]:
775
+ """
776
+ Return the names of the attributes that a parser of `lineage` sets.
777
+
778
+ The set holds the attributes parsed from the lineage's pattern, the
779
+ `_defaults`, the five lineage names (``segment`` to ``space``), and
780
+ the lineage's `_project_attributes`. At ``segment``, ``whole``, and
781
+ ``ensemble_long`` it also holds the bulk number density
782
+ ``rho_bulk_<suffix>`` and volume fraction ``phi_bulk_<suffix>`` of
783
+ each species and each total, and the declared masses. An attribute
784
+ that an override of ``_derived_attributes`` sets counts only if
785
+ `_project_attributes` lists it.
786
+
787
+ The set is built from the declaration, without parsing a name; the
788
+ cost is :math:`O(k + n)` for a pattern of :math:`k` keywords and
789
+ :math:`n` declared defaults, species, totals, masses, and project
790
+ attributes.
791
+
792
+ Parameters
793
+ ----------
794
+ lineage : :py:obj:`~.LineageT`
795
+ One of ``'segment'``, ``'whole'``, ``'ensemble_long'``,
796
+ ``'ensemble'``, or ``'space'``.
797
+
798
+ Returns
799
+ -------
800
+ frozenset of str
801
+ The attribute names.
802
+
803
+ Raises
804
+ ------
805
+ TypeError
806
+ If the class declares no patterns, such as `ParserBase` itself.
807
+ ValueError
808
+ If `lineage` is not a valid lineage.
809
+
810
+ Examples
811
+ --------
812
+ The bulk values exist at ``ensemble_long``, not at ``space``:
813
+
814
+ >>> from polyphys.tests.synthetic import ChainCyl
815
+ >>> 'phi_bulk_c' in ChainCyl.attributes_at('ensemble_long')
816
+ True
817
+ >>> 'phi_bulk_c' in ChainCyl.attributes_at('space')
818
+ False
819
+ """
820
+ cls._declared_patterns()
821
+ invalid_keyword(lineage, cls._lineages)
822
+ names = set(cls._genealogy_attributes[lineage])
823
+ names.update(cls._defaults, cls._lineages,
824
+ cls._project_attributes[lineage])
825
+ if lineage in _BULK_LINEAGES:
826
+ suffixes = [species.suffix for species in cls._species]
827
+ suffixes.extend(cls._totals)
828
+ for suffix in suffixes:
829
+ names.update((f"rho_bulk_{suffix}", f"phi_bulk_{suffix}"))
830
+ names.update(cls._masses)
831
+ return frozenset(names)
832
+
833
+ @classmethod
834
+ def _declared_patterns(cls) -> dict[LineageT, tuple[str, ...]]:
835
+ """
836
+ Return `_patterns`, or raise `TypeError` if the class declares none.
837
+ """
838
+ if cls._patterns is None:
839
+ raise TypeError(
840
+ f"{cls.__name__} declares no lineage patterns; use a "
841
+ "subclass that declares a study.")
842
+ return cls._patterns
843
+
844
+ @property
845
+ def lineages(self) -> list[LineageT]:
846
+ """
847
+ List of all the acceptable lineages.
848
+ """
849
+ return self._lineages
850
+
851
+ @property
852
+ def genealogy(self) -> dict[LineageT, list[LineageT]]:
853
+ """
854
+ Dictionary of lineages and their parent lineages.
855
+ """
856
+ return self._genealogy
857
+
858
+ @property
859
+ def geometry(self) -> GeometryT:
860
+ """
861
+ System geometry in a molecular dynamics system.
862
+ """
863
+ return self._geometry
864
+
865
+ @property
866
+ def groups(self) -> list[GroupT]:
867
+ """
868
+ List of valid group names for the subclass.
869
+ """
870
+ return self._groups
871
+
872
+ @property
873
+ def dump_keywords(self) -> dict[GroupT, str]:
874
+ """
875
+ Each group mapped to the attribute that holds the dump period, in
876
+ time steps, of its trajectory.
877
+ """
878
+ return self._dump_keywords
879
+
880
+ @property
881
+ def topology(self) -> TopologyT:
882
+ """
883
+ Define the polymer topology for the parser subclass.
884
+ """
885
+ return self._topology
886
+
887
+ @property
888
+ def genealogy_attributes(self) -> dict[LineageT, OrderedDict[str, str]]:
889
+ """
890
+ Dictionary of lineage-specific attributes. Each key is a lineage type,
891
+ and each value is an OrderedDict mapping attribute names to their
892
+ filename keywords, in pattern order.
893
+ """
894
+ return self._genealogy_attributes
895
+
896
+ @property
897
+ def project_attributes(self) -> dict[LineageT, list[str]]:
898
+ """
899
+ Dictionary of project attributes. Each key is a lineage type, and
900
+ each value is the list of attributes that are fixed or computed
901
+ rather than parsed at that lineage.
902
+ """
903
+ return self._project_attributes
904
+
905
+ @property
906
+ def filename(self) -> str:
907
+ """
908
+ Return the filename, either extracted from the path or the name
909
+ itself.
910
+ """
911
+ return self._filename
912
+
913
+ @property
914
+ def filepath(self) -> Optional[Path]:
915
+ """
916
+ Return the resolved absolute directory that holds the artifact, or
917
+ ``None`` if the parser was built from a name.
918
+
919
+ Examples
920
+ --------
921
+ >>> from polyphys.tests.synthetic import ChainCyl
922
+ >>> import tempfile
923
+ >>> with tempfile.TemporaryDirectory() as folder:
924
+ ... path = Path(folder, 'N200D10.0ac2.0.csv')
925
+ ... path.touch()
926
+ ... parser = ChainCyl(path, 'space', 'bug', ispath=True)
927
+ ... parser.filepath == Path(folder).resolve()
928
+ True
929
+ >>> ChainCyl('N200D10.0ac2.0', 'space', 'bug').filepath is None
930
+ True
931
+ """
932
+ return self._filepath
933
+
934
+ @property
935
+ def ispath(self) -> bool:
936
+ """
937
+ Return whether the artifact was parsed as a filesystem path.
938
+ """
939
+ return self._ispath
940
+
941
+ @property
942
+ def group(self) -> GroupT:
943
+ """
944
+ Return the current group.
945
+ """
946
+ return self._group
947
+
948
+ @property
949
+ def dump_keyword(self) -> str:
950
+ """
951
+ The attribute that holds the dump period, in time steps, of the
952
+ `group` trajectory.
953
+ """
954
+ return self._dump_keywords[self._group]
955
+
956
+ @property
957
+ def ext(self) -> Optional[str]:
958
+ """
959
+ Return the file extension, including the leading dot, or ``None`` if
960
+ the parser was built from a name.
961
+
962
+ The extension is :attr:`pathlib.PurePath.suffix`, the part from the
963
+ last dot, when that part holds a letter, such as ``.csv`` or ``.h5``;
964
+ otherwise it is ``''`` and the part stays in :attr:`name`. A name
965
+ that ends with a value, such as ``ac1.5``, or with a dot, such as
966
+ ``ac1.``, so keeps its last value whatever the Python version.
967
+
968
+ Examples
969
+ --------
970
+ >>> from polyphys.tests.synthetic import ChainCyl
971
+ >>> import tempfile
972
+ >>> with tempfile.TemporaryDirectory() as folder:
973
+ ... path = Path(folder, 'N200D10ac2')
974
+ ... path.touch()
975
+ ... ChainCyl(path, 'space', 'bug', ispath=True).ext
976
+ ''
977
+ >>> with tempfile.TemporaryDirectory() as folder:
978
+ ... path = Path(folder, 'N200D10ac1.5')
979
+ ... path.touch()
980
+ ... parser = ChainCyl(path, 'space', 'bug', ispath=True)
981
+ ... parser.ext, parser.dcrowd
982
+ ('', 1.5)
983
+ >>> ChainCyl('N200D10.0ac2.0', 'space', 'bug').ext is None
984
+ True
985
+ """
986
+ return self._ext
987
+
988
+ @property
989
+ def lineage(self) -> LineageT:
990
+ """
991
+ Return the current lineage.
992
+ """
993
+ return self._lineage
994
+
995
+ @property
996
+ def name(self) -> str:
997
+ """
998
+ Return the unique name parsed from the filename.
999
+ """
1000
+ return self._name
1001
+
1002
+ @property
1003
+ def project_name(self) -> str:
1004
+ """
1005
+ Return the project (parser class) name,
1006
+ """
1007
+ return self._project_name
1008
+
1009
+ @property
1010
+ def attributes(self) -> list[str]:
1011
+ """
1012
+ Return lineage-specific and project attributes for an artifact.
1013
+ """
1014
+ return self._attributes
1015
+
1016
+ @property
1017
+ def lineage_genealogy(self) -> list[LineageT]:
1018
+ """
1019
+ Return the parents of a given `lineage`.
1020
+ """
1021
+ return self._lineage_genealogy
1022
+
1023
+ @property
1024
+ def lineage_attributes(self) -> list[str]:
1025
+ """
1026
+ Return lineage-specific attributes for an artifact with a given
1027
+ `lineage`.
1028
+ """
1029
+ return self._lineage_attributes
1030
+
1031
+ @property
1032
+ def physical_attributes(self) -> list[str]:
1033
+ """
1034
+ Return project-level attributes for an artifact with a given
1035
+ `lineage`.
1036
+ """
1037
+ return self._physical_attributes
1038
+
1039
+ def _find_name(self) -> None:
1040
+ """
1041
+ parses the unique lineage_name (the first substring of filename
1042
+ and/or the segment keyword middle substring) of a filename.
1043
+ """
1044
+ if self.lineage in ['segment', 'whole']:
1045
+ # a 'segment' lineage only used in 'probe' phase
1046
+ # a 'whole' lineage used in 'probe' or 'analyze' phases
1047
+ # so its lineage_name is either ended by 'group' keyword or '-'.
1048
+ # these two combined below:
1049
+ base = self.name.split(f".{self.group}")[0]
1050
+ self._name = base.split('-')[0]
1051
+ else: # 'ensemble' or 'space' lineages
1052
+ self._name = self.name.split('-')[0]
1053
+
1054
+ def _set_defaults(self) -> None:
1055
+ """
1056
+ Set the attributes declared in `_defaults`.
1057
+ """
1058
+ for attr, value in self._defaults.items():
1059
+ setattr(self, attr, value)
1060
+
1061
+ def _parse_name(self) -> None:
1062
+ """
1063
+ Set the attributes of the lineage's pattern from the values in
1064
+ `name`.
1065
+
1066
+ Raises
1067
+ ------
1068
+ ValueError
1069
+ If `name` lacks a keyword of the pattern, or a keyword's value is
1070
+ not a number, or not a whole number for an `int` keyword.
1071
+ """
1072
+ keywords = self._keywords
1073
+ patterns = self._patterns
1074
+ assert keywords is not None and patterns is not None # __init__
1075
+ words = _TOKEN_RE.split(self.name)
1076
+ values: dict[str, str] = {}
1077
+ for keyword, token in zip(words[1::2], words[2::2]):
1078
+ values.setdefault(keyword, token)
1079
+ for keyword in patterns[self.lineage]:
1080
+ spec = keywords[keyword]
1081
+ if keyword not in values:
1082
+ raise ValueError(
1083
+ f"keyword {keyword!r} for attribute {spec.attr!r} not "
1084
+ f"found in {self.name!r} (lineage {self.lineage!r}).")
1085
+ try:
1086
+ value = spec.parse(values[keyword])
1087
+ except ValueError as err:
1088
+ raise ValueError(
1089
+ f"value of keyword {keyword!r} in {self.name!r} "
1090
+ f"(lineage {self.lineage!r}): {err}.") from err
1091
+ setattr(self, spec.attr, value)
1092
+
1093
+ def _set_parents(self) -> None:
1094
+ """
1095
+ Set parent lineage names for each lineage type, following the
1096
+ hierarchy defined in `_genealogy`.
1097
+
1098
+ Notes
1099
+ -----
1100
+ The `self._genealogy` defines the following parent-child hierarchy:
1101
+
1102
+ - space -> ensemble -> ensemble_long -> whole -> segment
1103
+
1104
+ Each lineage on the left has all the lineages on its right.
1105
+
1106
+ The long-form names (``segment``, ``whole``, and ``ensemble_long``)
1107
+ are cut from `name` after the value of the parent's last keyword, so
1108
+ they keep the original tokens. `_parse_name` converts some values,
1109
+ such as ``r`` to `dcyl` and ``l`` to `lcube`; writing those
1110
+ attributes back under the original keywords would convert them again
1111
+ at every re-parse. Because the cut ends at a keyword's value, a
1112
+ ``segment`` or ``whole`` name never carries the topology suffix:
1113
+ ``...ens1.ring`` and ``...ens1.j07.ring`` give the ``whole``
1114
+ ``...ens1``, so a simulation has one ``whole`` name whichever file it
1115
+ was parsed from.
1116
+
1117
+ The short-form names (``ensemble`` and ``space``) use their own
1118
+ keywords, such as ``D``, and are built from the attributes. A float
1119
+ value that is a whole number prints as an integer and any other
1120
+ float as :func:`repr` (``ac2``, ``epshc0``, ``epshc1.5``), whatever
1121
+ the name being parsed wrote, such as ``ac2.0`` or ``ac2``.
1122
+
1123
+ Every parent attribute is read once, so an unparsed keyword raises
1124
+ `AttributeError`. The cost is :math:`O(L)` in the length :math:`L`
1125
+ of `name`.
1126
+ """
1127
+ for lineage_name in self.lineages:
1128
+ lineage_value = 'N/A'
1129
+ if lineage_name in self.genealogy[self.lineage]:
1130
+ lineage_attr = self.genealogy_attributes[lineage_name]
1131
+ lineage_value = ''.join(
1132
+ f"{attr_short}{_format_value(getattr(self, attr_long))}"
1133
+ for attr_long, attr_short in lineage_attr.items()
1134
+ )
1135
+ if lineage_name in ['segment', 'whole', 'ensemble_long']:
1136
+ last_keyword = next(reversed(lineage_attr.values()))
1137
+ lineage_value = self._name_through(last_keyword)
1138
+ setattr(self, lineage_name, lineage_value)
1139
+
1140
+ def _name_through(self, keyword: str) -> str:
1141
+ """
1142
+ Return `name` up to and including the value of `keyword`, without a
1143
+ trailing dot.
1144
+
1145
+ Parameters
1146
+ ----------
1147
+ keyword : str
1148
+ A short-form keyword, such as ``ens``, that appears in `name`.
1149
+
1150
+ Returns
1151
+ -------
1152
+ str
1153
+ The prefix of `name` that ends with the value of `keyword`.
1154
+
1155
+ Raises
1156
+ ------
1157
+ ValueError
1158
+ If `keyword` is not a keyword of `name`.
1159
+ """
1160
+ for match in re.finditer(r"([a-zA-Z]+)([^a-zA-Z]*)", self.name):
1161
+ if match.group(1) == keyword:
1162
+ return self.name[:match.end()].rstrip('.')
1163
+ raise ValueError(f"'{keyword}' keyword not found in '{self.name}'.")
1164
+
1165
+ def _bulk_values(self, number: float, size: float) -> tuple[float, float]:
1166
+ """
1167
+ Return the bulk number density and volume fraction of `number`
1168
+ particles of diameter `size` in the periodic box.
1169
+
1170
+ Parameters
1171
+ ----------
1172
+ number : float
1173
+ Number of particles.
1174
+ size : float
1175
+ Particle diameter.
1176
+
1177
+ Returns
1178
+ -------
1179
+ tuple of (float, float)
1180
+ The number density and the volume fraction, from
1181
+ :func:`~polyphys.manage.utils.number_density_cube` and
1182
+ :func:`~polyphys.manage.utils.volume_fraction_cube` with the side
1183
+ `lcube`, or their cylinder counterparts with `lcyl` and `dcyl`,
1184
+ all with ``pbc=True``.
1185
+ """
1186
+ if self.geometry == 'cubic':
1187
+ lcube = getattr(self, 'lcube')
1188
+ return (number_density_cube(number, size, lcube, pbc=True),
1189
+ volume_fraction_cube(number, size, lcube, pbc=True))
1190
+ lcyl = getattr(self, 'lcyl')
1191
+ dcyl = getattr(self, 'dcyl')
1192
+ return (number_density_cylinder(number, size, lcyl, dcyl, pbc=True),
1193
+ volume_fraction_cylinder(number, size, lcyl, dcyl, pbc=True))
1194
+
1195
+ def _dependent_attributes(self) -> None:
1196
+ """
1197
+ Compute the bulk values of each species, their declared totals, and
1198
+ the declared masses.
1199
+
1200
+ Raises
1201
+ ------
1202
+ ValueError
1203
+ If a species leaves its centers no room in the box, such as a
1204
+ species as wide as the cylinder; the message names the species,
1205
+ `name`, and the lineage.
1206
+ """
1207
+ for species in self._species:
1208
+ try:
1209
+ rho, phi = self._bulk_values(getattr(self, species.number),
1210
+ getattr(self, species.size))
1211
+ except ValueError as err:
1212
+ raise ValueError(
1213
+ f"species {species.suffix!r} of {self.name!r} "
1214
+ f"(lineage {self.lineage!r}): {err}") from err
1215
+ setattr(self, f"rho_bulk_{species.suffix}", rho)
1216
+ setattr(self, f"phi_bulk_{species.suffix}", phi)
1217
+ for total, parts in self._totals.items():
1218
+ for quantity in ('rho_bulk', 'phi_bulk'):
1219
+ setattr(self, f"{quantity}_{total}",
1220
+ sum(getattr(self, f"{quantity}_{part}")
1221
+ for part in parts))
1222
+ for mass, size in self._masses.items():
1223
+ setattr(self, mass, getattr(self, size) ** 3)
1224
+
1225
+ def _derived_attributes(self) -> None:
1226
+ """
1227
+ Set the attributes that a subclass derives from parsed values outside
1228
+ the declaration tables; the base class sets none.
1229
+
1230
+ It runs at every lineage, after parsing, the parent names, and the
1231
+ bulk values, so an override reads only attributes that the lineage's
1232
+ pattern, `_defaults`, or the bulk recipe set. An override costs
1233
+ :math:`O(1)` per attribute it sets.
1234
+ """
1235
+
1236
+
1237
+ def __getattr__(name: str) -> object:
1238
+ """
1239
+ Say where a study parser that left this module in 1.0.0 went.
1240
+
1241
+ Python calls this (:pep:`562`) only for a name the module does not
1242
+ define. An :exc:`ImportError`, not an :exc:`AttributeError`, so that
1243
+ ``from polyphys.manage.parser import HnsCub`` shows the message: Python
1244
+ replaces an :exc:`AttributeError` raised here with its own.
1245
+
1246
+ Raises
1247
+ ------
1248
+ ImportError
1249
+ If `name` is a study parser that left the module.
1250
+ AttributeError
1251
+ For any other name.
1252
+ """
1253
+ if name in _MOVED_STUDIES:
1254
+ folder = _MOVED_STUDIES[name]
1255
+ if folder is None:
1256
+ raise ImportError(
1257
+ f"{name} was removed from PolyPhys in 1.0.0; pin polyphys "
1258
+ f"0.9.0 to keep using it, or copy it into your study's code.")
1259
+ raise ImportError(
1260
+ f"{name} moved out of PolyPhys in 1.0.0, to "
1261
+ f"projects/{folder}/parser.py in the PolyPhys repository, which "
1262
+ f"is not installed; copy it into your study's code.")
1263
+ raise AttributeError(
1264
+ f"module 'polyphys.manage.parser' has no attribute {name!r}")