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
polyphys/spec.py ADDED
@@ -0,0 +1,1359 @@
1
+ """\
2
+ ==========================================================
3
+ :mod:`polyphys.spec`
4
+ ==========================================================
5
+
6
+ This :mod:`~polyphys.spec` module holds the records that describe one study
7
+ as data, every one of them in a module that imports neither MDAnalysis nor
8
+ :mod:`polyphys.probe`, so a study's spec builds without the ``mda`` extra:
9
+
10
+ - :class:`ProjectSpec`, the per-project facts that the parser class does not
11
+ hold. A parser class declares how a study's filenames read; a
12
+ :class:`ProjectSpec` declares how the study's analysis reads its
13
+ artifacts: which particle group is the polymer, which parser attributes
14
+ become columns, how the bulk volume fraction of crowders is rounded, which
15
+ time series exist, and which size attribute scales each species' density
16
+ profile.
17
+ - :class:`ProbeSpec`, how :mod:`polyphys.probe` reads the study's
18
+ trajectories: which MDAnalysis selection is each species, which
19
+ chain-size observables are measured, how the chain is unwrapped, the bin
20
+ sizes of the histograms, and, for a study with binders, a
21
+ :class:`BinderSpec` of how their binding is counted.
22
+ - :class:`AnalysisTable`, the analysis of one particle group, which lists a
23
+ :class:`PropertySpec` per probe artifact for
24
+ :func:`~polyphys.analyze.pipeline.reduce_space`.
25
+
26
+ The facts the parser already holds, such as the geometry, the topology, and
27
+ the glob patterns of the ``space`` and ``ensemble_long`` names, are read from
28
+ the parser rather than stored twice.
29
+
30
+ PolyPhys holds no table of studies: a study builds its own spec where its
31
+ analysis lives. The specs of the maintainer's studies are in the repository's
32
+ top-level ``projects/`` folder, which is not part of the installed package.
33
+
34
+ .. versionadded:: 0.9.0
35
+ The records of ``polyphys.manage.projects`` and the table records and
36
+ builders of :mod:`polyphys.analyze.pipeline` moved here; their old import
37
+ paths warned with :exc:`DeprecationWarning`.
38
+
39
+ .. versionchanged:: 1.0.0
40
+ The old import paths are removed: ``polyphys.manage.projects`` no
41
+ longer exists, and :mod:`polyphys.analyze.pipeline` no longer serves the
42
+ table records and builders.
43
+
44
+ Contents
45
+ ========
46
+
47
+ Project and probe records:
48
+
49
+ - :class:`ProjectSpec`, a frozen record of one study.
50
+ - :class:`ProbeSpec`, a frozen record of how one study's trajectories are
51
+ probed, built beside its :class:`ProjectSpec`.
52
+ - :data:`CHAIN_SIZE_OBSERVABLES`, the names a :class:`ProbeSpec` may list as
53
+ chain-size observables.
54
+ - :class:`BinderSpec`, a frozen record of how the binding of a study's
55
+ two-patch binders to its chain is counted, held by the :class:`ProbeSpec`
56
+ of a study with binders.
57
+ - :data:`BINDER_SERIES`, the eight binding counts and the stems of their
58
+ time series.
59
+ - :data:`PerWhole`, a number or a function of a whole's parser, and
60
+ :data:`RecenterT`, how a probe moves a frame's positions.
61
+
62
+ Analysis records:
63
+
64
+ - :class:`PropertySpec` describes one probe artifact; :func:`tseries`,
65
+ :func:`histogram`, :func:`histogram_2d`, :func:`edge`, and
66
+ :func:`matrix_tseries` build the kinds the studies use.
67
+ - :class:`AnalysisTable` lists the properties of one group.
68
+ - :data:`PostHook`, the type of a post-hook; the hooks themselves are in
69
+ :mod:`polyphys.analyze.pipeline`.
70
+
71
+ Dependencies
72
+ ============
73
+ - Python standard library: `dataclasses`, `math`, `numbers`, `types`,
74
+ `typing`.
75
+ - :mod:`polyphys.manage.parser`: :class:`~polyphys.manage.parser.ParserBase`.
76
+ - :mod:`polyphys.manage.types`: domain type aliases such as
77
+ :py:obj:`~.GroupT`.
78
+
79
+ Notation
80
+ ========
81
+ :math:`\\phi_c`
82
+ The bulk volume fraction of crowders, the parser attribute
83
+ ``phi_bulk_c``: the fraction of the volume available to crowder centers
84
+ that the crowders fill (dimensionless).
85
+ :math:`\\Delta`
86
+ The rounding step, `divisor`, of :math:`\\phi_c` in the
87
+ ``phi_bulk_c_round`` column (dimensionless).
88
+
89
+ The *property*, *relation*, *whole type*, *post-hook*, and *lineage of a
90
+ table* of the analysis records are as :mod:`polyphys.analyze.pipeline`,
91
+ "Notation", defines them.
92
+
93
+ Examples
94
+ ========
95
+ >>> from polyphys.tests.synthetic import ChainCyl
96
+ >>> spec = ProjectSpec(
97
+ ... parser=ChainCyl, group='bug', divisor=0.025,
98
+ ... crowder_attr='phi_bulk_c',
99
+ ... physical_attrs=('space', 'nmon', 'dcyl', 'dcrowd'),
100
+ ... species_sizes={'Mon': 'dmon', 'Crd': 'dcrowd'},
101
+ ... tseries_properties=('gyrTMon',))
102
+ >>> spec.geometry, spec.space_pattern, spec.hierarchy
103
+ ('cylindrical', 'N*D*ac*', 'N*')
104
+ >>> table = AnalysisTable('bug', 'whole', False, (tseries('gyrT', 'Mon'),))
105
+ >>> [prop.prop for prop in table.properties]
106
+ ['gyrTMon']
107
+ """
108
+ import math
109
+ import numbers
110
+ from dataclasses import dataclass, field
111
+ from types import MappingProxyType
112
+ from typing import (
113
+ TYPE_CHECKING, Callable, Literal, Mapping, TypeGuard, get_args)
114
+
115
+ from polyphys.manage.parser import ParserBase
116
+ from polyphys.manage.types import (
117
+ DirectionT,
118
+ EntityT,
119
+ GeometryT,
120
+ GroupT,
121
+ PrimitiveLineageT,
122
+ PropertyT,
123
+ TopologyT,
124
+ WholeRelationT,
125
+ )
126
+
127
+ if TYPE_CHECKING: # pragma: no cover - for the type checker only
128
+ from polyphys.analyze.pipeline import ArrayWholes, Derived, HookContext
129
+
130
+ __all__ = [
131
+ 'BINDER_SERIES',
132
+ 'CHAIN_SIZE_OBSERVABLES',
133
+ 'AnalysisTable',
134
+ 'BinderSpec',
135
+ 'PerWhole',
136
+ 'PostHook',
137
+ 'ProbeSpec',
138
+ 'ProjectSpec',
139
+ 'PropertySpec',
140
+ 'RecenterT',
141
+ 'edge',
142
+ 'histogram',
143
+ 'histogram_2d',
144
+ 'matrix_tseries',
145
+ 'tseries',
146
+ ]
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Project and probe records
150
+ # ---------------------------------------------------------------------------
151
+
152
+ #: A quantity of a probe that is either the same number for every whole of a
153
+ #: study or a function of the whole's parser, such as a bin size that scales
154
+ #: with the crowder diameter. :meth:`ProbeSpec.resolve` turns either into the
155
+ #: number for one whole.
156
+ #:
157
+ #: .. versionadded:: 0.7.0
158
+ PerWhole = float | Callable[[ParserBase], float]
159
+
160
+ #: How a probe moves the positions of a frame before binning them. The
161
+ #: LAMMPS dump reader of MDAnalysis 2.4 and later translates every frame so
162
+ #: that the lower box bounds sit at the origin, which undoes the ``fix
163
+ #: recenter`` of the studies' LAMMPS scripts. ``'half'`` subtracts half the
164
+ #: box lengths of the frame, which restores the LAMMPS frame exactly;
165
+ #: ``'none'`` keeps the reader's frame; a triple is subtracted as given, so
166
+ #: the research code's shift by half the parser's ``(dcyl, dcyl, lcyl)`` or
167
+ #: ``lcube`` is reproducible.
168
+ #:
169
+ #: .. versionadded:: 0.7.0
170
+ RecenterT = Literal['none', 'half'] | tuple[float, float, float]
171
+
172
+ # The directions of the density profiles in each geometry.
173
+ _DIRECTIONS: dict[GeometryT, tuple[DirectionT, ...]] = {
174
+ 'cubic': ('r',),
175
+ 'cylindrical': ('r', 'z'),
176
+ }
177
+
178
+ #: The chain-size observables that
179
+ #: :class:`~polyphys.probe.chain_size.ChainSize` measures per frame, in the
180
+ #: research code's order: each is the name of a time series without its
181
+ #: species label, so ``'gyrT'`` with the chain label ``'Mon'`` is the
182
+ #: ``gyrTMon`` artifact. ``'principalT'`` is a per-frame
183
+ #: :math:`3 \\times 3` matrix; the others are scalars.
184
+ #:
185
+ #: .. versionadded:: 0.7.0
186
+ CHAIN_SIZE_OBSERVABLES: tuple[PropertyT, ...] = (
187
+ 'transSizeT', 'fsdT', 'gyrT', 'rfloryT', 'asphericityT', 'shapeT',
188
+ 'principalT')
189
+
190
+ # The chain-size observables that are per-frame matrices, which a project's
191
+ # `tseries_properties` does not list.
192
+ _MATRIX_OBSERVABLES: frozenset[PropertyT] = frozenset({'principalT'})
193
+
194
+ #: The eight per-frame binding counts that
195
+ #: :class:`~polyphys.probe.binders.BinderStatistics` measures, in the
196
+ #: research code's order: each attribute of
197
+ #: :class:`~polyphys.analyze.clusters.FrameBinding` mapped to the stem of its
198
+ #: time series and to which label completes the name, ``'patches'`` for the
199
+ #: three patch counts and ``'binders'`` for the five binder counts. With the
200
+ #: labels ``HnsPatch`` and ``HnsCore``, ``n_binders_bridging`` is the
201
+ #: ``nBridgeTHnsCore`` artifact.
202
+ #:
203
+ #: .. versionadded:: 0.7.0
204
+ BINDER_SERIES: Mapping[str, tuple[PropertyT, str]] = MappingProxyType({
205
+ 'n_patch_monomer_contacts': ('nBoundT', 'patches'),
206
+ 'n_patches_free': ('nFreeT', 'patches'),
207
+ 'n_patches_engaged': ('nEngagedT', 'patches'),
208
+ 'n_binders_free': ('nFreeT', 'binders'),
209
+ 'n_binders_bridging': ('nBridgeT', 'binders'),
210
+ 'n_binders_dangling': ('nDangleT', 'binders'),
211
+ 'n_pairs_cis': ('nCisT', 'binders'),
212
+ 'n_pairs_trans': ('nTransT', 'binders'),
213
+ })
214
+
215
+ # The number of patches per binder that binding_statistics counts.
216
+ _TWO_PATCHES = 2
217
+
218
+
219
+ @dataclass(frozen=True, slots=True)
220
+ class BinderSpec:
221
+ """
222
+ How the binding of a study's two-patch binders to its chain is counted: the
223
+ facts :class:`~polyphys.probe.binders.BinderStatistics` needs beyond the
224
+ selections of the :class:`ProbeSpec` that holds this record.
225
+
226
+ .. versionadded:: 0.7.0
227
+ .. versionchanged:: 1.0.0
228
+ `cis_threshold` is keyword-only and has no default.
229
+
230
+ Parameters
231
+ ----------
232
+ patches : :py:obj:`~.EntityT`
233
+ The label in the probe spec's `selections` of the binders' patches,
234
+ such as ``'HnsPatch'``, which names the three patch series
235
+ ``nBoundT``, ``nFreeT``, and ``nEngagedT``. The patches of binder
236
+ :math:`k` are the ones at positions :math:`2k` and :math:`2k + 1`
237
+ of the selection, in atom-ID order.
238
+ binders : :py:obj:`~.EntityT`
239
+ The label of the binders, such as ``'HnsCore'``, which names the
240
+ five binder series ``nFreeT``, ``nBridgeT``, ``nDangleT``,
241
+ ``nCisT``, and ``nTransT``. A label only: the binder cores are not
242
+ selected.
243
+ cutoff : float
244
+ The binding threshold :math:`r_\\mathrm{thres}`, in units of the
245
+ monomer diameter: a patch and a monomer closer than it are in
246
+ contact. Finite and positive. A study constant that the spec
247
+ states, never computed from the particle sizes; see the probe
248
+ guide, "Choosing the cutoff".
249
+ inclusive : bool, default True
250
+ Whether a pair at exactly `cutoff` is a contact
251
+ (:math:`r \\le r_\\mathrm{thres}`, the thesis criterion) or not
252
+ (:math:`r < r_\\mathrm{thres}`, the research probe's).
253
+ cis_threshold : int
254
+ Keyword-only, with no default. The largest genomic distance, in
255
+ bonds, of a cis bridge; non-negative. A study choice: the H-NS
256
+ studies state 3, a bridge with at most two monomers between its
257
+ sites, where the research probe used 4; the probe guide,
258
+ "Choosing the cis threshold", gives both values and why.
259
+ patches_per_binder : int, default 2
260
+ The number of patches per binder. Only ``2`` is accepted, which
261
+ :func:`~polyphys.analyze.clusters.binding_statistics` counts; the
262
+ field is kept so that binders of another valence can be declared
263
+ once a kernel counts them.
264
+ store_distances : bool, default False
265
+ Whether the probe also keeps and saves the monomer-to-patch
266
+ distance matrix of every frame, the input of the threshold sweep
267
+ and of the binder-cluster post-step of the research code.
268
+
269
+ Attributes
270
+ ----------
271
+ series : mapping of str to :py:obj:`~.PropertyT`
272
+ Each attribute name of
273
+ :class:`~polyphys.analyze.clusters.FrameBinding` in
274
+ :data:`BINDER_SERIES` mapped to the full name of its time series,
275
+ ``<stem><patches>`` or ``<stem><binders>``, in the research code's
276
+ order.
277
+
278
+ Raises
279
+ ------
280
+ ValueError
281
+ If `patches` or `binders` is not a non-empty string or the two are
282
+ equal; if `cutoff` is not a finite positive number; if
283
+ `cis_threshold` is a negative integer or not an integer; or if
284
+ `patches_per_binder` is not ``2``.
285
+
286
+ Notes
287
+ -----
288
+ Construction and :attr:`series` are :math:`O(1)`.
289
+
290
+ Examples
291
+ --------
292
+ >>> spec = BinderSpec(patches='HnsPatch', binders='HnsCore',
293
+ ... cutoff=0.685, cis_threshold=3)
294
+ >>> spec.series['n_binders_bridging'], spec.series['n_patches_free']
295
+ ('nBridgeTHnsCore', 'nFreeTHnsPatch')
296
+ >>> BinderSpec('HnsPatch', 'HnsCore', cutoff=0.685, cis_threshold=3,
297
+ ... patches_per_binder=3)
298
+ Traceback (most recent call last):
299
+ ...
300
+ ValueError: 'patches_per_binder' must be 2, not 3: ...
301
+ """
302
+ patches: EntityT
303
+ binders: EntityT
304
+ cutoff: float
305
+ inclusive: bool = True
306
+ patches_per_binder: int = _TWO_PATCHES
307
+ store_distances: bool = False
308
+ cis_threshold: int = field(kw_only=True)
309
+
310
+ def __post_init__(self) -> None:
311
+ for name, label in (('patches', self.patches),
312
+ ('binders', self.binders)):
313
+ if not (isinstance(label, str) and label):
314
+ raise ValueError(
315
+ f"'{name}' must be a non-empty species label, not "
316
+ f"{label!r}.")
317
+ if self.patches == self.binders:
318
+ raise ValueError(
319
+ f"'patches' and 'binders' must be different labels, not "
320
+ f"both {self.patches!r}.")
321
+ if not (isinstance(self.cutoff, (int, float))
322
+ and not isinstance(self.cutoff, bool)
323
+ and math.isfinite(self.cutoff) and self.cutoff > 0):
324
+ raise ValueError(
325
+ f"'cutoff' must be a finite positive number, not "
326
+ f"{self.cutoff!r}.")
327
+ if (isinstance(self.cis_threshold, bool)
328
+ or not isinstance(self.cis_threshold, int)
329
+ or self.cis_threshold < 0):
330
+ raise ValueError(
331
+ f"'cis_threshold' must be a non-negative integer, not "
332
+ f"{self.cis_threshold!r}.")
333
+ if (isinstance(self.patches_per_binder, bool)
334
+ or self.patches_per_binder != _TWO_PATCHES):
335
+ raise ValueError(
336
+ f"'patches_per_binder' must be {_TWO_PATCHES}, not "
337
+ f"{self.patches_per_binder!r}: binding_statistics counts "
338
+ "two-patch binders only.")
339
+
340
+ @property
341
+ def series(self) -> Mapping[str, PropertyT]:
342
+ """
343
+ Each binding count's attribute name mapped to the full name of its
344
+ time series, in the research code's order.
345
+ """
346
+ labels = {'patches': self.patches, 'binders': self.binders}
347
+ return MappingProxyType({
348
+ attr: f"{stem}{labels[which]}"
349
+ for attr, (stem, which) in BINDER_SERIES.items()})
350
+
351
+
352
+ def _is_positive_number(value: object) -> TypeGuard[numbers.Real]:
353
+ """Whether `value` is a finite positive real number: a Python or NumPy
354
+ integer or float, never a bool."""
355
+ return (isinstance(value, numbers.Real) and not isinstance(value, bool)
356
+ and math.isfinite(value) and float(value) > 0)
357
+
358
+
359
+ def _is_per_whole(value: object) -> bool:
360
+ """Whether `value` is a finite positive number or a callable."""
361
+ return callable(value) or _is_positive_number(value)
362
+
363
+
364
+ def _check_resolved(result: object, subject: str) -> float:
365
+ """
366
+ Return `result`, a :py:data:`PerWhole` constant or what a callable
367
+ gave, as a float.
368
+
369
+ Raises
370
+ ------
371
+ ValueError
372
+ If `result` is not a finite positive number; the message begins
373
+ with `subject`, which names the quantity or the whole.
374
+ """
375
+ if _is_positive_number(result):
376
+ return float(result)
377
+ raise ValueError(
378
+ f"{subject} must resolve to a finite positive number, not "
379
+ f"{result!r}.")
380
+
381
+
382
+ @dataclass(frozen=True, slots=True)
383
+ class ProjectSpec:
384
+ """
385
+ The per-project facts that the analysis of a study needs and its parser
386
+ class does not hold.
387
+
388
+ Every attribute a spec names is checked at construction against the
389
+ attributes its parser sets at the ``ensemble_long`` lineage, the lineage
390
+ whose names the organizer parses for these values.
391
+
392
+ Parameters
393
+ ----------
394
+ parser : type of :class:`~polyphys.manage.parser.ParserBase`
395
+ The study's concrete parser class.
396
+ group : :py:obj:`~.GroupT`
397
+ The particle group of the polymer, such as ``'bug'`` or
398
+ ``'nucleoid'``; one of the parser's groups, and not ``'all'``.
399
+ divisor : float
400
+ The step :math:`\\Delta > 0` to which the bulk volume fraction of
401
+ crowders :math:`\\phi_c` (`crowder_attr`, dimensionless) is rounded
402
+ in the ``phi_bulk_c_round`` column of the ``space`` tables.
403
+ physical_attrs : tuple of str
404
+ The parser attributes added as columns to the ``space`` tables, in
405
+ column order.
406
+ species_sizes : mapping of :py:obj:`~.EntityT` to str
407
+ Each species label of a density profile, such as ``'Mon'`` or
408
+ ``'Crd'``, mapped to the parser attribute that holds its particle
409
+ diameter, in units of the monomer diameter. Stored read-only, in the
410
+ given order.
411
+ tseries_properties : tuple of :py:obj:`~.PropertyT`
412
+ The per-frame time series of the study, named
413
+ ``<property>T<Species>``, such as ``'gyrTMon'``.
414
+ crowder_attr : str
415
+ Keyword-only. The parser attribute that holds :math:`\\phi_c`, such
416
+ as ``'phi_bulk_c'``, which the ``space`` tables add as a column and
417
+ round into ``phi_bulk_c_round``. A study choice, so it has no
418
+ default.
419
+
420
+ .. versionadded:: 0.9.0
421
+
422
+ Attributes
423
+ ----------
424
+ name : str
425
+ The parser class name, which is the project name.
426
+ geometry : :py:obj:`~.GeometryT`
427
+ The parser's geometry.
428
+ topology : :py:obj:`~.TopologyT`
429
+ The parser's polymer topology.
430
+ directions : tuple of :py:obj:`~.DirectionT`
431
+ The directions of the density profiles: ``('r',)`` in a cube and
432
+ ``('r', 'z')`` in a cylinder.
433
+ space_pattern : str
434
+ A glob pattern that matches the study's ``space`` names: each
435
+ keyword of the parser's ``space`` pattern followed by ``*``.
436
+ space_hierarchy : str
437
+ A glob prefix of the ``space`` names: their first keyword and ``*``.
438
+ hierarchy : str
439
+ A glob prefix of the ``ensemble_long`` names: their first keyword and
440
+ ``*``.
441
+
442
+ Raises
443
+ ------
444
+ TypeError
445
+ If `parser` is not a concrete subclass of
446
+ :class:`~polyphys.manage.parser.ParserBase`.
447
+ ValueError
448
+ If `group` is ``'all'`` or not one of the parser's groups; if
449
+ `divisor` is not a finite positive number; if `crowder_attr` is not
450
+ a non-empty string; or if `physical_attrs`, `species_sizes`, or
451
+ `crowder_attr` names an attribute the parser does not set at
452
+ ``ensemble_long``.
453
+
454
+ Examples
455
+ --------
456
+ >>> from polyphys.tests.synthetic import ChainCyl, RingCubBinders
457
+ >>> spec = ProjectSpec(
458
+ ... parser=RingCubBinders, group='nucleoid', divisor=0.01,
459
+ ... crowder_attr='phi_bulk_c',
460
+ ... physical_attrs=('space', 'nmon', 'nhns', 'dcrowd'),
461
+ ... species_sizes={'Mon': 'dmon', 'Crd': 'dcrowd', 'Hns': 'dhns'},
462
+ ... tseries_properties=('gyrTMon',))
463
+ >>> spec.name, spec.geometry, spec.directions
464
+ ('RingCubBinders', 'cubic', ('r',))
465
+ >>> spec.space_pattern, spec.space_hierarchy, spec.hierarchy
466
+ ('N*nh*ac*', 'N*', 'N*')
467
+ >>> spec.species_sizes['Hns']
468
+ 'dhns'
469
+
470
+ A spec that names an attribute the parser does not set fails at once:
471
+
472
+ >>> ProjectSpec(ChainCyl, 'bug', 0.025, ('nhns',), {}, (),
473
+ ... crowder_attr='phi_bulk_c')
474
+ Traceback (most recent call last):
475
+ ...
476
+ ValueError: ChainCyl does not set ['nhns'] at 'ensemble_long'.
477
+
478
+ Notes
479
+ -----
480
+ Construction costs :math:`O(a + s + n)` for :math:`a` physical
481
+ attributes, :math:`s` species, and the :math:`n` names that
482
+ :meth:`~polyphys.manage.parser.ParserBase.attributes_at` collects; every
483
+ derived attribute is :math:`O(1)`.
484
+ """
485
+ parser: type[ParserBase]
486
+ group: GroupT
487
+ divisor: float
488
+ physical_attrs: tuple[str, ...]
489
+ species_sizes: Mapping[EntityT, str] = field(hash=False)
490
+ tseries_properties: tuple[PropertyT, ...]
491
+ crowder_attr: str = field(kw_only=True)
492
+
493
+ def __post_init__(self) -> None:
494
+ # Copy the containers so that a caller's list or dict cannot change
495
+ # the spec afterwards.
496
+ object.__setattr__(self, 'physical_attrs', tuple(self.physical_attrs))
497
+ object.__setattr__(self, 'tseries_properties',
498
+ tuple(self.tseries_properties))
499
+ object.__setattr__(self, 'species_sizes',
500
+ MappingProxyType(dict(self.species_sizes)))
501
+ parser = self.parser
502
+ if not (isinstance(parser, type) and issubclass(parser, ParserBase)
503
+ and parser._patterns is not None):
504
+ raise TypeError(
505
+ f"'parser' must be a concrete ParserBase subclass, not "
506
+ f"{parser!r}.")
507
+ name = parser.__name__
508
+ if self.group == 'all' or self.group not in parser._groups:
509
+ raise ValueError(
510
+ f"{name}: 'group' must be the polymer group, one of "
511
+ f"{[g for g in parser._groups if g != 'all']}, not "
512
+ f"{self.group!r}.")
513
+ if not (math.isfinite(self.divisor) and self.divisor > 0):
514
+ raise ValueError(
515
+ f"{name}: 'divisor' must be a finite positive number, not "
516
+ f"{self.divisor!r}.")
517
+ if not (isinstance(self.crowder_attr, str) and self.crowder_attr):
518
+ raise ValueError(
519
+ f"{name}: 'crowder_attr' must be a non-empty attribute name, "
520
+ f"not {self.crowder_attr!r}.")
521
+ available = parser.attributes_at('ensemble_long')
522
+ named = [*self.physical_attrs, *self.species_sizes.values(),
523
+ self.crowder_attr]
524
+ missing = sorted({attr for attr in named if attr not in available})
525
+ if missing:
526
+ raise ValueError(
527
+ f"{name} does not set {missing} at 'ensemble_long'.")
528
+
529
+ @property
530
+ def name(self) -> str:
531
+ """
532
+ The parser class name, which is the project name.
533
+ """
534
+ return self.parser.__name__
535
+
536
+ @property
537
+ def geometry(self) -> GeometryT:
538
+ """
539
+ The parser's geometry.
540
+ """
541
+ return self.parser._geometry
542
+
543
+ @property
544
+ def topology(self) -> TopologyT:
545
+ """
546
+ The parser's polymer topology.
547
+ """
548
+ return self.parser._topology
549
+
550
+ @property
551
+ def directions(self) -> tuple[DirectionT, ...]:
552
+ """
553
+ The directions of the density profiles in the parser's geometry.
554
+ """
555
+ return _DIRECTIONS[self.geometry]
556
+
557
+ @property
558
+ def space_pattern(self) -> str:
559
+ """
560
+ A glob pattern that matches the study's ``space`` names.
561
+ """
562
+ return ''.join(f"{keyword}*"
563
+ for keyword in self.parser.pattern('space'))
564
+
565
+ @property
566
+ def space_hierarchy(self) -> str:
567
+ """
568
+ A glob prefix of the ``space`` names: their first keyword and ``*``.
569
+ """
570
+ return f"{self.parser.pattern('space')[0]}*"
571
+
572
+ @property
573
+ def hierarchy(self) -> str:
574
+ """
575
+ A glob prefix of the ``ensemble_long`` names: their first keyword
576
+ and ``*``.
577
+ """
578
+ return f"{self.parser.pattern('ensemble_long')[0]}*"
579
+
580
+
581
+ @dataclass(frozen=True, slots=True)
582
+ class ProbeSpec:
583
+ """
584
+ How the trajectories of one study are probed: the facts that
585
+ :mod:`polyphys.probe` needs beyond the study's :class:`ProjectSpec`.
586
+
587
+ A probe spec is built beside the project spec, in the study's own
588
+ folder, and reads the parser class and the geometry from it rather than
589
+ storing them twice. Every observable it names is checked at construction
590
+ against :data:`CHAIN_SIZE_OBSERVABLES` and, for the scalar ones, against
591
+ the project's ``tseries_properties``.
592
+
593
+ .. versionadded:: 0.7.0
594
+
595
+ Parameters
596
+ ----------
597
+ project : ProjectSpec
598
+ The study's project spec.
599
+ selections : mapping of :py:obj:`~.EntityT` to str
600
+ Each species label, such as ``'Mon'`` or ``'Foci'``, mapped to the
601
+ MDAnalysis selection string that picks its atoms, such as
602
+ ``'resid 1'`` or ``'type 2'``. Stored read-only, in the given order.
603
+ chain : :py:obj:`~.EntityT`
604
+ The label in `selections` of the polymer chain, whose chain-size
605
+ time series are named ``<observable><chain>``.
606
+ observables : tuple of :py:obj:`~.PropertyT`
607
+ The chain-size observables measured per frame, each a name in
608
+ :data:`CHAIN_SIZE_OBSERVABLES`, in the order the probe stores them.
609
+ unwrap : bool
610
+ Whether the chain is unwrapped across the periodic boundaries before
611
+ its gyration tensor is formed for ``asphericityT``, the `unwrap`
612
+ argument of ``AtomGroup.asphericity``: ``False`` for a cylinder,
613
+ which is periodic along its axis only, and ``True`` for a periodic
614
+ cube. Every other observable, ``shapeT`` included, reads the
615
+ positions as the dump gives them; see
616
+ :class:`polyphys.probe.chain_size.ChainSize`.
617
+ bin_sizes : mapping of :py:obj:`~.DirectionT` to :py:data:`PerWhole`, \
618
+ default {}
619
+ The bin size of the histogram along each direction: in units of the
620
+ monomer diameter for a spatial direction, such as ``'r'`` or
621
+ ``'z'``, and in radians for the angle ``'theta'``. Each is a finite
622
+ positive number, or a function of a whole's parser returning one,
623
+ for a size that scales with a whole's particle diameters. Stored
624
+ read-only. Left empty by a study whose histograms are not probed.
625
+ histogram_species : tuple of :py:obj:`~.EntityT`, default ()
626
+ The labels in `selections` of the species whose spatial histograms
627
+ :class:`~polyphys.probe.histograms.SpatialHistograms` collects, each
628
+ once. Left empty by a study whose histograms are not probed; otherwise
629
+ `bin_sizes` and `block_frames` are required.
630
+ block_frames : int or None, default None
631
+ The block length :math:`b` of the two-dimensional maps: the number of
632
+ consecutive frames whose maps are averaged into one stored map, a
633
+ positive integer. Required with `histogram_species`.
634
+ recenter : :py:data:`RecenterT`, default 'half'
635
+ How the positions of a frame are moved before they are binned:
636
+ ``'half'`` subtracts half the frame's box lengths, which restores the
637
+ LAMMPS frame the dump reader translated; ``'none'`` keeps the reader's
638
+ frame; a triple ``(dx, dy, dz)`` is subtracted as given. Stored as a
639
+ tuple of floats when given as a triple.
640
+ pair_species : :py:obj:`~.EntityT` or None, default None
641
+ The label in `selections` of the species whose pair distances and
642
+ contacts :class:`~polyphys.probe.pairs.PairDistances` collects, such as
643
+ ``'Foci'``; ``None`` when the study has no such species. Requires
644
+ `contact_cutoff`.
645
+ contact_cutoff : :py:data:`PerWhole` or None, default None
646
+ The center-to-center distance, in units of the monomer diameter, at
647
+ or below which two particles of `pair_species` are in direct
648
+ contact: a finite positive number or a function of a whole's parser,
649
+ such as the sum of the large-monomer and crowder diameters.
650
+ minimum_image : bool, default True
651
+ Whether the pair distances use the minimum-image convention along
652
+ the periodic directions of the geometry: the axis of a cylinder, all
653
+ three directions of a cube.
654
+ binders : BinderSpec, optional
655
+ How the binding of the study's two-patch binders is counted, for a
656
+ study with binders such as the H-NS ones; ``None`` for a study
657
+ without. Its `patches` must be a label in `selections` other than
658
+ `chain`, and each of its eight time series must be one of the
659
+ project's ``tseries_properties``.
660
+
661
+ Attributes
662
+ ----------
663
+ name : str
664
+ The project name, from the project spec.
665
+ parser : type of :class:`~polyphys.manage.parser.ParserBase`
666
+ The study's parser class, from the project spec.
667
+ geometry : :py:obj:`~.GeometryT`
668
+ The study's geometry, from the project spec.
669
+
670
+ Raises
671
+ ------
672
+ TypeError
673
+ If `project` is not a :class:`ProjectSpec`, or if `binders` is
674
+ neither ``None`` nor a :class:`BinderSpec`.
675
+ ValueError
676
+ If `selections` is empty or has a label or a selection that is not
677
+ a non-empty string; if `chain` is not a label in `selections`; if
678
+ `observables` is empty, repeats a name, or names an observable that
679
+ is not in :data:`CHAIN_SIZE_OBSERVABLES`; if a scalar observable
680
+ with the chain label is not one of the project's
681
+ ``tseries_properties``; if a bin size is keyed by an unknown
682
+ direction or is neither a finite positive number nor callable; if a
683
+ histogram species is not a label in `selections` or repeats, or is
684
+ given without `bin_sizes` or `block_frames`; if `block_frames` is
685
+ not a positive integer; if `recenter` is neither ``'none'``,
686
+ ``'half'``, nor a triple of finite numbers; if `pair_species` is not
687
+ a label in `selections` or is given without `contact_cutoff`; if
688
+ `contact_cutoff` is neither a finite positive number nor callable;
689
+ or if the patches of `binders` are not a label in `selections`
690
+ other than `chain`, or one of its time series is not among the
691
+ project's ``tseries_properties``.
692
+
693
+ Examples
694
+ --------
695
+ >>> from polyphys.tests.synthetic import ChainCyl
696
+ >>> project = ProjectSpec(
697
+ ... parser=ChainCyl, group='bug', divisor=0.025,
698
+ ... crowder_attr='phi_bulk_c',
699
+ ... physical_attrs=('space', 'nmon', 'dcyl', 'dcrowd'),
700
+ ... species_sizes={'Mon': 'dmon', 'Crd': 'dcrowd'},
701
+ ... tseries_properties=('fsdTMon', 'gyrTMon', 'rfloryTMon'))
702
+ >>> probe = ProbeSpec(
703
+ ... project=project, selections={'Mon': 'resid 1'}, chain='Mon',
704
+ ... observables=('rfloryT', 'fsdT', 'gyrT'), unwrap=False)
705
+ >>> probe.name, probe.geometry, probe.selections['Mon']
706
+ ('ChainCyl', 'cylindrical', 'resid 1')
707
+
708
+ An observable the project's time series do not list fails at once:
709
+
710
+ >>> ProbeSpec(project, {'Mon': 'resid 1'}, 'Mon', ('shapeT',), False)
711
+ Traceback (most recent call last):
712
+ ...
713
+ ValueError: ChainCyl: 'observables' names ['shapeTMon'], which ...
714
+
715
+ A study whose histograms are probed names the species, the bin sizes,
716
+ which may depend on the whole, and the block length; the bin sizes are
717
+ resolved for one whole from its parser:
718
+
719
+ >>> probe = ProbeSpec(
720
+ ... project=project, selections={'Mon': 'resid 1', 'Crd': 'resid 0'},
721
+ ... chain='Mon', observables=('gyrT',), unwrap=False,
722
+ ... bin_sizes={'r': lambda info: 0.1 * min(info.dmon, info.dcrowd),
723
+ ... 'z': 0.5},
724
+ ... histogram_species=('Mon', 'Crd'), block_frames=100)
725
+ >>> whole = ChainCyl(
726
+ ... 'N200r10.5lz50ac0.5nc100dt0.005bdump1adump1ens1',
727
+ ... 'whole', 'all')
728
+ >>> probe.resolve(probe.bin_sizes['r'], whole), probe.recenter
729
+ (0.05, 'half')
730
+
731
+ A study with binders adds a :class:`BinderSpec`:
732
+
733
+ >>> hns = ProjectSpec(
734
+ ... parser=ChainCyl, group='bug', divisor=0.025,
735
+ ... crowder_attr='phi_bulk_c',
736
+ ... physical_attrs=('space', 'nmon', 'dcyl', 'dcrowd'),
737
+ ... species_sizes={'Mon': 'dmon', 'Crd': 'dcrowd'},
738
+ ... tseries_properties=(
739
+ ... 'gyrTMon', 'nBoundTHnsPatch', 'nFreeTHnsPatch',
740
+ ... 'nEngagedTHnsPatch', 'nFreeTHnsCore', 'nBridgeTHnsCore',
741
+ ... 'nDangleTHnsCore', 'nCisTHnsCore', 'nTransTHnsCore'))
742
+ >>> probe = ProbeSpec(
743
+ ... project=hns, selections={'Mon': 'resid 1', 'HnsPatch': 'type 2'},
744
+ ... chain='Mon', observables=('gyrT',), unwrap=False,
745
+ ... binders=BinderSpec('HnsPatch', 'HnsCore', cutoff=0.685,
746
+ ... cis_threshold=3))
747
+ >>> probe.binders.series['n_pairs_cis']
748
+ 'nCisTHnsCore'
749
+
750
+ Notes
751
+ -----
752
+ Construction costs :math:`O(s + o + b + h)` for :math:`s` selections,
753
+ :math:`o` observables, :math:`b` bin sizes, and :math:`h` histogram
754
+ species; every derived attribute is :math:`O(1)`.
755
+ """
756
+ project: ProjectSpec
757
+ selections: Mapping[EntityT, str] = field(hash=False)
758
+ chain: EntityT
759
+ observables: tuple[PropertyT, ...]
760
+ unwrap: bool
761
+ bin_sizes: Mapping[DirectionT, PerWhole] = field(
762
+ default_factory=dict, hash=False)
763
+ histogram_species: tuple[EntityT, ...] = ()
764
+ block_frames: int | None = None
765
+ recenter: RecenterT = 'half'
766
+ pair_species: EntityT | None = None
767
+ contact_cutoff: PerWhole | None = None
768
+ minimum_image: bool = True
769
+ binders: BinderSpec | None = None
770
+
771
+ def __post_init__(self) -> None:
772
+ if not isinstance(self.project, ProjectSpec):
773
+ raise TypeError(
774
+ f"'project' must be a ProjectSpec, not {self.project!r}.")
775
+ if self.binders is not None \
776
+ and not isinstance(self.binders, BinderSpec):
777
+ raise TypeError(
778
+ f"'binders' must be a BinderSpec or None, not "
779
+ f"{self.binders!r}.")
780
+ name = self.project.name
781
+ # Copy the containers so that a caller's dict or list cannot change
782
+ # the spec afterwards.
783
+ object.__setattr__(self, 'selections',
784
+ MappingProxyType(dict(self.selections)))
785
+ object.__setattr__(self, 'observables', tuple(self.observables))
786
+ object.__setattr__(self, 'bin_sizes',
787
+ MappingProxyType(dict(self.bin_sizes)))
788
+ if not self.selections or any(
789
+ not (isinstance(key, str) and key
790
+ and isinstance(value, str) and value)
791
+ for key, value in self.selections.items()):
792
+ raise ValueError(
793
+ f"{name}: 'selections' must map each species label to a "
794
+ f"non-empty selection string, not {dict(self.selections)!r}.")
795
+ if self.chain not in self.selections:
796
+ raise ValueError(
797
+ f"{name}: 'chain' must be a label in 'selections', one of "
798
+ f"{list(self.selections)}, not {self.chain!r}.")
799
+ if not self.observables:
800
+ raise ValueError(f"{name}: 'observables' must not be empty.")
801
+ unknown = [obs for obs in self.observables
802
+ if obs not in CHAIN_SIZE_OBSERVABLES]
803
+ if unknown:
804
+ raise ValueError(
805
+ f"{name}: 'observables' names {unknown}, which are not in "
806
+ f"{list(CHAIN_SIZE_OBSERVABLES)}.")
807
+ if len(set(self.observables)) != len(self.observables):
808
+ raise ValueError(
809
+ f"{name}: 'observables' repeats a name: "
810
+ f"{list(self.observables)}.")
811
+ unlisted = [f"{obs}{self.chain}" for obs in self.observables
812
+ if obs not in _MATRIX_OBSERVABLES
813
+ and f"{obs}{self.chain}"
814
+ not in self.project.tseries_properties]
815
+ if unlisted:
816
+ raise ValueError(
817
+ f"{name}: 'observables' names {unlisted}, which are not "
818
+ f"among the project's 'tseries_properties' "
819
+ f"{list(self.project.tseries_properties)}.")
820
+ directions = get_args(DirectionT)
821
+ for direction, size in self.bin_sizes.items():
822
+ if direction not in directions:
823
+ raise ValueError(
824
+ f"{name}: 'bin_sizes' has the direction {direction!r}; "
825
+ f"each must be one of {list(directions)}.")
826
+ if not _is_per_whole(size):
827
+ raise ValueError(
828
+ f"{name}: the bin size along {direction!r} must be a "
829
+ f"finite positive number or a callable, not {size!r}.")
830
+ object.__setattr__(self, 'histogram_species',
831
+ tuple(self.histogram_species))
832
+ unselected = [species for species in self.histogram_species
833
+ if species not in self.selections]
834
+ if unselected:
835
+ raise ValueError(
836
+ f"{name}: 'histogram_species' names {unselected}, which are "
837
+ f"not labels in 'selections' {list(self.selections)}.")
838
+ if len(set(self.histogram_species)) != len(self.histogram_species):
839
+ raise ValueError(
840
+ f"{name}: 'histogram_species' repeats a label: "
841
+ f"{list(self.histogram_species)}.")
842
+ if self.histogram_species and not self.bin_sizes:
843
+ raise ValueError(
844
+ f"{name}: 'histogram_species' needs 'bin_sizes'.")
845
+ if self.histogram_species and self.block_frames is None:
846
+ raise ValueError(
847
+ f"{name}: 'histogram_species' needs 'block_frames'.")
848
+ if self.block_frames is not None and not (
849
+ isinstance(self.block_frames, int)
850
+ and not isinstance(self.block_frames, bool)
851
+ and self.block_frames > 0):
852
+ raise ValueError(
853
+ f"{name}: 'block_frames' must be a positive integer, not "
854
+ f"{self.block_frames!r}.")
855
+ if self.recenter not in ('none', 'half'):
856
+ shift = self.recenter
857
+ if not (isinstance(shift, (tuple, list)) and len(shift) == 3
858
+ and all(isinstance(value, (int, float))
859
+ and not isinstance(value, bool)
860
+ and math.isfinite(value) for value in shift)):
861
+ raise ValueError(
862
+ f"{name}: 'recenter' must be 'none', 'half', or a triple "
863
+ f"of finite numbers, not {shift!r}.")
864
+ object.__setattr__(self, 'recenter',
865
+ tuple(float(value) for value in shift))
866
+ if self.pair_species is not None:
867
+ if self.pair_species not in self.selections:
868
+ raise ValueError(
869
+ f"{name}: 'pair_species' must be a label in "
870
+ f"'selections', one of {list(self.selections)}, not "
871
+ f"{self.pair_species!r}.")
872
+ if self.contact_cutoff is None:
873
+ raise ValueError(
874
+ f"{name}: 'pair_species' needs 'contact_cutoff'.")
875
+ if self.contact_cutoff is not None and not _is_per_whole(
876
+ self.contact_cutoff):
877
+ raise ValueError(
878
+ f"{name}: 'contact_cutoff' must be a finite positive number "
879
+ f"or a callable, not {self.contact_cutoff!r}.")
880
+
881
+ if self.binders is not None:
882
+ if self.binders.patches not in self.selections \
883
+ or self.binders.patches == self.chain:
884
+ raise ValueError(
885
+ f"{name}: the patches of 'binders' must be a label in "
886
+ f"'selections' other than the chain {self.chain!r}, "
887
+ f"not {self.binders.patches!r}.")
888
+ unlisted = [
889
+ series for series in self.binders.series.values()
890
+ if series not in self.project.tseries_properties]
891
+ if unlisted:
892
+ raise ValueError(
893
+ f"{name}: 'binders' names the series {unlisted}, which "
894
+ f"are not among the project's 'tseries_properties' "
895
+ f"{list(self.project.tseries_properties)}.")
896
+
897
+ @staticmethod
898
+ def resolve(value: PerWhole, parser: ParserBase) -> float:
899
+ """
900
+ The number a :py:data:`PerWhole` quantity takes for one whole.
901
+
902
+ Parameters
903
+ ----------
904
+ value : :py:data:`PerWhole`
905
+ A number, returned as a float, or a function of `parser`.
906
+ parser : ParserBase
907
+ The whole's parser, passed to a callable `value`.
908
+
909
+ Returns
910
+ -------
911
+ float
912
+ The number for this whole.
913
+
914
+ Raises
915
+ ------
916
+ ValueError
917
+ If `value`, or what a callable `value` returns, is anything but
918
+ a finite positive number: a Python or NumPy integer or float,
919
+ not a bool.
920
+
921
+ Examples
922
+ --------
923
+ >>> from polyphys.tests.synthetic import ChainCyl
924
+ >>> whole = ChainCyl(
925
+ ... 'N200r10.5lz50ac0.5nc100dt0.005bdump1adump1ens1',
926
+ ... 'whole', 'all')
927
+ >>> ProbeSpec.resolve(0.5, whole)
928
+ 0.5
929
+ >>> ProbeSpec.resolve(lambda info: info.dmon + info.dcrowd, whole)
930
+ 1.5
931
+ >>> ProbeSpec.resolve(-1.0, whole)
932
+ Traceback (most recent call last):
933
+ ...
934
+ ValueError: A per-whole quantity of N200r10.5lz50ac0.5nc100dt0.005b...
935
+
936
+ Notes
937
+ -----
938
+ :math:`O(1)` beyond the cost of the callable.
939
+
940
+ .. versionadded:: 0.7.0
941
+
942
+ .. versionchanged:: 1.0.0
943
+ A callable may return a NumPy integer or float, such as
944
+ ``numpy.float32``, where only a Python number or
945
+ ``numpy.float64`` was accepted. A constant `value` is checked
946
+ with the same rule, where it was returned as ``float(value)``.
947
+ """
948
+ result = value(parser) if callable(value) else value
949
+ return _check_resolved(
950
+ result, f"A per-whole quantity of {parser.name}")
951
+
952
+ @property
953
+ def name(self) -> str:
954
+ """
955
+ The project name, from the project spec.
956
+ """
957
+ return self.project.name
958
+
959
+ @property
960
+ def parser(self) -> type[ParserBase]:
961
+ """
962
+ The study's parser class, from the project spec.
963
+ """
964
+ return self.project.parser
965
+
966
+ @property
967
+ def geometry(self) -> GeometryT:
968
+ """
969
+ The study's geometry, from the project spec.
970
+ """
971
+ return self.project.geometry
972
+
973
+ # ---------------------------------------------------------------------------
974
+ # Analysis records
975
+ # ---------------------------------------------------------------------------
976
+
977
+
978
+ #: A post-hook ``(wholes, context) -> tuple[Derived, ...]``: see
979
+ #: :mod:`polyphys.analyze.pipeline`, "Notation", and its hooks. A hook may
980
+ #: also declare the names of the artifacts it returns with a method
981
+ #: ``outputs(prop) -> tuple[str, ...]``, which :class:`AnalysisTable` reads
982
+ #: to reject a repeated name when the table is built; every hook of
983
+ #: :mod:`polyphys.analyze.pipeline` declares them.
984
+ #:
985
+ #: .. versionadded:: 0.6.0
986
+ #:
987
+ #: .. versionchanged:: 0.9.0
988
+ #: The optional ``outputs`` method.
989
+ PostHook = Callable[['ArrayWholes', 'HookContext'], tuple['Derived', ...]]
990
+
991
+
992
+ @dataclass(frozen=True)
993
+ class PropertySpec:
994
+ """
995
+ One probe artifact of a particle group, and how it reduces.
996
+
997
+ Attributes
998
+ ----------
999
+ name : str
1000
+ The property's name without its species, such as ``'gyrT'`` or
1001
+ ``'rHist'``.
1002
+ species : str
1003
+ The species the property is measured on, such as ``'Mon'``; the
1004
+ empty string for a property without one, such as an edge file.
1005
+ relation : {'tseries', 'histogram', 'bin_edge'}
1006
+ How the segments of a whole combine; see
1007
+ :mod:`polyphys.analyze.pipeline`, "Notation".
1008
+ whole_type : {'vector', 'matrix', 'bin_edge'}
1009
+ The shape of one whole as loaded; see
1010
+ :mod:`polyphys.analyze.pipeline`, "Notation".
1011
+ edge_prop : str, optional
1012
+ The property of the file that holds this histogram's bin edges, such
1013
+ as ``'rEdgeMon'`` for ``'rHistMon'``. Only for a ``'vector'``
1014
+ ``'histogram'``.
1015
+ post_hook : callable, optional
1016
+ A post-hook ``(wholes, context) -> tuple[Derived, ...]``; see
1017
+ :mod:`polyphys.analyze.pipeline`, "Notation". ``None`` passes the
1018
+ wholes through under `prop`.
1019
+
1020
+ Raises
1021
+ ------
1022
+ ValueError
1023
+ If `name` is empty; if `relation` or `whole_type` is unknown; if
1024
+ exactly one of them is ``'bin_edge'``; or if `edge_prop` is given
1025
+ for anything but a ``'vector'`` ``'histogram'``.
1026
+
1027
+ Notes
1028
+ -----
1029
+ The named constructors :func:`tseries`, :func:`histogram`,
1030
+ :func:`histogram_2d`, :func:`edge`, and :func:`matrix_tseries` build the
1031
+ kinds the studies use, so a table reads as data.
1032
+
1033
+ Examples
1034
+ --------
1035
+ >>> spec = PropertySpec('rHist', 'Mon', 'histogram', 'vector',
1036
+ ... edge_prop='rEdgeMon')
1037
+ >>> spec.prop, spec.has_edges
1038
+ ('rHistMon', True)
1039
+
1040
+ An edge file is a ``'bin_edge'`` in both its relation and its shape:
1041
+
1042
+ >>> PropertySpec('xEdge', '', 'bin_edge', 'vector')
1043
+ Traceback (most recent call last):
1044
+ ...
1045
+ ValueError: 'xEdge': relation 'bin_edge' and whole_type 'vector' ...
1046
+
1047
+ .. versionadded:: 0.6.0
1048
+ """
1049
+
1050
+ name: str
1051
+ species: EntityT
1052
+ relation: WholeRelationT
1053
+ whole_type: Literal['vector', 'matrix', 'bin_edge']
1054
+ edge_prop: PropertyT | None = None
1055
+ post_hook: 'PostHook | None' = None
1056
+
1057
+ def __post_init__(self) -> None:
1058
+ if not self.name:
1059
+ raise ValueError("A property's name must not be empty.")
1060
+ if self.relation not in ('tseries', 'histogram', 'bin_edge'):
1061
+ raise ValueError(
1062
+ f"'{self.prop}': relation must be 'tseries', 'histogram', or "
1063
+ f"'bin_edge', not '{self.relation}'."
1064
+ )
1065
+ if self.whole_type not in ('vector', 'matrix', 'bin_edge'):
1066
+ raise ValueError(
1067
+ f"'{self.prop}': whole_type must be 'vector', 'matrix', or "
1068
+ f"'bin_edge', not '{self.whole_type}'."
1069
+ )
1070
+ if (self.relation == 'bin_edge') != (self.whole_type == 'bin_edge'):
1071
+ raise ValueError(
1072
+ f"'{self.prop}': relation '{self.relation}' and whole_type "
1073
+ f"'{self.whole_type}' do not match; an edge file is "
1074
+ "'bin_edge' in both."
1075
+ )
1076
+ if self.edge_prop is not None and not (
1077
+ self.relation == 'histogram' and self.whole_type == 'vector'
1078
+ ):
1079
+ raise ValueError(
1080
+ f"'{self.prop}': edge_prop is only valid for a 'vector' "
1081
+ f"'histogram', not a '{self.whole_type}' '{self.relation}'."
1082
+ )
1083
+
1084
+ @property
1085
+ def prop(self) -> PropertyT:
1086
+ """
1087
+ The property as it appears in file names, `name` then `species`.
1088
+ """
1089
+ return self.name + self.species
1090
+
1091
+ @property
1092
+ def has_edges(self) -> bool:
1093
+ """
1094
+ Whether the property has a bin-edge file.
1095
+ """
1096
+ return self.edge_prop is not None
1097
+
1098
+
1099
+ @dataclass(frozen=True)
1100
+ class AnalysisTable:
1101
+ """
1102
+ The analysis of one particle group of a study, as data.
1103
+
1104
+ Attributes
1105
+ ----------
1106
+ group : str
1107
+ The particle group whose artifacts the table reduces.
1108
+ lineage : {'segment', 'whole'}
1109
+ The lineage of the group's probe artifacts; see
1110
+ :mod:`polyphys.analyze.pipeline`, "Notation".
1111
+ stamps : bool
1112
+ Whether the group's stamp files are combined by
1113
+ :func:`~polyphys.analyze.pipeline.reduce_stamps`.
1114
+ properties : tuple of PropertySpec
1115
+ The artifacts to reduce, each listed once.
1116
+
1117
+ Raises
1118
+ ------
1119
+ ValueError
1120
+ If `lineage` is unknown; if two properties name the same artifact;
1121
+ or if two outputs share a name, among the property of each entry
1122
+ without a hook and the names each hook declares with its
1123
+ ``outputs`` method, so that no output overwrites another when
1124
+ :func:`~polyphys.analyze.pipeline.reduce_space` runs. A hook that
1125
+ declares nothing is checked when it runs. As a hook's
1126
+ ``outputs`` raises, such as for a hook given a property it cannot
1127
+ reduce.
1128
+
1129
+ Examples
1130
+ --------
1131
+ >>> table = AnalysisTable('bug', 'whole', True, (tseries('gyrT', 'Mon'),))
1132
+ >>> table.lineage, len(table.properties)
1133
+ ('whole', 1)
1134
+
1135
+ Listing an artifact twice is an error, so a table cannot reduce one file
1136
+ two ways by accident:
1137
+
1138
+ >>> AnalysisTable('bug', 'whole', True,
1139
+ ... (tseries('gyrT', 'Mon'), tseries('gyrT', 'Mon')))
1140
+ Traceback (most recent call last):
1141
+ ...
1142
+ ValueError: Properties listed more than once: ['gyrTMon'].
1143
+
1144
+ So is a hook output that another entry also produces:
1145
+
1146
+ >>> from polyphys.analyze.pipeline import TimeAverage
1147
+ >>> AnalysisTable('bug', 'whole', True, (
1148
+ ... tseries('gyrT', 'Mon', post_hook=TimeAverage('fsdT')),
1149
+ ... tseries('fsdT', 'Mon')))
1150
+ Traceback (most recent call last):
1151
+ ...
1152
+ ValueError: Outputs named more than once: ['fsdTMon'].
1153
+
1154
+ Notes
1155
+ -----
1156
+ Construction is :math:`O(p + o)` for :math:`p` properties and the
1157
+ :math:`o` outputs their hooks declare.
1158
+
1159
+ .. versionadded:: 0.6.0
1160
+
1161
+ .. versionchanged:: 0.9.0
1162
+ Repeated output names are rejected.
1163
+ """
1164
+
1165
+ group: GroupT
1166
+ lineage: PrimitiveLineageT
1167
+ stamps: bool
1168
+ properties: tuple[PropertySpec, ...]
1169
+
1170
+ def __post_init__(self) -> None:
1171
+ if self.lineage not in ('segment', 'whole'):
1172
+ raise ValueError(
1173
+ f"lineage must be 'segment' or 'whole', not "
1174
+ f"'{self.lineage}'."
1175
+ )
1176
+ object.__setattr__(self, 'properties', tuple(self.properties))
1177
+ seen: set[str] = set()
1178
+ repeated: list[str] = []
1179
+ for prop in self.properties:
1180
+ if prop.prop in seen and prop.prop not in repeated:
1181
+ repeated.append(prop.prop)
1182
+ seen.add(prop.prop)
1183
+ if repeated:
1184
+ raise ValueError(f"Properties listed more than once: {repeated}.")
1185
+ outputs: set[PropertyT] = set()
1186
+ repeated_outputs: list[PropertyT] = []
1187
+ for prop in self.properties:
1188
+ for name in _declared_outputs(prop):
1189
+ if name in outputs and name not in repeated_outputs:
1190
+ repeated_outputs.append(name)
1191
+ outputs.add(name)
1192
+ if repeated_outputs:
1193
+ raise ValueError(
1194
+ f"Outputs named more than once: {repeated_outputs}.")
1195
+
1196
+
1197
+ def _declared_outputs(prop: PropertySpec) -> tuple[PropertyT, ...]:
1198
+ """
1199
+ The names of the artifacts that `prop` yields: its own without a hook,
1200
+ the hook's declared ``outputs``, or none for a hook that declares none.
1201
+ """
1202
+ if prop.post_hook is None:
1203
+ return (prop.prop,)
1204
+ declare = getattr(prop.post_hook, 'outputs', None)
1205
+ if declare is None:
1206
+ return ()
1207
+ return tuple(declare(prop))
1208
+
1209
+
1210
+ def tseries(
1211
+ name: str, species: EntityT, post_hook: 'PostHook | None' = None
1212
+ ) -> PropertySpec:
1213
+ """
1214
+ A scalar time series: one value per frame, concatenated over segments.
1215
+
1216
+ Parameters
1217
+ ----------
1218
+ name : str
1219
+ The property's name, such as ``'gyrT'``.
1220
+ species : str
1221
+ The species, such as ``'Mon'``.
1222
+ post_hook : callable, optional
1223
+ A post-hook; see :class:`PropertySpec`.
1224
+
1225
+ Returns
1226
+ -------
1227
+ PropertySpec
1228
+ A ``'tseries'`` ``'vector'`` property.
1229
+
1230
+ Examples
1231
+ --------
1232
+ >>> tseries('gyrT', 'Mon').prop
1233
+ 'gyrTMon'
1234
+
1235
+ .. versionadded:: 0.6.0
1236
+ """
1237
+ return PropertySpec(name, species, 'tseries', 'vector',
1238
+ post_hook=post_hook)
1239
+
1240
+
1241
+ def histogram(
1242
+ direction: DirectionT, species: EntityT,
1243
+ post_hook: 'PostHook | None' = None,
1244
+ ) -> PropertySpec:
1245
+ """
1246
+ A one-dimensional histogram with its bin-edge file.
1247
+
1248
+ Parameters
1249
+ ----------
1250
+ direction : str
1251
+ The direction, such as ``'r'``, ``'z'``, or ``'theta'``: the property
1252
+ is ``<direction>Hist<species>`` and its edges
1253
+ ``<direction>Edge<species>``.
1254
+ species : str
1255
+ The species, such as ``'Crd'``.
1256
+ post_hook : callable, optional
1257
+ A post-hook; see :class:`PropertySpec`.
1258
+
1259
+ Returns
1260
+ -------
1261
+ PropertySpec
1262
+ A ``'histogram'`` ``'vector'`` property with `edge_prop` set.
1263
+
1264
+ Examples
1265
+ --------
1266
+ >>> spec = histogram('r', 'Crd')
1267
+ >>> spec.prop, spec.edge_prop
1268
+ ('rHistCrd', 'rEdgeCrd')
1269
+
1270
+ .. versionadded:: 0.6.0
1271
+ """
1272
+ return PropertySpec(f'{direction}Hist', species, 'histogram', 'vector',
1273
+ edge_prop=f'{direction}Edge{species}',
1274
+ post_hook=post_hook)
1275
+
1276
+
1277
+ def histogram_2d(plane: str, species: EntityT) -> PropertySpec:
1278
+ """
1279
+ A two-dimensional histogram, summed over segments and stacked over wholes.
1280
+
1281
+ Parameters
1282
+ ----------
1283
+ plane : str
1284
+ The plane as the probe phase names it, such as ``'xy'`` or
1285
+ ``'xz'``: the property is ``<plane>Hist<species>``. Its edges are
1286
+ the :func:`edge` properties of the plane's two directions.
1287
+ species : str
1288
+ The species, such as ``'Crd'``.
1289
+
1290
+ Returns
1291
+ -------
1292
+ PropertySpec
1293
+ A ``'histogram'`` ``'matrix'`` property.
1294
+
1295
+ Examples
1296
+ --------
1297
+ >>> histogram_2d('xy', 'Crd').prop
1298
+ 'xyHistCrd'
1299
+
1300
+ .. versionadded:: 0.6.0
1301
+ """
1302
+ return PropertySpec(f'{plane}Hist', species, 'histogram', 'matrix')
1303
+
1304
+
1305
+ def edge(direction: DirectionT) -> PropertySpec:
1306
+ """
1307
+ A bin-edge file shared by the two-dimensional histograms of a direction.
1308
+
1309
+ Parameters
1310
+ ----------
1311
+ direction : str
1312
+ The direction, such as ``'x'``: the property is
1313
+ ``<direction>Edge``, with no species.
1314
+
1315
+ Returns
1316
+ -------
1317
+ PropertySpec
1318
+ A ``'bin_edge'`` property.
1319
+
1320
+ Examples
1321
+ --------
1322
+ >>> edge('x').prop
1323
+ 'xEdge'
1324
+
1325
+ .. versionadded:: 0.6.0
1326
+ """
1327
+ return PropertySpec(f'{direction}Edge', '', 'bin_edge', 'bin_edge')
1328
+
1329
+
1330
+ def matrix_tseries(
1331
+ name: str, species: EntityT, post_hook: 'PostHook | None' = None
1332
+ ) -> PropertySpec:
1333
+ """
1334
+ A time series of arrays: one array per frame, concatenated over segments.
1335
+
1336
+ Parameters
1337
+ ----------
1338
+ name : str
1339
+ The property's name, such as ``'principalT'`` (one matrix per frame)
1340
+ or ``'clustersHistT'`` (one histogram per frame).
1341
+ species : str
1342
+ The species, such as ``'Mon'``.
1343
+ post_hook : callable, optional
1344
+ A post-hook; see :class:`PropertySpec`.
1345
+
1346
+ Returns
1347
+ -------
1348
+ PropertySpec
1349
+ A ``'tseries'`` ``'matrix'`` property.
1350
+
1351
+ Examples
1352
+ --------
1353
+ >>> matrix_tseries('principalT', 'Mon').whole_type
1354
+ 'matrix'
1355
+
1356
+ .. versionadded:: 0.6.0
1357
+ """
1358
+ return PropertySpec(name, species, 'tseries', 'matrix',
1359
+ post_hook=post_hook)