CodeEntropy 2.4.3__py3-none-any.whl → 2.5.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.
CodeEntropy/__init__.py CHANGED
@@ -8,4 +8,4 @@ entropy estimates, supporting a wide range of applications in molecular simulati
8
8
  and statistical mechanics.
9
9
  """
10
10
 
11
- __version__ = "2.4.3"
11
+ __version__ = "2.5.0"
@@ -148,7 +148,7 @@ class VibrationalEntropy:
148
148
  Eigenvalues as a NumPy array.
149
149
  """
150
150
  matrix = np.asarray(matrix, dtype=float)
151
- return la.eigvals(matrix)
151
+ return la.eigvalsh(matrix)
152
152
 
153
153
  def _convert_lambda_units(self, lambdas: np.ndarray) -> np.ndarray:
154
154
  """Convert eigenvalues into SI units using run_manager.
@@ -185,7 +185,10 @@ class VibrationalEntropy:
185
185
  """Convert eigenvalues to frequencies with robust filtering.
186
186
 
187
187
  Filters out eigenvalues that are complex, non-positive, or near-zero to
188
- avoid invalid frequencies and unstable entropies.
188
+ avoid invalid frequencies and unstable entropies. The near-zero
189
+ tolerance scales with the spectrum's own magnitude (as in
190
+ ``numpy.linalg.matrix_rank``) rather than using a fixed cutoff, since
191
+ the eigensolver noise floor around a true null mode is not constant.
189
192
 
190
193
  Args:
191
194
  lambdas: Eigenvalues (post unit conversion).
@@ -197,8 +200,11 @@ class VibrationalEntropy:
197
200
  lambdas = np.asarray(lambdas)
198
201
  lambdas = np.real_if_close(lambdas, tol=1000)
199
202
 
203
+ scale = float(np.max(np.abs(lambdas))) if lambdas.size else 0.0
204
+ atol = lambdas.size * np.finfo(float).eps * scale
205
+
200
206
  valid_mask = (
201
- np.isreal(lambdas) & (lambdas > 0) & (~np.isclose(lambdas, 0, atol=1e-7))
207
+ np.isreal(lambdas) & (lambdas > 0) & (~np.isclose(lambdas, 0, atol=atol))
202
208
  )
203
209
 
204
210
  removed = int(len(lambdas) - np.count_nonzero(valid_mask))
@@ -73,17 +73,24 @@ class AxesCalculator:
73
73
  (previous/next in sequence) using MDAnalysis bonded selections.
74
74
  - If there are *no* bonds to other residues:
75
75
  * Use a custom principal axes, from a moment-of-inertia (MOI) tensor
76
- that uses positions of heavy atoms only, but including masses of
76
+ that uses positions of heavy atoms only, but includes masses of
77
77
  heavy atom + bonded hydrogens.
78
78
  * Set translational axes equal to rotational axes (as per the original
79
79
  code convention).
80
- - If bonded to other residues:
80
+
81
+ - If bonded to only one other residue:
82
+ * Translational axes are principal axes of data_container.
83
+ * Find edge heavy atom (i.e. heavy atoms bonded to neighbour residue).
84
+ Compute rotation centre and axes as in get_terminal_axes.
85
+ Compute custom MOI, using heavy atom positions and
86
+ heavy atom + hydrogen masses.
87
+
88
+ - If bonded to at least two other residues:
81
89
  * Translational axes are principal axes of data_container.
82
- * Find edge heavy atoms (i.e. heavy atoms bonded to neighbour residues)
83
- and find the shortest chain between them: the backbone. Edge
84
- atoms + backbone COM are used to determine residue rotational axes.
85
- (see get_residue_custom_axes).Compute a custom MOI, using heavy atom
86
- positions and heavy atom + hydrogen masses.
90
+ * Find edge heavy atoms (i.e. heavy atoms bonded to neighbour residues).
91
+ Compute rotation centre and axes as in get_non_terminal_axes.
92
+ Compute a custom MOI, using heavy atom positions and
93
+ heavy atom + hydrogen masses.
87
94
 
88
95
  Args:
89
96
  data_container (MDAnalysis.Universe or AtomGroup):
@@ -137,39 +144,27 @@ class AxesCalculator:
137
144
  masses=ua_masses,
138
145
  dimensions=data_container.dimensions[:3],
139
146
  )
140
- rot_axes, moment_of_inertia = self.get_custom_principal_axes(moi_tensor)
147
+ rot_axes, moment_of_inertia = self.get_principal_axes_from_tensor(
148
+ moi_tensor
149
+ )
141
150
  trans_axes = rot_axes # per original convention
142
151
  rot_center = np.array(residue.center_of_mass())
143
152
  else:
144
153
  make_whole(data_container.atoms)
145
- trans_axes = data_container.atoms.principal_axes()
146
-
154
+ trans_axes = self.get_principal_axes_from_group(data_container.atoms)
147
155
  if len(edge_atom_set) == 1:
148
- if index == 0:
149
- # first residue: use first heavy atom
150
- edges = [residue.atoms[0], edge_atom_set[0]]
151
- backbone = self.get_chain(
152
- residue, residue.atoms[0], edge_atom_set[0]
153
- )
154
- else:
155
- # last residue: last heavy atom
156
- last_index = len(uas) - 1
157
- last = None
158
- if last_index > 0 and last is None:
159
- heavy_atom = uas[last_index]
160
- last = heavy_atom
161
- edges = [edge_atom_set[0], last]
162
-
163
- backbone = self.get_chain(residue, edge_atom_set[0], last)
156
+ edge_atom = edge_atom_set[0]
157
+ rot_center, rot_axes = self.get_terminal_axes(
158
+ residue=residue,
159
+ edge=edge_atom,
160
+ dimensions=data_container.dimensions[:3],
161
+ )
164
162
  else:
