Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
timeout-minutes: 30
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
python-version: ['3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v4

Expand Down
2 changes: 1 addition & 1 deletion .readthedocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ version: 2
build:
os: ubuntu-22.04
tools:
python: "3.10"
python: "3.11"

# Build documentation in the docs/ directory with Sphinx
sphinx:
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [1.8.0] - 2026-03-27

### Added

- `TransitionTable.__str__()` for formatted text display with auto-detected integer/float formatting.
- `TransitionTable._repr_html_()` for automatic HTML table rendering in Jupyter notebooks.
- Displaying Tables section in the transition tables guide.

### Changed

- Minimum Python version bumped from 3.10 to 3.11 (required by pymatgen-core).
- Runtime dependency changed from `pymatgen` to `pymatgen-core`; full `pymatgen` moved to dev dependencies.

## [1.7.0] - 2026-03-22

### Added
Expand Down
4 changes: 4 additions & 0 deletions docs/source/guides/transition_tables.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ probs = trajectory.transition_probabilities_by_label()

Both return a `TransitionTable` — a labelled square matrix with convenient access patterns.

## Displaying Tables

`TransitionTable` displays as a formatted text table via `print()` and renders as an HTML table automatically in Jupyter notebooks. Integer tables (counts) show whole numbers; float tables (probabilities) show three decimal places.

## Accessing the Data

`TransitionTable` provides several ways to access the data:
Expand Down
2 changes: 1 addition & 1 deletion docs/source/tutorials.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ You will learn how to:

**Argyrodite Site Analysis** — [`argyrodite_site_analysis.ipynb`](https://github.com/bjmorgan/site-analysis/blob/main/tutorials/argyrodite_site_analysis.ipynb)

A realistic example analysing lithium-ion site occupations in Li6PS5Cl argyrodite solid electrolytes with varying degrees of anion disorder. Uses MD trajectory data included in the repository at `tutorials/data/`.
A realistic example analysing lithium-ion site occupations in Li<sub>6</sub>PS<sub>5</sub>Cl argyrodite solid electrolytes with varying degrees of anion disorder. Uses MD trajectory data included in the repository at `tutorials/data/`.

You will learn how to:

Expand Down
2 changes: 1 addition & 1 deletion mypy.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[mypy]
python_version = 3.10
python_version = 3.11
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = False
Expand Down
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "site_analysis"
version = "1.7.0"
version = "1.8.0"
description = "Analysis tools for tracking ion migration through crystallographic sites"
readme = "README.md"
authors = [
Expand All @@ -15,11 +15,11 @@ classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]
requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = [
"numpy",
"scipy",
"pymatgen",
"pymatgen-core",
"tqdm",
"monty",
]
Expand All @@ -42,6 +42,7 @@ dev = [
"myst-parser", # For markdown in Sphinx
"matplotlib", # For plots in the docs
"numba", # For testing numba-accelerated containment
"pymatgen", # For io.vasp and symmetry in tests/benchmarks
]

[project.urls]
Expand Down
2 changes: 1 addition & 1 deletion site_analysis/reference_workflow/structure_aligner.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ def _apply_translation(self,
Translated structure
"""
# Create a copy of the structure
new_structure = structure.copy()
new_structure: Structure = structure.copy()

# Apply translation to all sites
for i, site in enumerate(new_structure):
Expand Down
52 changes: 50 additions & 2 deletions site_analysis/transition_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import html
from typing import Generic, Sequence, TypeVar

import numpy as np
Expand All @@ -32,8 +33,8 @@ class TransitionTable(Generic[TableKey]):

Raises:
ValueError: If *matrix* is not 2-D and square, if
``len(keys) != matrix.shape[0]``, or if *keys* contains
duplicates.
``len(keys) != matrix.shape[0]``, if *keys* contains
duplicates, or if *matrix* has a non-numeric dtype.
"""

__slots__ = ('_keys', '_matrix', '_key_to_index', '_frozen')
Expand All @@ -53,6 +54,10 @@ def __init__(
f"len(keys) ({len(keys)}) != matrix dimension "
f"({self._matrix.shape[0]})"
)
if not np.issubdtype(self._matrix.dtype, np.number):
raise ValueError(
f"matrix must have a numeric dtype, got {self._matrix.dtype}"
)
if len(set(keys)) != len(keys):
raise ValueError("keys must not contain duplicates")
self._keys: tuple[TableKey, ...] = keys
Expand Down Expand Up @@ -174,6 +179,49 @@ def __eq__(self, other: object) -> bool:

__hash__ = None # type: ignore[assignment]

def _formatted_cells(self) -> tuple[list[str], list[list[str]]]:
"""Return string keys and a grid of formatted cell values.

Auto-detects formatting from the matrix dtype: integers are
formatted with ``d``, floats with ``.3f``.
"""
fmt = 'd' if np.issubdtype(self._matrix.dtype, np.integer) else '.3f'
n = len(self._keys)
str_keys = [str(k) for k in self._keys]
cells = [[f'{self._matrix[i, j]:{fmt}}' for j in range(n)]
for i in range(n)]
return str_keys, cells

def __str__(self) -> str:
"""Return a formatted table of transition values."""
if len(self._keys) == 0:
return ''
str_keys, cells = self._formatted_cells()
col_width = max(
max(len(k) for k in str_keys),
max(len(v) for row in cells for v in row),
)
header = ' ' * col_width + ''.join(v.rjust(col_width + 2) for v in str_keys)
rows = []
for key, row in zip(str_keys, cells):
row_values = ''.join(v.rjust(col_width + 2) for v in row)
rows.append(key.rjust(col_width) + row_values)
return header + '\n' + '\n'.join(rows)

def _repr_html_(self) -> str:
"""Return an HTML table for Jupyter notebook display."""
if len(self._keys) == 0:
return ''
str_keys, cells = self._formatted_cells()
esc_keys = [html.escape(k) for k in str_keys]
header_cells = ''.join(f'<th>{k}</th>' for k in esc_keys)
header = f'<tr><th></th>{header_cells}</tr>'
rows = []
for key, row in zip(esc_keys, cells):
row_cells = ''.join(f'<td>{v}</td>' for v in row)
Comment thread
bjmorgan marked this conversation as resolved.
rows.append(f'<tr><th>{key}</th>{row_cells}</tr>')
return f'<table>{header}{"".join(rows)}</table>'
Comment thread
bjmorgan marked this conversation as resolved.

def __repr__(self) -> str:
return (
f"TransitionTable(keys={self._keys!r}, "
Expand Down
92 changes: 92 additions & 0 deletions tests/test_transition_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ def test_duplicate_keys_raises(self):
with self.assertRaises(ValueError):
TransitionTable(keys=("A", "A"), matrix=np.array([[0, 1], [2, 0]]))

def test_non_numeric_dtype_raises(self):
"""Test that a non-numeric matrix dtype raises ValueError."""
with self.assertRaises(ValueError):
TransitionTable(keys=("A", "B"), matrix=np.array([["x", "y"], ["z", "w"]]))

def test_frozen(self):
"""Test that TransitionTable is immutable."""
table = TransitionTable(keys=("A", "B"), matrix=np.array([[0, 1], [2, 0]]))
Expand Down Expand Up @@ -260,5 +265,92 @@ def test_repr_includes_keys_and_shape(self):
self.assertIn("2", r)


class TransitionTableStrTestCase(unittest.TestCase):
"""Tests for __str__."""

def test_str_integer_matrix(self):
"""Test that integer matrices are formatted without decimals."""
matrix = np.array([[0, 3], [2, 0]])
table = TransitionTable(keys=("A", "B"), matrix=matrix)
result = str(table)
self.assertIn('0', result)
self.assertIn('3', result)
self.assertNotIn('.', result)

def test_str_float_matrix(self):
"""Test that float matrices are formatted with 3 decimal places."""
matrix = np.array([[0.0, 0.75], [1.0, 0.0]])
table = TransitionTable(keys=("A", "B"), matrix=matrix)
result = str(table)
self.assertIn('0.750', result)
self.assertIn('1.000', result)

def test_str_empty_table(self):
"""Test that an empty table returns an empty string."""
table = TransitionTable(keys=(), matrix=np.empty((0, 0)))
self.assertEqual(str(table), '')

def test_str_alignment_with_varying_key_lengths(self):
"""Test that columns are aligned with keys of different lengths."""
matrix = np.array([[0, 1], [2, 0]])
table = TransitionTable(keys=("short", "much longer key"), matrix=matrix)
result = str(table)
lines = result.split('\n')
# All lines should have the same length
self.assertEqual(len(set(len(line) for line in lines)), 1)

def test_str_contains_all_keys(self):
"""Test that all keys appear in the string output."""
matrix = np.array([[0, 3, 1], [2, 0, 4], [5, 6, 0]])
table = TransitionTable(keys=("type 2", "type 4", "type 5"), matrix=matrix)
result = str(table)
self.assertIn('type 2', result)
self.assertIn('type 4', result)
self.assertIn('type 5', result)


class TransitionTableReprHtmlTestCase(unittest.TestCase):
"""Tests for _repr_html_."""

def test_repr_html_contains_table_tags(self):
"""Test that _repr_html_ returns valid HTML table markup."""
matrix = np.array([[0, 3], [2, 0]])
table = TransitionTable(keys=("A", "B"), matrix=matrix)
html = table._repr_html_()
self.assertTrue(html.startswith('<table>'))
self.assertTrue(html.endswith('</table>'))
self.assertIn('<th>A</th>', html)
self.assertIn('<th>B</th>', html)

def test_repr_html_empty_table(self):
"""Test that an empty table returns an empty string."""
table = TransitionTable(keys=(), matrix=np.empty((0, 0)))
self.assertEqual(table._repr_html_(), '')

def test_repr_html_float_formatting(self):
"""Test that float values are formatted with 3 decimal places."""
matrix = np.array([[0.0, 0.75], [1.0, 0.0]])
table = TransitionTable(keys=("A", "B"), matrix=matrix)
html = table._repr_html_()
self.assertIn('0.750', html)
self.assertIn('1.000', html)

def test_repr_html_integer_formatting(self):
"""Test that integer values are formatted without decimals."""
matrix = np.array([[0, 5], [2, 0]])
table = TransitionTable(keys=("A", "B"), matrix=matrix)
html = table._repr_html_()
self.assertNotIn('.', html)

def test_repr_html_escapes_keys(self):
"""Test that HTML special characters in keys are escaped."""
matrix = np.array([[0, 1], [1, 0]])
table = TransitionTable(keys=("<script>", "B&C"), matrix=matrix)
result = table._repr_html_()
self.assertNotIn('<script>', result)
self.assertIn('&lt;script&gt;', result)
self.assertIn('B&amp;C', result)


if __name__ == '__main__':
unittest.main()
Loading
Loading