Skip to content

Commit c7eeefe

Browse files
authored
Add transition_counts() and transition_probabilities() methods
Add methods to aggregate per-site transition data into a TransitionTable, a labelled square matrix with convenient access patterns (.matrix, .get(), .to_dict(), .reorder()). - transition_counts(by='site'|'label') returns raw hop counts - transition_probabilities(by='site'|'label') returns row-normalised probabilities (rows with no outgoing transitions remain as zeros) - by='label' aggregates across sites sharing a label; unlabelled sites are skipped with a warning if transitions are dropped - Optional keys parameter for custom row/column ordering - Unknown destination indices raise ValueError Closes #45
1 parent 766538f commit c7eeefe

5 files changed

Lines changed: 772 additions & 2 deletions

File tree

site_analysis/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@
1919
create_trajectory_with_polyhedral_sites,
2020
create_trajectory_with_dynamic_voronoi_sites
2121
)
22+
from site_analysis.transition_table import TransitionTable

site_analysis/trajectory.py

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,18 @@
2727
... .build())
2828
"""
2929

30+
import warnings
3031
from collections import Counter
3132
from collections.abc import Iterable
32-
from typing import Sequence
33+
from typing import Literal, Sequence
3334

35+
import numpy as np
3436
from tqdm.auto import tqdm
3537

3638
from pymatgen.core import Structure
3739

40+
from .transition_table import TransitionTable
41+
3842
from .atom import Atom
3943
from .dynamic_voronoi_site import DynamicVoronoiSite
4044
from .dynamic_voronoi_site_collection import DynamicVoronoiSiteCollection
@@ -161,7 +165,126 @@ def site_labels(self) -> list[str | None]:
161165
A list of site labels (or None for sites without labels).
162166
"""
163167
return [s.label for s in self.sites]
164-
168+
169+
def transition_counts(
170+
self,
171+
by: Literal['site', 'label'] = 'site',
172+
*,
173+
keys: Sequence[int] | Sequence[str] | None = None,
174+
) -> TransitionTable:
175+
"""Return transition counts as a :class:`TransitionTable`.
176+
177+
Args:
178+
by: Aggregation key. ``'site'`` (default) keys by site index;
179+
``'label'`` aggregates by site label (unlabelled sites are
180+
skipped, with a warning if any transitions are dropped).
181+
keys: Optional key ordering for rows and columns. If ``None``,
182+
keys are sorted. Must be a permutation of the default keys.
183+
184+
Returns:
185+
A :class:`TransitionTable` of integer counts.
186+
187+
Raises:
188+
ValueError: If ``by`` is not ``'site'`` or ``'label'``, if
189+
*keys* does not match the default key set, or if a site
190+
has a transition to an unknown site index.
191+
"""
192+
if by == 'site':
193+
site_keys = tuple(sorted(s.index for s in self.sites))
194+
index_set = set(site_keys)
195+
idx_lookup = {k: i for i, k in enumerate(site_keys)}
196+
n = len(site_keys)
197+
matrix = np.zeros((n, n), dtype=int)
198+
for site in self.sites:
199+
row = idx_lookup[site.index]
200+
for dest, count in site.transitions.items():
201+
if dest not in index_set:
202+
raise ValueError(
203+
f"Site {site.index} has a transition to unknown "
204+
f"site index {dest}."
205+
)
206+
matrix[row, idx_lookup[dest]] = count
207+
result_keys: tuple[int, ...] | tuple[str, ...] = site_keys
208+
elif by == 'label':
209+
all_site_indices = {s.index for s in self.sites}
210+
index_to_label = {
211+
s.index: s.label for s in self.sites if s.label is not None
212+
}
213+
label_keys = tuple(sorted(set(index_to_label.values())))
214+
label_lookup = {k: i for i, k in enumerate(label_keys)}
215+
n = len(label_keys)
216+
matrix = np.zeros((n, n), dtype=int)
217+
dropped = 0
218+
for site in self.sites:
219+
src_label = index_to_label.get(site.index)
220+
if src_label is None:
221+
for dest in site.transitions:
222+
if dest not in all_site_indices:
223+
raise ValueError(
224+
f"Site {site.index} has a transition to unknown "
225+
f"site index {dest}."
226+
)
227+
dropped += sum(site.transitions.values())
228+
continue
229+
for dest, count in site.transitions.items():
230+
if dest not in all_site_indices:
231+
raise ValueError(
232+
f"Site {site.index} has a transition to unknown "
233+
f"site index {dest}."
234+
)
235+
dest_label = index_to_label.get(dest)
236+
if dest_label is None:
237+
dropped += count
238+
continue
239+
matrix[label_lookup[src_label], label_lookup[dest_label]] += count
240+
if dropped > 0:
241+
warnings.warn(
242+
f"{dropped} transition(s) involving unlabelled sites were "
243+
f"excluded from the label-aggregated counts.",
244+
stacklevel=2,
245+
)
246+
result_keys = label_keys
247+
else:
248+
raise ValueError(f"Invalid value for 'by': {by!r}. Must be 'site' or 'label'.")
249+
table = TransitionTable(keys=result_keys, matrix=matrix)
250+
if keys is not None:
251+
return table.reorder(keys)
252+
return table
253+
254+
def transition_probabilities(
255+
self,
256+
by: Literal['site', 'label'] = 'site',
257+
*,
258+
keys: Sequence[int] | Sequence[str] | None = None,
259+
) -> TransitionTable:
260+
"""Return row-normalised transition probabilities as a :class:`TransitionTable`.
261+
262+
Each row is normalised so that its values sum to 1.0. Rows with no
263+
outgoing transitions remain as all zeros.
264+
265+
Args:
266+
by: Aggregation key. ``'site'`` (default) keys by site index;
267+
``'label'`` aggregates by site label (unlabelled sites are
268+
skipped, with a warning if any transitions are dropped).
269+
keys: Optional key ordering for rows and columns. If ``None``,
270+
keys are sorted. Must be a permutation of the default keys.
271+
272+
Returns:
273+
A :class:`TransitionTable` of float probabilities.
274+
275+
Raises:
276+
ValueError: If ``by`` is not ``'site'`` or ``'label'``, if
277+
*keys* does not match the default key set, or if a site
278+
has a transition to an unknown site index.
279+
"""
280+
counts = self.transition_counts(by=by, keys=keys)
281+
count_data = counts.matrix.astype(float)
282+
row_sums = count_data.sum(axis=1)
283+
probs = np.zeros_like(count_data)
284+
nonzero = row_sums > 0
285+
probs[nonzero] = count_data[nonzero] / row_sums[nonzero, np.newaxis]
286+
return TransitionTable(keys=counts.keys, matrix=probs)
287+
165288
@property
166289
def atom_sites(self) -> list[int | None]:
167290
"""Return the sites that each atom currently occupies.

site_analysis/transition_table.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Transition table for storing labelled transition data.
2+
3+
Provides the :class:`TransitionTable` class, which stores transition
4+
counts or probabilities as a labelled square matrix with convenient
5+
access patterns.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Sequence, overload
11+
12+
import numpy as np
13+
14+
15+
class TransitionTable:
16+
"""A labelled square matrix of transition data.
17+
18+
Stores transition counts or probabilities with named keys for
19+
rows and columns. Provides multiple access patterns:
20+
21+
- ``.matrix`` — the raw (read-only) :class:`numpy.ndarray`
22+
- ``.get(from_key, to_key)`` — key-based lookup (matched types enforced)
23+
- ``.to_dict()`` — square dict-of-dicts
24+
- ``.reorder(keys)`` — return a new table with reordered rows/columns
25+
26+
Args:
27+
keys: Row and column labels (site indices or site labels).
28+
matrix: A square 2-D numpy array of transition values.
29+
30+
Raises:
31+
ValueError: If *matrix* is not 2-D and square, if
32+
``len(keys) != matrix.shape[0]``, or if *keys* contains
33+
duplicates.
34+
"""
35+
36+
__slots__ = ('_keys', '_matrix', '_key_to_index', '_frozen')
37+
38+
def __init__(
39+
self,
40+
keys: tuple[int, ...] | tuple[str, ...],
41+
matrix: np.ndarray,
42+
) -> None:
43+
self._matrix = np.array(matrix, copy=True)
44+
if self._matrix.ndim != 2 or self._matrix.shape[0] != self._matrix.shape[1]:
45+
raise ValueError(
46+
f"matrix must be a square 2-D array, got shape {self._matrix.shape}"
47+
)
48+
if len(keys) != self._matrix.shape[0]:
49+
raise ValueError(
50+
f"len(keys) ({len(keys)}) != matrix dimension "
51+
f"({self._matrix.shape[0]})"
52+
)
53+
if len(set(keys)) != len(keys):
54+
raise ValueError("keys must not contain duplicates")
55+
self._keys = keys
56+
self._key_to_index = {k: i for i, k in enumerate(keys)}
57+
self._matrix.flags.writeable = False
58+
self._frozen = True
59+
60+
@property
61+
def keys(self) -> tuple[int, ...] | tuple[str, ...]:
62+
"""Row and column labels."""
63+
return self._keys
64+
65+
@property
66+
def matrix(self) -> np.ndarray:
67+
"""The transition data as a read-only 2-D numpy array."""
68+
return self._matrix
69+
70+
@overload
71+
def get(self, from_key: int, to_key: int) -> int | float: ...
72+
@overload
73+
def get(self, from_key: str, to_key: str) -> int | float: ...
74+
75+
def get(self, from_key: int | str, to_key: int | str) -> int | float:
76+
"""Look up a single transition value by key.
77+
78+
Args:
79+
from_key: The source key (row).
80+
to_key: The destination key (column).
81+
82+
Returns:
83+
The transition value at ``(from_key, to_key)``.
84+
85+
Raises:
86+
KeyError: If either key is not present in the table.
87+
"""
88+
try:
89+
i = self._key_to_index[from_key]
90+
except KeyError:
91+
raise KeyError(from_key) from None
92+
try:
93+
j = self._key_to_index[to_key]
94+
except KeyError:
95+
raise KeyError(to_key) from None
96+
value: int | float = self._matrix[i, j].item()
97+
return value
98+
99+
def to_dict(self) -> dict[int | str, dict[int | str, int | float]]:
100+
"""Convert to a square dict-of-dicts.
101+
102+
Returns:
103+
A dict ``{from_key: {to_key: value}}`` mirroring the matrix.
104+
"""
105+
return {
106+
self._keys[i]: {
107+
self._keys[j]: self._matrix[i, j].item()
108+
for j in range(len(self._keys))
109+
}
110+
for i in range(len(self._keys))
111+
}
112+
113+
def reorder(self, keys: Sequence[int] | Sequence[str]) -> TransitionTable:
114+
"""Return a new table with rows and columns reordered.
115+
116+
Args:
117+
keys: The desired key ordering. Must contain exactly
118+
the same keys as the current table.
119+
120+
Returns:
121+
A new :class:`TransitionTable` with reordered rows and columns.
122+
123+
Raises:
124+
ValueError: If *keys* does not match the current key set exactly.
125+
"""
126+
new_keys: tuple[int, ...] | tuple[str, ...] = tuple(keys) # type: ignore[assignment]
127+
if len(new_keys) != len(self._keys) or set(new_keys) != set(self._keys):
128+
missing = sorted(set(self._keys) - set(new_keys))
129+
extra = sorted(set(new_keys) - set(self._keys))
130+
parts = []
131+
if missing:
132+
parts.append(f"missing keys: {missing!r}")
133+
if extra:
134+
parts.append(f"unknown keys: {extra!r}")
135+
raise ValueError(
136+
f"keys must be a permutation of the current keys; {'; '.join(parts)}"
137+
)
138+
order = [self._key_to_index[k] for k in new_keys]
139+
reordered = self._matrix[np.ix_(order, order)]
140+
return TransitionTable(keys=new_keys, matrix=reordered)
141+
142+
def __eq__(self, other: object) -> bool:
143+
if not isinstance(other, TransitionTable):
144+
return NotImplemented
145+
return self._keys == other._keys and np.array_equal(self._matrix, other._matrix)
146+
147+
__hash__ = None # type: ignore[assignment]
148+
149+
def __repr__(self) -> str:
150+
return (
151+
f"TransitionTable(keys={self._keys!r}, "
152+
f"shape={self._matrix.shape[0]}x{self._matrix.shape[1]})"
153+
)
154+
155+
def __setattr__(self, name: str, value: object) -> None:
156+
if getattr(self, '_frozen', False):
157+
raise AttributeError("TransitionTable is immutable")
158+
super().__setattr__(name, value)

0 commit comments

Comments
 (0)