165
- edges = [edge_atom_set[0], edge_atom_set[1]]
166
- backbone = self.get_chain(residue, edge_atom_set[0], edge_atom_set[1])
167
- backbone_center = np.zeros(3)
168
- for heavy_atom in backbone:
169
- backbone_center += heavy_atom.position
170
- backbone_center = backbone_center / len(backbone)
171
- rot_center, rot_axes = self.get_residue_custom_axes(edges, backbone_center)
172
-
163
+ rot_center, rot_axes = self.get_non_terminal_axes(
164
+ residue=residue,
165
+ edges=edge_atom_set,
166
+ dimensions=data_container.dimensions[:3],
167
+ )
173
168
  moment_of_inertia = self.get_custom_residue_moment_of_inertia(
174
169
  center_of_mass=rot_center,
175
170
  positions=uas.positions,
@@ -224,14 +219,14 @@ class AxesCalculator:
224
219
  masses=topology.residue_ua_masses,
225
220
  dimensions=dimensions,
226
221
  )
227
- rot_axes, moment_of_inertia = self.get_custom_principal_axes(
222
+ rot_axes, moment_of_inertia = self.get_principal_axes_from_tensor(
228
223
  moment_of_inertia_tensor
229
224
  )
230
225
  trans_axes = rot_axes
231
226
  else:
232
227
  make_whole(mol.atoms)
233
- trans_axes = mol.atoms.principal_axes()
234
- rot_axes, moment_of_inertia = self.get_vanilla_axes(residue_atoms)
228
+ trans_axes = self.get_principal_axes_from_group(mol.atoms)
229
+ rot_axes, moment_of_inertia = self.get_molecule_axes(residue_atoms)
235
230
  center = residue_atoms.center_of_mass(unwrap=True)
236
231
 
237
232
  return trans_axes, rot_axes, center, moment_of_inertia
@@ -247,18 +242,17 @@ class AxesCalculator:
247
242
  Use the same approach as residue level rotational.
248
243
  Identify residue of interest and neighbours, then select
249
244
  edge heavy atoms (i.e. heavy atoms bonded to neighbour residues).
250
- If there are no bonds to neighbouring residues, use residue
251
- .principal axes Otherwise, find the shortest chain between edge
252
- residues: the backbone. Edge atoms + backbone COM are used to
253
- determine UA translational axes (see get_residue_custom_axes)
245
+ - If there are *no* bonds to other residues, use a custom principal axes
246
+ from a moment-of-inertia (MOI) tensor that uses positions of heavy atoms
247
+ only, but includes masses of heavy atom + bonded hydrogens.
248
+ - If bonded to only one other residue, see get_terminal_axes.
249
+ - If bonded to at least two other residues, see get_non_terminal_axes.
254
250
 
255
251
  - Rotational axes:
256
252
  Identify heavy atoms in the residue/molecule of interest and choose
257
253
  the `index`-th heavy atom (where index corresponds to the bead index).
258
254
  Use bonded topology around that heavy atom to determine UA rotational
259
- axes (see :meth:`get_bonded_axes`).
260
- Compute a custom MOI tensor using heavy-atom coordinates but UA masses
261
- (heavy + bonded H masses), then compute the principal axes from it.
255
+ axes (see :meth:`get_bonded_axes`). Compute a custom MOI tensor.
262
256
 
263
257
  Args:
264
258
  data_container (MDAnalysis.Universe or AtomGroup):
@@ -289,72 +283,52 @@ class AxesCalculator:
289
283
  # only the one residue => use principal axes
290
284
  residue = data_container
291
285
  trans_center = data_container.atoms.center_of_mass(unwrap=True)
292
- trans_axes = data_container.atoms.principal_axes()
293
- residue_heavy_atoms = heavy_atoms
286
+ trans_axes = self.get_principal_axes_from_group(data_container.atoms)
294
287
  else:
295
288
  # residue of interest has at least one neighbour
296
- if res_position == -1:
297
- residue = data_container.residues[0]
298
- resindex = residue.resindex
299
- resindex_next = resindex + 1
300
-
301
- second_edge = data_container.select_atoms(
302
- f"resindex {resindex} and bonded resindex {resindex_next}"
289
+ if res_position == -1 or res_position == 1:
290
+ # look at a terminal residue
291
+ if res_position == -1:
292
+ # first residue
293
+ residue = data_container.residues[0]
294
+ resindex = residue.resindex
295
+ resindex_next = resindex + 1
296
+ edge_atom_set = data_container.select_atoms(
297
+ f"resindex {resindex} and bonded resindex {resindex_next}"
298
+ )
299
+ else:
300
+ # last residue
301
+ residue = data_container.residues[1]
302
+ resindex = residue.resindex
303
+ resindex_prev = resindex - 1
304
+ edge_atom_set = data_container.select_atoms(
305
+ f"resindex {resindex} and bonded resindex {resindex_prev}"
306
+ )
307
+ edge_atom = edge_atom_set[0]
308
+ trans_center, trans_axes = self.get_terminal_axes(
309
+ residue=residue,
310
+ edge=edge_atom,
311
+ dimensions=data_container.dimensions[:3],
303
312
  )
304
-
305
- edges = [residue.atoms[0], second_edge[0]]
306
- backbone = self.get_chain(
307
- residue, residue.atoms[0], second_edge.atoms[0]
308
- )
309
-
310
- elif res_position == 0:
313
+ else:
311
314
  # between 2 residues
312
315
  residue = data_container.residues[1]
313
316
  resindex = residue.resindex
314
317
  resindex_next = resindex + 1
315
318
  resindex_prev = resindex - 1
316
-
317
- edge_set = data_container.select_atoms(
319
+ edge_atom_set = data_container.select_atoms(
318
320
  f"resindex {resindex} and "
319
321
  f"(bonded resindex {resindex_prev} or "
320
322
  f"resindex {resindex_next})"
321
323
  )
322
-
323
- edges = [edge_set[0], edge_set[1]]
324
- backbone = self.get_chain(residue, edge_set[0], edge_set[1])
325
-
326
- else:
327
- # last resid
328
- # always resindex 1 in data_container
329
- residue = data_container.residues[1]
330
- resindex = residue.resindex
331
- resindex_prev = resindex - 1
332
- first_edge = data_container.select_atoms(
333
- f"resindex {resindex} and bonded resindex {resindex_prev}"
324
+ trans_center, trans_axes = self.get_non_terminal_axes(
325
+ residue=residue,
326
+ edges=edge_atom_set,
327
+ dimensions=data_container.dimensions[:3],
334
328
  )
335
-
336
- last_index = len(heavy_atoms) - 1
337
- last = None
338
- # look for last heavy atom
339
- # with only one bond to another
340
- if last_index > 0 and last is None:
341
- heavy_atom = heavy_atoms[last_index]
342
- last = heavy_atom
343
-
344
- edges = [first_edge.atoms[0], last]
345
- backbone = self.get_chain(residue, first_edge.atoms[0], last)
346
-
347
- backbone_center = np.zeros(3)
348
- for heavy_atom in backbone:
349
- backbone_center += heavy_atom.position
350
- backbone_center = backbone_center / len(backbone)
351
-
352
- trans_center, trans_axes = self.get_residue_custom_axes(
353
- edges, backbone_center
354
- )
355
- residue_heavy_atoms = residue.atoms.select_atoms("mass 2 to 999")
356
-
357
329
  # look for heavy atoms in residue of interest
330
+ residue_heavy_atoms = residue.atoms.select_atoms("mass 2 to 999")
331
+
358
332
  heavy_atom_indices = []
359
333
  for atom in residue_heavy_atoms:
360
334
  heavy_atom_indices.append(atom.index)
@@ -444,12 +418,12 @@ class AxesCalculator:
444
418
  masses=topology.residue_ua_masses,
445
419
  dimensions=dimensions,
446
420
  )
447
- trans_axes, _moment_of_inertia = self.get_custom_principal_axes(
421
+ trans_axes, _moment_of_inertia = self.get_principal_axes_from_tensor(
448
422
  moment_of_inertia_tensor
449
423
  )
450
424
  else:
451
425
  make_whole(residue_atoms)
452
- trans_axes = residue_atoms.principal_axes()
426
+ trans_axes = self.get_principal_axes_from_group(residue_atoms)
453
427
 
454
428
  center = heavy_atom.position
455
429
  rot_axes, moment_of_inertia = self.get_bonded_axes_from_topology(
@@ -506,10 +480,10 @@ class AxesCalculator:
506
480
  ua_all = u.atoms[topology.ua_all_atom_indices]
507
481
 
508
482
  if len(heavy_bonded) == 0:
509
- custom_axes, custom_moment_of_inertia = self.get_vanilla_axes(ua_all)
483
+ custom_axes, custom_moment_of_inertia = self.get_molecule_axes(ua_all)
510
484
 
511
485
  if len(heavy_bonded) == 1 and len(light_bonded) == 0:
512
- custom_axes = self.get_custom_axes(
486
+ custom_axes = self.get_bonded_vector_axes(
513
487
  a=heavy_atom.position,
514
488
  b_list=[heavy_bonded[0].position],
515
489
  c=np.zeros(3),
@@ -517,7 +491,7 @@ class AxesCalculator:
517
491
  )
518
492
 
519
493
  if len(heavy_bonded) == 1 and len(light_bonded) >= 1:
520
- custom_axes = self.get_custom_axes(
494
+ custom_axes = self.get_bonded_vector_axes(
521
495
  a=heavy_atom.position,
522
496
  b_list=[heavy_bonded[0].position],
523
497
  c=light_bonded[0].position,
@@ -525,7 +499,7 @@ class AxesCalculator:
525
499
  )
526
500
 
527
501
  if len(heavy_bonded) >= 2:
528
- custom_axes = self.get_custom_axes(
502
+ custom_axes = self.get_bonded_vector_axes(
529
503
  a=heavy_atom.position,
530
504
  b_list=heavy_bonded.positions,
531
505
  c=heavy_bonded[1].position,
@@ -579,10 +553,14 @@ class AxesCalculator:
579
553
  rot_center: (3,) rotation centre,
580
554
  lies on the E1-E2 vector
581
555
  rot_axes: (3,3) rotation axes of residue
556
+
557
+ Raises:
558
+ ValueError: If axes cannot be normalized due to degeneracy.
559
+
582
560
  """
583
- first_edge_centre_of_geometry_vector = center - edges[0].position
561
+ first_edge_centre_of_geometry_vector = center - edges[0]
584
562
  # look for projection of E1-O onto E1-E2 (E1-C)
585
- first_edge_second_edge_vector = edges[1].position - edges[0].position
563
+ first_edge_second_edge_vector = edges[1] - edges[0]
586
564
  first_edge_origin_vector = (
587
565
  np.dot(first_edge_second_edge_vector, first_edge_centre_of_geometry_vector)
588
566
  / (np.linalg.norm(first_edge_second_edge_vector) ** 2)
@@ -594,15 +572,119 @@ class AxesCalculator:
594
572
  )
595
573
  y_axis = origin_centre_of_geometry_vector
596
574
  z_axis = np.cross(x_axis, y_axis)
597
- x_axis /= np.linalg.norm(x_axis)
598
- y_axis /= np.linalg.norm(y_axis)
599
- z_axis /= np.linalg.norm(z_axis)
600
- rot_axes = np.array([x_axis, y_axis, z_axis])
601
- rot_center = first_edge_origin_vector + edges[0].position
575
+ unscaled_rot_axes = np.array((x_axis, y_axis, z_axis), dtype=float)
576
+ mod = np.sqrt(np.sum(unscaled_rot_axes**2, axis=1))
577
+ if np.any(np.isclose(mod, 0.0)):
578
+ raise ValueError("Degenerate custom axes: cannot normalize (zero norm).")
579
+ rot_axes = unscaled_rot_axes / mod[:, np.newaxis]
580
+ rot_center = first_edge_origin_vector + edges[0]
581
+ return rot_center, rot_axes
582
+
583
+ def get_terminal_axes(self, residue, edge, dimensions):
584
+ """
585
+ Compute rotation axes at the residue level/translation axes at the UA level
586
+ for the terminal residues in a polymer, given the edge atom
587
+ (i.e. atom bonded to neighbour residue) and residue of interest.
588
+ Find all heavy atoms bonded to edge heavy atom and compute
589
+ their average position. Find all other heavy atoms in residue
590
+ and compute their average position. The three points are now used to
591
+ obtain determine residue rotational axes. (see get_residue_custom_axes)
592
+ If there are only two heavy atoms in the residue/all heavy atoms are bonded
593
+ to edge atom, x-axis is set along the vector between the
594
+ edge atom and average position of bonded atoms, y-axis is arbitrary
595
+ and z-axis is paralel to the two. This is the same as case 2 in get_bonded_axes.
596
+ If there no heavy atoms bonded to the edge atom (i.e. the edge atom is the only
597
+ heavy atom in the residue), centre is set on edge atom and axes are principal
598
+ axes.
599
+
600
+ Args:
601
+ residue: MDAnalysis AtomGroup
602
+ edge: MDAnalysis atom
603
+ dimensions: (3,) dimensions of the simulation box
604
+
605
+ Returns:
606
+ rot_center: (3,) rotation centre,
607
+ rot_axes: (3,3) rotation axes of residue
608
+ """
609
+ heavy_atoms = residue.atoms.select_atoms("mass 2 to 999")
610
+ bonded_atoms = residue.atoms.select_atoms(
611
+ f"(mass 2 to 999) and bonded index {edge.index}"
612
+ )
613
+ if len(bonded_atoms) == 0:
614
+ # there is only one heavy atom in the residue
615
+ rot_center = edge.position
616
+ rot_axes = self.get_principal_axes_from_group(residue.atoms)
617
+ else:
618
+ average_bonded = np.zeros(3)
619
+ for bonded_atom in bonded_atoms:
620
+ average_bonded += bonded_atom.position
621
+ average_bonded /= len(bonded_atoms)
622
+ # find the average position of all other heavy atoms in residue
623
+ other_atoms = []
624
+ for atom in heavy_atoms:
625
+ if atom != edge and atom not in bonded_atoms:
626
+ other_atoms.append(atom)
627
+ if len(other_atoms) > 0:
628
+ average_other_atoms = np.zeros(3)
629
+ for atom in other_atoms:
630
+ average_other_atoms += atom.position
631
+ average_other_atoms /= len(other_atoms)
632
+ rot_center, rot_axes = self.get_residue_custom_axes(
633
+ [edge.position, average_other_atoms], average_bonded
634
+ )
635
+ else:
636
+ rot_center = edge.position
637
+ rot_axes = self.get_bonded_vector_axes(
638
+ a=edge.position,
639
+ b_list=[average_bonded],
640
+ c=np.zeros(3),
641
+ dimensions=dimensions,
642
+ )
643
+ return rot_center, rot_axes
644
+
645
+ def get_non_terminal_axes(self, residue, edges, dimensions):
646
+ """
647
+ Compute rotation axes at the residue level/ translation axes at
648
+ the UA level for the non-terminal residues in a linear polymer, given the
649
+ edge atoms (i.e. heavy atoms bonded to neighbour residues) and
650
+ residue of interest. Find the shortest chain between edge atoms: the backbone.
651
+ Edges + backbone average position determine the residue rotational axes.
652
+ (see get_residue_custom_axes). If the two edge heavy atoms
653
+ are bonded to each other (i.e. there is no backbone), x-axis is set
654
+ along the vector between the edge atom and average position of bonded
655
+ atoms, y-axis is arbitrary and z-axis is paralel to the two. This is the
656
+ same as case 2 in get_bonded_axes.
657
+ Args:
658
+ residue: MDAnalysis AtomGroup
659
+ edges: MDAnalysis AtomGroup
660
+ dimensions: (3,) dimensions of the simulation box
661
+
662
+ Returns:
663
+ rot_center: (3,) rotation centre,
664
+ rot_axes: (3,3) rotation axes of residue
665
+ """
666
+ backbone = self.get_chain(residue, edges[0], edges[1])
667
+ backbone_center = np.zeros(3)
668
+ if len(backbone) > 0:
669
+ for heavy_atom in backbone:
670
+ backbone_center += heavy_atom.position
671
+ backbone_center /= len(backbone)
672
+ rot_center, rot_axes = self.get_residue_custom_axes(
673
+ edges.positions, backbone_center
674
+ )
675
+ else:
676
+ rot_center = (edges[0].position + edges[1].position) / 2
677
+ rot_axes = self.get_bonded_vector_axes(
678
+ a=rot_center,
679
+ b_list=[edges[0].position],
680
+ c=np.zeros(3),
681
+ dimensions=dimensions,
682
+ )
602
683
  return rot_center, rot_axes
603
684
 
604
685
  def get_bonded_axes(self, system, atom, dimensions: np.ndarray):
605
- r"""Compute UA rotational axes from bonded topology around a heavy atom.
686
+ """
687
+ Compute UA rotational axes from bonded topology around a heavy atom.
606
688
 
607
689
  For a given heavy atom, use its bonded atoms to get the axes for rotating
608
690
  forces around. Few cases for choosing united atom axes, which are dependent
@@ -669,11 +751,11 @@ class AxesCalculator:
669
751
 
670
752
  # case1
671
753
  if len(heavy_bonded) == 0:
672
- custom_axes, custom_moment_of_inertia = self.get_vanilla_axes(ua_all)
754
+ custom_axes, custom_moment_of_inertia = self.get_molecule_axes(ua_all)
673
755
 
674
756
  # case2
675
757
  if len(heavy_bonded) == 1 and len(light_bonded) == 0:
676
- custom_axes = self.get_custom_axes(
758
+ custom_axes = self.get_bonded_vector_axes(
677
759
  a=atom.position,
678
760
  b_list=[heavy_bonded[0].position],
679
761
  c=np.zeros(3),
@@ -682,7 +764,7 @@ class AxesCalculator:
682
764
 
683
765
  # case3
684
766
  if len(heavy_bonded) == 1 and len(light_bonded) >= 1:
685
- custom_axes = self.get_custom_axes(
767
+ custom_axes = self.get_bonded_vector_axes(
686
768
  a=atom.position,
687
769
  b_list=[heavy_bonded[0].position],
688
770
  c=light_bonded[0].position,
@@ -692,7 +774,7 @@ class AxesCalculator:
692
774
  # case4 (not used in original 2019 code; case5 used instead)
693
775
  # case5
694
776
  if len(heavy_bonded) >= 2:
695
- custom_axes = self.get_custom_axes(
777
+ custom_axes = self.get_bonded_vector_axes(
696
778
  a=atom.position,
697
779
  b_list=heavy_bonded.positions,
698
780
  c=heavy_bonded[1].position,
@@ -732,14 +814,15 @@ class AxesCalculator:
732
814
  bonded_H_atoms = bonded_atoms.select_atoms("mass 1 to 1.1")
733
815
  return bonded_heavy_atoms, bonded_H_atoms
734
816
 
735
- def get_vanilla_axes(self, molecule):
736
- """Get principal axes and sorted principal moments (vanilla method).
817
+ def get_molecule_axes(self, molecule):
818
+ """Get principal axes and sorted principal moments.
737
819
 
738
- Compute the principal axes and moments of inertia for a molecule using
739
- MDAnalysis built-in functionality.
820
+ Compute the principal axes and moments of inertia for a molecule from its
821
+ moment of inertia tensor.
740
822
 
741
- The original description is preserved:
742
- - The molecule is made whole to ensure correct handling of PBC.
823
+ - The molecule is made whole to ensure correct handling of PBC. The tensor
824
+ is taken after that, so it does not depend on where the molecule sits
825
+ relative to the periodic boundary.
743
826
  - The moments are obtained by diagonalising the moment of inertia tensor.
744
827
  - Eigenvalues are returned sorted from largest to smallest magnitude.
745
828
 
@@ -752,17 +835,10 @@ class AxesCalculator:
752
835
  - principal_axes: (3, 3) axes.
753
836
  - moment_of_inertia: (3,) moments sorted descending by absolute value.
754
837
  """
755
- moment_of_inertia_tensor = molecule.moment_of_inertia(unwrap=True)
756
838
  make_whole(molecule.atoms)
757
- principal_axes = molecule.principal_axes()
758
-
759
- eigenvalues, _ = np.linalg.eig(moment_of_inertia_tensor)
760
- order = np.argsort(np.abs(eigenvalues))[::-1]
761
- moment_of_inertia = eigenvalues[order]
839
+ return self.get_principal_axes_from_tensor(molecule.moment_of_inertia())
762
840
 
763
- return principal_axes, moment_of_inertia
764
-
765
- def get_custom_axes(
841
+ def get_bonded_vector_axes(
766
842
  self,
767
843
  a: np.ndarray,
768
844
  b_list: Sequence[np.ndarray],
@@ -994,44 +1070,72 @@ class AxesCalculator:
994
1070
 
995
1071
  return moment_of_inertia_tensor
996
1072
 
997
- def get_custom_principal_axes(
998
- self, moment_of_inertia_tensor: np.ndarray
1073
+ def get_principal_axes_from_tensor(
1074
+ self,
1075
+ moment_of_inertia_tensor: np.ndarray,
1076
+ degeneracy_rtol: float = 100 * np.finfo(np.float32).eps,
1077
+ reference_vector: Sequence[float] = (1.0, 2.0, 3.0),
999
1078
  ) -> tuple[np.ndarray, np.ndarray]:
1000
- """Compute principal axes and moments from a custom MOI tensor.
1079
+ """Compute the principal axes and moments of a moment of inertia tensor.
1001
1080
 
1002
- Principal axes and centre of axes from the ordered eigenvalues and
1003
- eigenvectors of a moment of inertia tensor. This function allows for a
1004
- custom moment of inertia tensor to be used, which isn't possible with the
1005
- built-in MDAnalysis principal_axes() function.
1081
+ The eigenvectors of a symmetric tensor are only defined up to a sign, and,
1082
+ for equal moments, up to a rotation within the degenerate eigenspace. The
1083
+ axes are returned in a canonical form, so that a given tensor always
1084
+ produces the same axes. The convention is:
1006
1085
 
1007
- Original behaviour preserved:
1008
-
1009
- - Eigenvalues are sorted by descending absolute magnitude.
1010
- - Eigenvectors are transposed so axes are returned as rows.
1011
- - Z axis is flipped to enforce the same handedness convention as the
1012
- original implementation.
1086
+ - Moments are sorted by descending absolute value.
1087
+ - Within a degenerate eigenspace (moments equal to within
1088
+ ``degeneracy_rtol`` of the largest), the basis is the eigenvectors of a
1089
+ reference tensor built from ``reference_vector``, projected onto that
1090
+ eigenspace.
1091
+ - The first two axes are signed to have a positive projection onto
1092
+ ``reference_vector``, and the third is their cross product.
1013
1093
 
1014
1094
  Args:
1015
- moment_of_inertia_tensor: (3, 3) custom inertia tensor.
1095
+ moment_of_inertia_tensor: (3, 3) symmetric inertia tensor.
1096
+ degeneracy_rtol: Relative tolerance, with respect to the largest
1097
+ moment, for treating moments as degenerate. The default is a
1098
+ multiple of the float32 precision of the coordinates.
1099
+ reference_vector: Fixed direction used to choose axis signs and the
1100
+ basis within a degenerate eigenspace. Its components must be
1101
+ distinct, so that no symmetric or axis-aligned direction is
1102
+ perpendicular to it or ties on its components.
1016
1103
 
1017
1104
  Returns:
1018
1105
  Tuple[np.ndarray, np.ndarray]:
1019
1106
  - principal_axes: (3, 3) principal axes (rows).
1020
- - moment_of_inertia: (3,) principal moments.
1107
+ - principal_moments: (3,) moments sorted by descending magnitude.
1021
1108
  """
1022
- eigenvalues, eigenvectors = np.linalg.eig(moment_of_inertia_tensor)
1023
- order = np.abs(eigenvalues).argsort()[::-1] # descending order
1024
- transposed = np.transpose(eigenvectors) # columns -> rows
1025
- moment_of_inertia = eigenvalues[order]
1026
- principal_axes = transposed[order]
1027
-
1028
- # point z axis in correct direction, as per original code
1029
- cross_xy = np.cross(principal_axes[0], principal_axes[1])
1030
- dot_z = float(np.dot(cross_xy, principal_axes[2]))
1031
- if dot_z < 0:
1032
- principal_axes[2] *= -1
1033
-
1034
- return principal_axes, moment_of_inertia
1109
+ principal_moments, eigenvectors = np.linalg.eigh(moment_of_inertia_tensor)
1110
+ order = np.argsort(np.abs(principal_moments))[::-1]
1111
+ principal_moments = principal_moments[order]
1112
+ eigenvectors = eigenvectors[:, order]
1113
+
1114
+ reference = np.asarray(reference_vector, dtype=float)
1115
+ gaps = np.abs(np.diff(np.abs(principal_moments)))
1116
+ boundaries = (
1117
+ np.flatnonzero(gaps > degeneracy_rtol * np.abs(principal_moments[0])) + 1
1118
+ )
1119
+ for subspace in np.split(np.arange(3), boundaries):
1120
+ if len(subspace) > 1:
1121
+ basis = eigenvectors[:, subspace]
1122
+ _, rotation = np.linalg.eigh(basis.T @ np.diag(reference) @ basis)
1123
+ eigenvectors[:, subspace] = basis @ rotation
1124
+
1125
+ for axis in (0, 1):
1126
+ if eigenvectors[:, axis] @ reference < 0:
1127
+ eigenvectors[:, axis] *= -1
1128
+
1129
+ z_axis = np.cross(eigenvectors[:, 0], eigenvectors[:, 1])
1130
+ principal_axes = np.array([eigenvectors[:, 0], eigenvectors[:, 1], z_axis])
1131
+ return principal_axes, principal_moments
1132
+
1133
+ def get_principal_axes_from_group(self, group) -> np.ndarray:
1134
+ """Return the canonical principal axes (rows) of an atom group."""
1135
+ principal_axes, _ = self.get_principal_axes_from_tensor(
1136
+ group.atoms.moment_of_inertia()
1137
+ )
1138
+ return principal_axes
1035
1139
 
1036
1140
  def get_UA_masses(self, molecule) -> list[float]:
1037
1141
  """Return united-atom (UA) masses for a molecule.
@@ -506,8 +506,8 @@ class FrameCovarianceNode:
506
506
  # principal axes
507
507
  make_whole(residue.atoms)
508
508
  make_whole(bead)
509
- trans_axes = residue.atoms.principal_axes()
510
- rot_axes, moi = axes_manager.get_vanilla_axes(bead)
509
+ trans_axes = axes_manager.get_principal_axes_from_group(residue.atoms)
510
+ rot_axes, moi = axes_manager.get_molecule_axes(bead)
511
511
  center = bead.center_of_mass(unwrap=True)
512
512
 
513
513
  force_vecs.append(
@@ -658,8 +658,8 @@ class FrameCovarianceNode:
658
658
  make_whole(mol.atoms)
659
659
  make_whole(bead)
660
660
 
661
- trans_axes = mol.atoms.principal_axes()
662
- rot_axes, moi = axes_manager.get_vanilla_axes(bead)
661
+ trans_axes = axes_manager.get_principal_axes_from_group(mol.atoms)
662
+ rot_axes, moi = axes_manager.get_molecule_axes(bead)
663
663
  center = bead.center_of_mass(unwrap=True)
664
664
  return (
665
665
  np.asarray(trans_axes),
@@ -688,8 +688,8 @@ class FrameCovarianceNode:
688
688
  make_whole(mol.atoms)
689
689
  make_whole(bead)
690
690
 
691
- trans_axes = mol.atoms.principal_axes()
692
- rot_axes, moi = axes_manager.get_vanilla_axes(bead)
691
+ trans_axes = axes_manager.get_principal_axes_from_group(mol.atoms)
692
+ rot_axes, moi = axes_manager.get_molecule_axes(bead)
693
693
  center = bead.center_of_mass(unwrap=True)
694
694
 
695
695
  return (
@@ -354,7 +354,10 @@ class UniverseOperations:
354
354
  select_atom_force = u_force.select_atoms("all")
355
355
 
356
356
  coordinates = self._extract_timeseries(select_atom, kind="positions")
357
- dimensions = self._extract_timeseries(select_atom, kind="dimensions")
357
+ if u.dimensions is None:
358
+ dimensions = self._build_non_periodic_dimensions(coordinates)
359
+ else:
360
+ dimensions = self._extract_timeseries(select_atom, kind="dimensions")
358
361
 
359
362
  forces = self._extract_force_timeseries_with_fallback(
360
363
  select_atom_force,
@@ -374,6 +377,30 @@ class UniverseOperations:
374
377
 
375
378
  return new_universe
376
379
 
380
+ @staticmethod
381
+ def _build_non_periodic_dimensions(coordinates: np.ndarray) -> np.ndarray:
382
+ """Build a box for a trajectory without box dimensions.
383
+
384
+ The axes and covariance code needs a box for the minimum image
385
+ convention and unwrapping. The box is cubic and more than twice the
386
+ largest interatomic distance, so neither step changes the coordinates.
387
+
388
+ Args:
389
+ coordinates: Positions with shape ``(n_frames, n_atoms, 3)``.
390
+
391
+ Returns:
392
+ Array of shape ``(n_frames, 6)`` holding the box lengths and 90
393
+ degree angles for every frame.
394
+ """
395
+ span = float(np.max(np.ptp(coordinates, axis=1)))
396
+ length = max(10.0 * span, 100.0)
397
+ box = np.array([length, length, length, 90.0, 90.0, 90.0], dtype=np.float32)
398
+ logger.warning(
399
+ "Trajectory has no box dimensions; treating it as non-periodic "
400
+ f"using a {length:.1f} A cubic box."
401
+ )
402
+ return np.tile(box, (coordinates.shape[0], 1))
403
+
377
404
  def _extract_timeseries(self, atomgroup, *, kind: str) -> np.ndarray:
378
405
  """Extract a time series array using explicit frame indexing.
379
406
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: CodeEntropy
3
- Version: 2.4.3
3
+ Version: 2.5.0
4
4
  Summary: CodeEntropy is a Python package for computing the configurational entropy of macromolecular systems using forces sampled from molecular dynamics (MD) simulations. It implements the multiscale cell correlation method to provide accurate and efficient entropy estimates, supporting a wide range of applications in molecular simulation and statistical mechanics.
5
5
  Keywords: entropy,macromolecular systems,MD simulation
6
6
  Author-email: Arghya 'Argo' Chakravorty <arghyac@umich.edu>, Donald Chung-HK <donald.chung@stfc.ac.uk>, Sarah Fegan <sarah.fegan@stfc.ac.uk>, James Gebbie-Rayet <james.gebbie@stfc.ac.uk>, Sarah Harris <sarah.harris@sheffield.ac.uk>, Richard Henchman <rhen7213@uni.sydney.edu.au>, Jonathan Higham <j.higham4@lancaster.ac.uk>, Jas Kalayan <jas.kalayan@stfc.ac.uk>, Ioana Papa <iapapa1@sheffield.ac.uk>
@@ -16,34 +16,29 @@ Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Natural Language :: English
17
17
  Classifier: Development Status :: 5 - Production/Stable
18
18
  License-File: LICENSE
19
- Requires-Dist: numpy>=2.3,<3.0
19
+ Requires-Dist: numpy>=2.3.2,<2.5.0
20
20
  Requires-Dist: mdanalysis>=2.10,<3.0
21
- Requires-Dist: pandas>=3,<3.1
22
- Requires-Dist: psutil>=7.1,<8.0
23
- Requires-Dist: PyYAML>=6.0,<7.0
24
- Requires-Dist: python-json-logger>=4.0,<5.0
25
- Requires-Dist: rich>=15,<15.1
26
- Requires-Dist: art>=6.5,<7.0
27
- Requires-Dist: networkx>=3.6,<3.7
28
- Requires-Dist: matplotlib>=3.11,<3.12
29
- Requires-Dist: waterEntropy>=2,<2.3
30
- Requires-Dist: requests>=2.32,<3.0
31
- Requires-Dist: rdkit>=2025.9.5
32
- Requires-Dist: numba>=0.65.1,<0.70
33
- Requires-Dist: dask>=2026.1.2,<2026.2.0
34
- Requires-Dist: dask-jobqueue>=0.9.0,<0.10
35
- Requires-Dist: distributed>=2026.1.2,<2026.2.0
36
- Requires-Dist: sphinx>=9.1,<9.2 ; extra == "docs"
37
- Requires-Dist: nbsphinx>=0.9,<1.0 ; extra == "docs"
38
- Requires-Dist: sphinx_rtd_theme>=3,<3.2 ; extra == "docs"
39
- Requires-Dist: sphinxcontrib-contentui>=0.2,<1.0 ; extra == "docs"
40
- Requires-Dist: sphinxcontrib-details-directive>=0.1,<1.0 ; extra == "docs"
21
+ Requires-Dist: pandas>=2.3.3,<4.0
22
+ Requires-Dist: psutil>=5.9.4,<8.0
23
+ Requires-Dist: PyYAML>=6.0.3,<7.0
24
+ Requires-Dist: rich>=13.0.0,<16.0
25
+ Requires-Dist: art>=5.0,<7.0
26
+ Requires-Dist: networkx>=2.8,<4.0
27
+ Requires-Dist: waterEntropy>=2,<3.0
28
+ Requires-Dist: requests>=2.32.2,<3.0
29
+ Requires-Dist: rdkit>=2025.3.4,<2026
30
+ Requires-Dist: numba>=0.63.0,<0.70
31
+ Requires-Dist: dask>=2025.4.0,<2027.1.0
32
+ Requires-Dist: dask-jobqueue>=0.9.0,<1.0
33
+ Requires-Dist: distributed>=2025.4.0,<2027.1.0
34
+ Requires-Dist: sphinx>=8.0.0,<10.0 ; extra == "docs"
35
+ Requires-Dist: nbsphinx>=0.9.3,<1.0 ; extra == "docs"
41
36
  Requires-Dist: sphinx_copybutton>=0.5,<1.0 ; extra == "docs"
42
- Requires-Dist: furo>=2025.0,<2026.0 ; extra == "docs"
37
+ Requires-Dist: furo>=2025.12.19,<2026.0 ; extra == "docs"
43
38
  Requires-Dist: markupsafe>=3.0,<4.0 ; extra == "docs"
44
- Requires-Dist: pre-commit>=4.5,<5.0 ; extra == "pre-commit"
45
- Requires-Dist: ruff>=0.16,<0.17 ; extra == "pre-commit"
46
- Requires-Dist: pylint>=4.0,<5.0 ; extra == "pre-commit"
39
+ Requires-Dist: pre-commit>=3.5.0,<5.0 ; extra == "pre-commit"
40
+ Requires-Dist: ruff>=0.15.2,<1.0 ; extra == "pre-commit"
41
+ Requires-Dist: pylint>=3.0.0,<5.0 ; extra == "pre-commit"
47
42
  Requires-Dist: rstcheck>=6.2,<7.0 ; extra == "pre-commit"
48
43
  Requires-Dist: pytest>=9.0,<10.0 ; extra == "testing"
49
44
  Requires-Dist: pytest-cov>=7.0,<8.0 ; extra == "testing"
@@ -1,4 +1,4 @@
1
- CodeEntropy/__init__.py,sha256=YQbehIDwncRue48kSLjP2_K10IGKY4dbbhrzfR9UZ8M,404
1
+ CodeEntropy/__init__.py,sha256=QM8PCDQ0OM8HKjPNiIHHpPFtqLhnyTcGo8Tc6-_Nh-s,404
2
2
  CodeEntropy/__main__.py,sha256=5i_ZrxQaq0q2aPllbB4SZ6BApV6oUFCgx0cGyjfMPrU,108
3
3
  CodeEntropy/cli.py,sha256=xgSgagCV6HQ3CoXUpG7p7Ef5hCExgxp7orz6dZZWA6c,960
4
4
  CodeEntropy/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -11,7 +11,7 @@ CodeEntropy/entropy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSu
11
11
  CodeEntropy/entropy/configurational.py,sha256=3erd1Ul4sGL5BMBDYq33f27aRTZqiEb6hv1bVfWo9QA,2392
12
12
  CodeEntropy/entropy/graph.py,sha256=LeDF2KxxNVgpetrIE9j0dnW64UhGywDLqoBJEf54vtc,4612
13
13
  CodeEntropy/entropy/orientational.py,sha256=gfpYqCKQjAAx22rrUdbiLvzfEIXy1nwJoub1rgYKCvY,3415
14
- CodeEntropy/entropy/vibrational.py,sha256=jgPWWiUW6aRCx949cFrNKuLB1MJoawD-9Uk8OB04Cs8,9366
14
+ CodeEntropy/entropy/vibrational.py,sha256=WeQJ1bKQPSxPrREJ8CDhk9Ph2xeunrWZ0zu76y-H6Ow,9733
15
15
  CodeEntropy/entropy/water.py,sha256=Ob3V08DyQNSWDMqpQ27rqjgHeU5AlKZfjqf9EqUdgqc,8225
16
16
  CodeEntropy/entropy/workflow.py,sha256=bxg8AVXGk7uwlv4u2AZ4kTQ26PMge_Z5ubWghdLtn9U,16100
17
17
  CodeEntropy/entropy/nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -20,7 +20,7 @@ CodeEntropy/entropy/nodes/configurational.py,sha256=-3j2ji_p-twPjZyjQ1T3tgN0pYLS
20
20
  CodeEntropy/entropy/nodes/orientational.py,sha256=yPN_XRJ1LOxJ7hOE2cnsjIbgQlI2avM1g3mFbGhW6fM,2481
21
21
  CodeEntropy/entropy/nodes/vibrational.py,sha256=3uhRqaMpK8u7QK2SDQ6RLRcr2rS3sPh6-GlTaNdTFWw,16978
22
22
  CodeEntropy/levels/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
- CodeEntropy/levels/axes.py,sha256=PzFKuac22C_JQvkQamKB7j4W6OUXBx-YMAyKHXlw_E0,44699
23
+ CodeEntropy/levels/axes.py,sha256=wmotkCwWcc7RzicWJQzXVwYFqkyw9WqWpyeRF1NB3zU,50338
24
24
  CodeEntropy/levels/forces.py,sha256=c7XnNIBTFCHHXllr1WxTIrxEV_CkUB2b1vdFO8hUofY,11595
25
25
  CodeEntropy/levels/hierarchy.py,sha256=3cd4t6Tywg7VMgw4TboFF5VAHdhG4FaGBXsMptchisY,5158
26
26
  CodeEntropy/levels/linalg.py,sha256=PJlrKtIOYcYDycW3Tbetm6k94FKFZTq-hm28qiu_tS0,3565
@@ -47,7 +47,7 @@ CodeEntropy/levels/nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZ
47
47
  CodeEntropy/levels/nodes/accumulators.py,sha256=XM7phCAIo0RK9YAtx2wrdo2oinmpwXYrH9mp1_LQD7U,6259
48
48
  CodeEntropy/levels/nodes/axes_topology.py,sha256=QzrWbCL9YA8fzF4_WWpQGaIAAsOnjY6cVH7INDE40dk,11285
49
49
  CodeEntropy/levels/nodes/beads.py,sha256=9Ow065FY4jrIqPKCZJCvDNfIekJ17OBSX-bmEFsxtbc,8010
50
- CodeEntropy/levels/nodes/covariance.py,sha256=zwLPG_VRAH6hmCXeKkeQgqk7e_FRTBq0XXo-bNsR-U8,30210
50
+ CodeEntropy/levels/nodes/covariance.py,sha256=EY_-Ps-e4qtI9RIhV47j7cuvP3znJovGt6kK6yC77sU,30294
51
51
  CodeEntropy/levels/nodes/detect_levels.py,sha256=YrM5SSceLiMqbOhr8Fy6SV-E_zkKRpvgHfDT8Grbin8,1864
52
52
  CodeEntropy/levels/nodes/detect_molecules.py,sha256=jILCddEicVngA7FjvQzafbG8HslaRICpT6hj2Uyi6YY,3059
53
53
  CodeEntropy/molecules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -56,10 +56,10 @@ CodeEntropy/results/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSu
56
56
  CodeEntropy/results/reporter.py,sha256=ZmCEKhh_h7-0JGumlyrpH0Y-FaG8aGv-KkzdRAamEAQ,18368
57
57
  CodeEntropy/trajectory/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
58
58
  CodeEntropy/trajectory/frames.py,sha256=MSx_swzFjo4TgkZTR1y6W-1zuKNPg_C36I_uFqvOKpk,4354
59
- CodeEntropy/trajectory/mda.py,sha256=cWqgSgamdPnL4n3aWEKUgycjtzjpdgRgMVX6G5fln94,15803
59
+ CodeEntropy/trajectory/mda.py,sha256=epKpAzxkNQi3rE7HJc_tsK_ZY4c575LdXmWewJoP1qE,16954
60
60
  CodeEntropy/trajectory/source.py,sha256=NfYsdm1amr29h2tmJP-RKyCp3phb1SOAtAELJmGm740,1475
61
- codeentropy-2.4.3.dist-info/entry_points.txt,sha256=8Oa3H9ErsrGtx5Hc6hIHOw73wK3Ylkvfgtl_4cBb_GU,52
62
- codeentropy-2.4.3.dist-info/licenses/LICENSE,sha256=hgMaAAVW1Q6dL586OHoz8vFO-6BYn-d2aAFBpVQylFc,1072
63
- codeentropy-2.4.3.dist-info/WHEEL,sha256=_yMHPaBV-s6fQMwUoo83NSdTCM3c8xXWFXRhqafN-7Y,81
64
- codeentropy-2.4.3.dist-info/METADATA,sha256=kZG1TxE_QkYCWS_xExG8KjSE4CkHgAZ_dJ_CDCfkQTo,6593
65
- codeentropy-2.4.3.dist-info/RECORD,,
61
+ codeentropy-2.5.0.dist-info/entry_points.txt,sha256=8Oa3H9ErsrGtx5Hc6hIHOw73wK3Ylkvfgtl_4cBb_GU,52
62
+ codeentropy-2.5.0.dist-info/licenses/LICENSE,sha256=hgMaAAVW1Q6dL586OHoz8vFO-6BYn-d2aAFBpVQylFc,1072
63
+ codeentropy-2.5.0.dist-info/WHEEL,sha256=lqN8jXt_QloFOGcx9kStvWbryhfEXoI2F2z-YURm350,81
64
+ codeentropy-2.5.0.dist-info/METADATA,sha256=OAUfGJ1rsObNQ9ftW76e4CZr3m6PFxG1ycdM6B5HdsI,6348
65
+ codeentropy-2.5.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: flit 4.0.2
2
+ Generator: flit 4.1.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any