Skip to content

Commit 8d46315

Browse files
authored
Support partial / mixed occupancy sites (#77)
Adds site-level partial and mixed occupancy support to StructureScene. Mixed sites are constructed by passing a Composition value in the species list; from_pymatgen propagates partial occupancies directly. Mixed sites render as VESTA-style pie wedges (one per constituent species) with vacancy fractions filled opaquely with the canvas background colour. Wedge angles are exact (proportional to occupancy); outlines are stroked at atom_outline_width in matplotlib points, matching pure-circle outlines. Three new RenderStyle fields control appearance: wedge_start_angle, show_wedge_edges, vacancy_colour. Bond detection and polyhedra firing match against constituent species of a mixed site; multiple matching specs draw exactly one bond per pair. Half-bond colour at a mixed-site end uses the dominant species' colour. AtomStyle.visible applies only to pure-string sites; mixed sites always render every constituent. set_atom_data(by_species=...) raises ValueError on conflicting writes to a single mixed site. Pure-string scenes are byte-identical in behaviour. Mixed sites are opt-in. Closes #76.
1 parent a92cc86 commit 8d46315

36 files changed

Lines changed: 2077 additions & 103 deletions

docs/api.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ Data model
2525
.. autoclass:: AtomStyle
2626
:members:
2727

28+
.. autoclass:: Composition
29+
:members:
30+
2831
.. autoclass:: BondSpec
2932
:members:
3033

docs/changelog.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,30 @@
11
Changelog
22
=========
33

4+
0.20.0
5+
------
6+
7+
- Sites can now be partially occupied or shared between multiple
8+
species. Pass a :class:`Composition` in
9+
:attr:`~hofmann.StructureScene.species` to declare a mixed site;
10+
the renderer draws it as a pie of wedges, one per constituent
11+
species, with vacancy fractions filled opaquely with the canvas
12+
background colour.
13+
:func:`~hofmann.from_pymatgen` propagates partial occupancies from
14+
the source structure.
15+
16+
- New render style fields :attr:`~hofmann.RenderStyle.wedge_start_angle`,
17+
:attr:`~hofmann.RenderStyle.show_wedge_edges`, and
18+
:attr:`~hofmann.RenderStyle.vacancy_colour` control the appearance
19+
of mixed-site wedges.
20+
21+
- :attr:`~hofmann.AtomStyle.visible` now applies only to pure-string
22+
site rows. Constituents of a :class:`~hofmann.Composition` are
23+
always rendered, regardless of their ``visible`` flag, and the
24+
legend follows suit. This keeps wedge rendering consistent with
25+
bond detection and rule lookups (which already operated on every
26+
constituent of a mixed site).
27+
428
0.19.0
529
------
630

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Quick example
4242

4343
legends
4444
colouring
45+
partial_occupancy
4546
animations
4647
styles
4748
interactive

docs/partial_occupancy.rst

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
.. _partial_occupancy:
2+
3+
Partial and mixed occupancy
4+
============================
5+
6+
Hofmann can render sites that are partially occupied (a fraction of an
7+
atom) or shared between multiple species (a solid solution). These
8+
sites appear as VESTA-style pie wedges, one wedge per constituent
9+
species, with wedge angles proportional to occupancy.
10+
11+
Constructing a mixed site
12+
--------------------------
13+
14+
Mixed sites are expressed by passing a :class:`Composition` value in
15+
the ``species`` list, in place of a plain string label::
16+
17+
import numpy as np
18+
from hofmann import Composition, StructureScene, Frame
19+
20+
fe_mn = Composition({"Fe": 0.7, "Mn": 0.3})
21+
scene = StructureScene(
22+
species=["Fe", fe_mn, fe_mn, "O"],
23+
frames=[Frame(coords=np.array([
24+
[0.0, 0.0, 0.0],
25+
[2.0, 0.0, 0.0],
26+
[4.0, 0.0, 0.0],
27+
[6.0, 0.0, 0.0],
28+
]))],
29+
)
30+
31+
Reusing a single ``Composition`` value across many rows is the
32+
recommended way to keep authoring concise.
33+
34+
Vacancies
35+
---------
36+
37+
A :class:`Composition` whose occupancies sum to less than one carries
38+
an implicit vacancy fraction::
39+
40+
fe_partial = Composition({"Fe": 0.7}) # 70% Fe + 30% vacancy
41+
42+
The vacancy fraction renders as an opaque wedge filled with the
43+
canvas background colour by default, so partial sites still read as
44+
solid circles with a "missing" slice. Set
45+
:attr:`~hofmann.RenderStyle.vacancy_colour` to override with an
46+
explicit colour (for example, ``"lightgrey"`` on a white canvas to
47+
make the vacancy stand out).
48+
49+
Loading from pymatgen
50+
---------------------
51+
52+
:func:`~hofmann.from_pymatgen` propagates partial occupancies
53+
directly: any :class:`pymatgen.core.PeriodicSite` whose ``species``
54+
mapping has more than one entry, or a single entry at occupancy less
55+
than one, becomes a :class:`Composition` in the resulting scene. No
56+
preprocessing or manual handling is required.
57+
58+
Customising appearance
59+
----------------------
60+
61+
By default a mixed site is drawn at a radius equal to the
62+
occupancy-weighted average of its constituents'
63+
:attr:`~hofmann.AtomStyle.radius`, normalised by the total species
64+
occupancy so that vacancy fractions do not shrink the site. Each
65+
wedge uses its species' :attr:`~hofmann.AtomStyle.colour`.
66+
For more specific styling — for example, colouring all mixed sites the
67+
same way to highlight disordered positions — use
68+
:meth:`~hofmann.StructureScene.set_atom_data` and the ``colour_by``
69+
parameter of the render methods, exactly as for pure sites.
70+
71+
The wedge layout itself is controlled by three render-style fields:
72+
73+
- :attr:`~hofmann.RenderStyle.wedge_start_angle` sets the starting
74+
orientation (default 12 o'clock).
75+
- :attr:`~hofmann.RenderStyle.show_wedge_edges` toggles radial edges
76+
between wedges (default on; set to ``False`` to stroke only the
77+
outer arc).
78+
- :attr:`~hofmann.RenderStyle.vacancy_colour` overrides the vacancy
79+
fill (default: canvas background colour).
80+
81+
Visibility of constituent species
82+
---------------------------------
83+
84+
:attr:`~hofmann.AtomStyle.visible` is a per-species flag — setting it
85+
to ``False`` hides every pure-string site of that species. It does
86+
**not** apply to constituents of a :class:`Composition`: a mixed site
87+
is always drawn with all of its constituents, regardless of any
88+
constituent's ``visible`` flag. This avoids the visually inconsistent
89+
state where a constituent is hidden in the wedge rendering but its
90+
species still attracts bonds and matches rule lookups. To
91+
de-emphasise specific mixed sites, use
92+
:meth:`~hofmann.StructureScene.set_atom_data` with ``colour_by`` to
93+
recolour them at the row level.
94+
95+
Bonding and polyhedra
96+
---------------------
97+
98+
:class:`BondSpec` and :class:`PolyhedronSpec` rules fire on a mixed
99+
site whenever any constituent species satisfies the rule. A 70 / 30
100+
Fe / Mn site with both ``("Fe", "O")`` and ``("Mn", "O")`` bond rules
101+
defined produces exactly one bond per neighbour, drawn at whichever
102+
matching cutoff is permissive enough. Vacancy fractions never
103+
participate in bonding.
104+
105+
The half-bond at a mixed-site end uses the dominant species'
106+
colour — the species with the highest occupancy in the composition,
107+
with alphabetical tiebreak. The wedges, not the bond, communicate the
108+
full composition.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "hofmann"
7-
version = "0.19.0"
7+
version = "0.20.0"
88
description = "A modern Python reimagining of the XBS ball-and-stick crystal structure viewer"
99
readme = "README.md"
1010
requires-python = ">=3.11"

src/hofmann/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
CellEdgeStyle,
2929
CmapSpec,
3030
Colour,
31+
Composition,
3132
Frame,
3233
LegendItem,
3334
LegendStyle,
@@ -55,6 +56,7 @@
5556
"CmapSpec",
5657
"COVALENT_RADII",
5758
"Colour",
59+
"Composition",
5860
"ELEMENT_COLOURS",
5961
"Frame",
6062
"LegendItem",

src/hofmann/construction/bonds.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
import numpy as np
66

77
from hofmann.model import Bond, BondSpec
8+
from hofmann.model._util import _site_species
9+
from hofmann.model.composition import Composition
810

911

1012
def compute_bonds(
11-
species: tuple[str, ...],
13+
species: tuple[str | Composition, ...],
1214
coords: np.ndarray,
1315
bond_specs: list[BondSpec],
1416
lattice: np.ndarray | None = None,
@@ -53,8 +55,10 @@ def compute_bonds(
5355
f"{coords.shape[0]} rows"
5456
)
5557

56-
# Pre-compute unique species for efficient matching.
57-
unique_species = list(set(species))
58+
unique_species_set: set[str] = set()
59+
for site in species:
60+
unique_species_set |= _site_species(site)
61+
unique_species = list(unique_species_set)
5862

5963
# Vectorised pairwise difference vectors: diff[i,j] = coords[i] - coords[j].
6064
diff = coords[:, np.newaxis, :] - coords[np.newaxis, :, :]
@@ -70,7 +74,7 @@ def compute_bonds(
7074

7175

7276
def _compute_bonds_direct(
73-
species: tuple[str, ...],
77+
species: tuple[str | Composition, ...],
7478
diff: np.ndarray,
7579
bond_specs: list[BondSpec],
7680
unique_species: list[str],
@@ -121,7 +125,7 @@ def _inscribed_sphere_radius(lattice: np.ndarray) -> float:
121125

122126

123127
def _compute_bonds_periodic(
124-
species: tuple[str, ...],
128+
species: tuple[str | Composition, ...],
125129
diff: np.ndarray,
126130
bond_specs: list[BondSpec],
127131
lattice: np.ndarray,
@@ -160,7 +164,7 @@ def _compute_bonds_periodic(
160164

161165

162166
def _compute_bonds_mic(
163-
species: tuple[str, ...],
167+
species: tuple[str | Composition, ...],
164168
diff_frac: np.ndarray,
165169
bond_specs: list[BondSpec],
166170
lattice: np.ndarray,
@@ -200,7 +204,7 @@ def _compute_bonds_mic(
200204

201205

202206
def _compute_bonds_multi_image(
203-
species: tuple[str, ...],
207+
species: tuple[str | Composition, ...],
204208
diff_frac: np.ndarray,
205209
bond_specs: list[BondSpec],
206210
lattice: np.ndarray,
@@ -268,15 +272,23 @@ def _compute_bonds_multi_image(
268272

269273
def _species_pair_mask(
270274
spec: BondSpec,
271-
species: tuple[str, ...],
275+
species: tuple[str | Composition, ...],
272276
unique_species: list[str],
273277
) -> np.ndarray:
274-
"""Build a boolean (n, n) mask for species pairs matching *spec*."""
278+
"""Build a boolean (n, n) mask for species pairs matching *spec*.
279+
280+
For a mixed site, the row matches the spec's species if any
281+
constituent species satisfies the (fnmatch-aware) pattern.
282+
"""
275283
sp_a, sp_b = spec.species
276284
match_a = {s for s in unique_species if fnmatch(s, sp_a)}
277285
match_b = {s for s in unique_species if fnmatch(s, sp_b)}
278-
mask_a = np.array([s in match_a for s in species])
279-
mask_b = np.array([s in match_b for s in species])
286+
mask_a = np.array([
287+
bool(_site_species(site) & match_a) for site in species
288+
])
289+
mask_b = np.array([
290+
bool(_site_species(site) & match_b) for site in species
291+
])
280292
return (
281293
(mask_a[:, np.newaxis] & mask_b[np.newaxis, :])
282294
| (mask_b[:, np.newaxis] & mask_a[np.newaxis, :])

src/hofmann/construction/polyhedra.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
from scipy.spatial import ConvexHull, Delaunay, QhullError
88

99
from hofmann.model import Bond, Polyhedron, PolyhedronSpec
10+
from hofmann.model._util import _site_species
11+
from hofmann.model.composition import Composition
1012

1113

1214
def compute_polyhedra(
13-
species: tuple[str, ...],
15+
species: tuple[str | Composition, ...],
1416
coords: np.ndarray,
1517
bonds: list[Bond],
1618
polyhedra_specs: list[PolyhedronSpec],
@@ -54,7 +56,7 @@ def compute_polyhedra(
5456
for i, sp in enumerate(species):
5557
if i in claimed:
5658
continue
57-
if not fnmatch(sp, spec.centre):
59+
if not any(fnmatch(s, spec.centre) for s in _site_species(sp)):
5860
continue
5961

6062
neighbours = sorted(adjacency.get(i, set()))

0 commit comments

Comments
 (0)