Superspace: incommensurate crystallography in (3+d) dimensions¶
pyextal.superspace handles modulated structures — crystals whose diffraction
pattern is sharp but does not sit on a three-dimensional lattice, so three
integers cannot index it.
Every reflection is labelled by \(3+d\) integers:
Main reflections have every \(m_j = 0\); the rest are satellites, of order
\(\sum_j |m_j|\). d = 1, 2, 3 are all supported.
The worked example is 1T-TaS\(_2\) — see
example/superspace/TaS2_superspace_SAED.ipynb.
from pyextal.superspace import tas2, saed_pattern, plot_pattern
lat, atoms = tas2.structure() # the published refinement
pattern = saed_pattern(lat, atoms, zone=(0, 0, 1),
condition=tas2.reflection_condition)
plot_pattern(pattern)
tas2.structure() returns the model refined by Spijkerman et al. — 21
harmonics on Ta and 12 on S, in superspace group \(P\bar3(\alpha\beta\tfrac13)\).
model='standin' gives a small invented triple-q wave instead, which is faster
and useful where the real amplitudes are not the point.
Modules¶
Module |
Role |
|---|---|
|
|
|
Enumerating and labelling reflections; arrays are |
|
Displacive and occupational modulation as Fourier series in internal space; |
|
\(F(H)\) by quadrature over internal space, plus the Bessel closed form |
|
Superspace operations, the \(\varepsilon\) solver, reflection transformation |
|
Form factors read from the engine’s own |
|
Kinematic zone-axis patterns with relrod weighting |
|
Commensurate supercell → |
|
Drives the Fortran engine with a modulated structure (Stage B) |
|
The 1T-TaS\(_2\) example structure |
Rank: how many dimensions the structure actually needs¶
d q-vectors give a \((3{+}d)\)D description only if they are independent
modulo the main lattice. If some integer combination \(\sum_j n_j \mathbf q_j\)
lands on a reciprocal lattice point, satellites coincide and a refinement in
that many dimensions fits parameters the data cannot separate.
1T-TaS\(_2\) is the case in point. Its charge-density wave is triple-q, three arms related by the three-fold axis, which suggests \((3{+}3)\)D. But with the refined wave vectors the third arm is exactly \(\mathbf q_2 - \mathbf q_1\) — no lattice vector needed to make it work — so the three span a rank-2 module and the correct description is \((3{+}2)\)D, which is what the published refinement used. The paper’s own satellite grouping says the same: it lists \((1,0)\), \((0,1)\) and \((-1,1)\) as one symmetry-equivalent set.
q1, q2 = tas2.published_q('tripled')
lat = SuperspaceLattice(tas2.CELL_TRIPLED, np.vstack([q1, q2, q2 - q1]))
# UserWarning: ... not independent modulo the main lattice: q1 - q2 + q3 = 0
lat.rank() # 2, not 3
lat.integer_relations() # [(array([1, -1, 1]), array([0, 0, 0]))]
Commensurate (rational) q-vectors are rank-deficient too, but that is a legitimate thing to build, so it gets a different message pointing at the supercell rather than reporting a mistake.
Satellite order is not sum |m|¶
The obvious measure of how far out a satellite is, \(\sum_j |m_j|\), is not invariant under the crystal’s symmetry once \(d > 1\), and using it as a cutoff silently destroys the symmetry of everything downstream.
In 1T-TaS\(_2\) the three-fold sends \((1,0) \to (0,1) \to (-1,1)\). The first two have \(\sum|m| = 1\) and the third has 2, so a cutoff at 1 keeps two thirds of a symmetry orbit and the computed pattern comes out visibly not three-fold. The reference makes the same point in section II: “‘order of a satellite’ cannot be represented by a single number anymore”.
What is invariant is the length of the modulation part,
\(|\sum_j m_j \mathbf q_j|\), since any symmetry operation permutes the
\(\mathbf q\)-vectors. enumerate_reflections cuts on that, in units of the
shortest \(\mathbf q\), and ReflectionSet.order ranks it, so
symmetry-equivalent satellites always share an order. For \(d = 1\) the two
measures coincide. ReflectionSet.index_sum still gives \(\sum|m|\) for
labelling — just never filter on it.
The structure factor¶
Each atom is smeared over the internal coordinate, so the sum over atoms becomes a sum of integrals:
structure_factor evaluates this by quadrature. For a single harmonic the
integral is \(J_{-m}(2\pi\,\mathbf S\!\cdot\!\mathbf A)\), and the quadrature
agrees with it to ~1e-22 relative — the rectangle rule converges exponentially
on a smooth periodic integrand, so nothing is given up by taking the general
route that also handles many harmonics and occupational modulation.
The one thing that fails silently is an under-resolved internal grid, whose
required size grows with satellite order and modulation amplitude.
auto_n_internal sizes it, and structure_factor uses that by default.
Form factors come from sftabl.sct, the same Cromer-Mann table the Fortran
engine reads, with the engine’s own Mott-Bethe conversion — so the kinematic and
dynamical halves use identical scattering.
Dynamical calculations: the commensurate approximant¶
When the q-vectors are rational the structure is not incommensurate at all — it is an ordinary periodic crystal with a bigger cell. That supercell is plain 3D, so the existing Bloch-wave engine computes it with no modification.
lat, atoms = tas2.structure(commensurate=True) # published, approximated
res = dynamical_pattern(lat, atoms, thickness=200.0, gmax=0.7)
res['H'] # superspace indices of every output beam
res['intensity'] # dynamical intensities
supercell_matrix() derives the cell from the q-vectors alone — for
1T-TaS\(_2\) it returns the \(\sqrt{13}a \times \sqrt{13}a \times 3c\) “Star of
David” cell, 117 atoms. assign_superspace_index maps the engine’s supercell
reflections back to \((3{+}d)\) labels, so the dynamical result lines up with the
kinematic one reflection by reflection.
The supercell reciprocal lattice is \(|\det M|\) times denser than the parent’s,
so keep gmax modest: a value that is unremarkable for the basic cell can
select thousands of beams.
The 1T-TaS2 model¶
tas2 carries Tables III–V of the reference verbatim. Two settings are available:
setting='tripled'(default) — \(a_0 \times a_0 \times 3c_0\), the cell the refinement uses. The wave vectors are purely in-plane and the three layers come from superspace centring translations that carry an internal phase shift. Nine atoms.setting='basic'— \(a_0 \times a_0 \times c_0\), with the wave vectors picking up components of \(\pm\tfrac13\) along \(\mathbf c_0^*\).published_q,tripled_to_basicandbasic_to_tripledsupport it, and the indexing is exact: both settings place every reflection identically to 2e-16, and give the same structure factors to 8e-16 for the unmodulated structure.The modulated structure is only available in the tripled setting. In the basic cell \(\mathbf q\) has a \(\mathbf c^*\) component, so an atom’s own \(z\) enters its internal coordinate — \(\bar x_{basic} = \bar x_{tripled} + q_z z\) — while the published modulation functions are written against the tripled convention, where it does not. The compensating phase is atom-dependent; neither a constant internal shift nor a per-atom shift by \(q_z z\) reproduces the tripled model (both leave 2–4% residuals).
published_atomsraises rather than return a structure that is quietly wrong.
The centring imposes \(l + 2m_1 + m_2 \equiv 0 \pmod 3\)
(tas2.reflection_condition), which has a consequence worth knowing: the [001]
zero-order Laue zone is \(l = 0\), so it needs \(2m_1 + m_2 \equiv 0\), and no
first-order satellite satisfies that. A [001] pattern shows the main
reflections and the second-order \((1,1)\)-type satellites; the first-order ones
live in \(l_0 = n \pm \tfrac13\).
Nothing filters the forbidden reflections — the three centred layers cancel them to fourteen orders of magnitude, which is the check that the internal phase shifts are right.
What was verified¶
Tables IV and V quote amplitudes “in angstrom” for the tripled cell, which is ambiguous, so the convention was checked against the paper’s own figures rather than assumed:
quantity |
recomputed |
paper |
|---|---|---|
Ta max \(|u_{xy}|\) (Fig. 6) |
0.3080 Å |
0.308 Å |
Ta–Ta(100) shortest (Figs. 3–5) |
3.0865 Å |
3.086 Å |
Ta–Ta(100) longest (Figs. 3–5) |
3.8079 Å |
3.806 Å |
S \(u_z\) max (Fig. 10) |
+0.116 Å |
+0.106 Å |
S \(u_z\) min (Fig. 10) |
−0.114 Å |
−0.124 Å |
The three Ta numbers agree to four significant figures, fixing the convention as displacements in Å along \(a\), \(b\), \(c\) of the tripled cell. The S extremes are about 0.01 Å out; that is not a transcription error (flipping any harmonic group’s sign makes it worse, and every Table I symmetry relation closes), and Fig. 10 plots a particular symmetry copy read off contours spaced 0.0177 Å apart.
One value is corrected against the source. Table V’s \(B^y\) for the harmonic \((-1,1)\) is entered as \(-0.0107\), not \(+0.0107\): Table I’s own relation \(B^y_{-m_1-m_2,m_1} = -B^x_{-m_2,m_1+m_2}\) requires the minus, and with the plus the sulfur modulation breaks its three-fold site symmetry by 0.043 Å — a third of its amplitude. The PDF renders minus as the digit 2 and this one is lost on extraction. Every dependent amplitude in both tables is checked against Table I in the test suite.
Table III places S at \((1/3, 1/3, 0.0862)\) while Fig. 1 gives \(\pm(1/3, 2/3, z)\) and §V C refers to \((2/3, 1/3, 0.086)\). The standard CdI2 positions \(\pm(1/3, 2/3, z)\) are used, as the only choice giving Ta octahedral sulfur coordination — corroborated by the refinement reporting every Ta cosine amplitude as zero, which requires Ta on the inversion centre.
Modulated structures inside the Bloch engine¶
The engine carries superspace indices and a modulated structure factor of its own:
blochwave.superspaceholdsdmod, the wave vectorsqvec, the satellite indiceshklm(parallel todifpar.hkl), and the per-atom harmonics.difpar.gvecholds each beam’s effective index \(\mathbf{hkl} + \sum_j m_j \mathbf q_j\), which is not integral and so cannot live indifpar.hkl. Every geometry calculation readsgvec, so the ordinary and modulated cases follow one path.sfact_modintegrates the modulated structure factor over internal space, andsetughcalls it instead ofsfactwheneverdmod > 0.
from pyextal.superspace import engine, enumerate_reflections, plot_image, tas2
lat, atoms = tas2.structure(setting='tripled')
beams = enumerate_reflections(lat, gmax=0.55, max_index=3, max_order=2,
condition=tas2.reflection_condition)
# the whole sequence: write the cell, parse, load the modulation, solve
pattern = engine.dynamical_saed(lat, atoms, beams.H, thickness=200.0,
zone=(0, 0, 1), hv=200.0)
plot_image(pattern, sigma=3.0, gamma=1.5)
or step by step, to keep the engine loaded for something else:
from pyextal.callBloch import bloch_parse
engine.write_dat('mod.dat', lat, atoms, beams.H, aper=2.0)
bloch_parse('mod.dat')
engine.load(lat, atoms, beams.H) # U(g,h) is now modulated
engine.unload() # and back to the ordinary matrix
The output aperture is applied during parsing, before the modulation
exists, so it can only see the main index. dynamical_saed therefore keeps
every beam and cuts on the true effective \(|g|\) afterwards; if you drive the
engine yourself, make aper generous and filter in Python.
What is verified¶
check |
result |
|---|---|
|
bit-identical |
|
9e-8 |
|
9e-8 |
Fortran |
2.3e-5 |
The first is the gate: gvec is seeded as real(hkl), which is exact, so an
ordinary crystal is untouched. The last is the real validation — the Fortran
integrates over internal space in its own loop and the Python does it in numpy,
sharing only the Cromer-Mann table.
Limits¶
P1 only. Relating the modulation functions of symmetry-equivalent atoms needs a phase that depends on each atom’s own position;
loadrefuses anything else rather than putting the wrong modulation on generated atoms.Explicit beam lists.
seleenumerates integer indices and cannot reach a satellite, so write thehklblock out.engine.write_datdoes.The
.datgrammar is untouched. The modulation is pushed in from Python after parsing rather than parsed from the file, which keeps the order-sensitive format out of the blast radius.One crystal per engine — the engine keeps the parsed crystal and the loaded modulation in module-global state, and
terminate()ends it for the life of the process.engine.run_isolated(func, ...)puts each calculation in a fresh interpreter, which is how a notebook compares two crystals or two routes.
The commensurate phase¶
Below 180 K the CDW locks in and the in-plane cell becomes
\(\sqrt{13}a \times \sqrt{13}a\) — 13 basic cells, the Star of David.
tas2.commensurate_structure() builds that model, in the paper’s own
\((3\!+\!2)\) basis: \(\mathbf q_1 = (\sigma_1, \sigma_2, 0)\) and
\(\mathbf q_2 = (-\sigma_2, \sigma_1{+}\sigma_2, 0)\) with
\((\sigma_1, \sigma_2) = (3/13,\, 1/13)\), which is tas2.SIGMA_C.
The basis is load-bearing. The harmonic labels \((n_1, n_2)\) of Tables IV and V are written against that pair, so expressing the same lattice through the three-fold triplet re-labels every published amplitude. It is silent and it is large: the site residual goes from \(4.7\times10^{-4}\) Å to \(0.57\) Å, and the diffraction pattern stops being three-fold.
The full C phase also stacks with a \(13c\) repeat. That is not represented,
and cannot be in this basis: with arms \(\{\mathbf q_1, \mathbf q_2,
\mathbf q_2 - \mathbf q_1\}\) the \(c\) components are \(q_z\), \(q_z\) and \(0\), so a
nonzero \(q_z\) makes the third arm inequivalent to the other two and
epsilon_for reports the three-fold as not a symmetry. Representing the
stacking needs a 13-fold \(c\) axis carrying the layer shifts as centring
translations, exactly as the paper puts the thirds in a tripled cell — and the
C-phase stacking sequence that would fix those translations is not published.
commensurate_structure(cz=...) accepts the parameter for exploring it and
warns that the result is not the C phase.
Coincident reflections¶
Once \(\mathbf q\) is rational, the Fourier module collapses onto a 3D lattice and the map from a superspace index to a reciprocal-lattice point stops being injective. If \(\mathbf c \cdot \mathbf q\) is an integer vector \(d\mathbf h\), then \((\mathbf h, \mathbf m)\) and \((\mathbf h - d\mathbf h, \mathbf m + \mathbf c)\) are the same physical reflection, and the crystal has one Fourier coefficient there — the coherent sum of their amplitudes.
Two places need this, and they need it in different forms:
from pyextal.superspace import merge_coincident, coincidence_coset
rep, group, ngroups = merge_coincident(lat, H) # 437 indices -> 305 reflections
c, dh = coincidence_coset(lat) # 37 members within |dh| < 1.05
The reflection list.
saed_patterncallsmerge_coincidentby itself wheneverlat.is_commensurate()(override withmerge=). Without it the one intensity is split between the coincident indices in whatever way the enumeration happened to order them, so symmetry-equivalent spots get different shares: the worst three-fold mismatch is \(0.63\) unmerged against \(2.5\times10^{-3}\) merged.Every \(U(\mathbf g, \mathbf h)\) of the dynamical matrix. Here merging the list is not enough — the index differences \(\mathbf H_i - \mathbf H_j\) reach coincidences that are not in the beam list at all — so the sum happens inside the Fortran structure factor.
engine.loadswitches it on whenever the lattice is commensurate (override withcoset=, size it withcoset_dh=).
Without the coset the superspace engine computes the \(t\)-averaged structure rather than the actual crystal: integrating over a continuous internal coordinate averages over the modulation phase, which for an incommensurate structure is right and for a commensurate one is not. Its satellites come out 41% wrong.
Truncate on the shift, never on the satellite order. The coset is a lattice and so infinite. \(|\mathbf c \cdot \mathbf q|\) is a reciprocal-space length and therefore invariant under the point group, so cutting there keeps whole shells and converges monotonically. \(\sum_j |c_j|\) is not invariant: cutting there admits part of a symmetry orbit and drops the rest, which adds asymmetry. Measured against the exact supercell:
cut |
members |
worst relative difference |
|---|---|---|
none |
1 |
\(4.1\times10^{-1}\) |
\(\lvert d\mathbf h\rvert < 0.40\) |
7 |
\(5.0\times10^{-2}\) |
\(\lvert d\mathbf h\rvert < 0.70\) |
19 |
\(9.7\times10^{-3}\) |
\(\lvert d\mathbf h\rvert < 1.05\) (default) |
37 |
\(1.2\times10^{-3}\) |
order \(\sum_j\lvert c_j\rvert \le 4\) |
3 |
\(1.7\) |
The coset multiplies the cost of every structure-factor evaluation by its
member count, so it is the expensive part of a commensurate dynamical run.
Lower coset_dh to trade accuracy for speed; the table above is the exchange
rate.
Reduce the beam list first¶
A dynamical calculation must be given distinct reflections, and on a
commensurate lattice an enumerated list is not. At gmax=0.40, max_index=3, max_order=3 the C phase of 1T-TaS2 enumerates 581 superspace indices that are
only 209 distinct reflections.
from pyextal.superspace import distinct_beams
H = distinct_beams(lat, beams.H) # 581 -> 209, and warns that it did
dynamical_saed and supercell_saed call this themselves, so results come
back indexed by the reduced list — sup['H'] and ss.H — which is shorter
than what was passed in. Join on those. engine.load raises instead of
reducing, because by the time it runs the .dat has already been written.
Skipping the reduction does not produce a wrong number, it produces a crash a
long way from its cause: duplicate beams are identical rows in the structure
matrix, and the .dat written from such a list repeats indices in its hkl
block, so bloch_parse cannot resolve some out entries and appends them past
the array allocated from the declared nbeams. Expect a failed eigensolve, a
segmentation fault, or the child process killed outright.
Two routes to the same answer¶
The commensurate structure can be computed two independent ways, and the agreement is the strongest check on the superspace engine:
from pyextal.superspace import engine, enumerate_reflections, supercell_saed, tas2
lat, atoms = tas2.commensurate_structure()
beams = enumerate_reflections(lat, gmax=0.40, max_index=2, max_order=1)
sup = engine.run_isolated(supercell_saed, lat, atoms, beams.H, thickness=200.0)
ss = engine.run_isolated(engine.dynamical_saed, lat, atoms, beams.H, thickness=200.0)
supercell_saed freezes the modulation into 39 atomic coordinates and hands an
ordinary crystal to the unmodified engine — it never mentions a satellite.
dynamical_saed keeps 3 atoms, integrates the structure factor over internal
space and sums the coincident amplitudes. They agree to 1.2e-3, the
resolution of that quadrature. Both must be given the same beam list, or they
are solving different scattering problems.
Note also that the first-order satellites are visible in a \([001]\) pattern of the commensurate phase and are not in the nearly commensurate one: \(q_z = 1/3\) lifts them out of the zero-order Laue zone, and locking in puts \(\mathbf q\) in plane and brings them back.
Diffraction patterns as images¶
render_image puts intensities on a pixel grid, giving a diffraction image
rather than a scatter of markers — something to threshold, line-scan or compare
against a plate. It convolves with the pseudo-Voigt the JAX refinement fits, in
the same functional form, so a refined sigma and gamma transfer directly.
from pyextal.superspace import render_image, plot_image, saturation_level
image, extent = render_image(pattern, shape=(700, 700), sigma=3.0, gamma=1.5)
plot_image(pattern, sigma=3.0, gamma=1.5, colorbar=True)
vmax sets the intensity that maps to full scale, in the units of the returned
image; smaller brightens, larger dims, and the returned array is untouched
either way. Its main use is putting several patterns on one scale — left to
itself plot_image normalises each pattern to its own strongest diffracted
beam, so two panels look equally bright whatever the physics did:
render = dict(shape=(650, 650), sigma=3.0, gamma=1.5)
level = saturation_level(kinematic, **render) # the automatic choice
plot_image(kinematic, vmax=level, **render)
plot_image(dynamical, vmax=level, **render) # now comparable
saturation_level computes that default without drawing anything. Give it the
same render arguments: the convolution spreads a fixed number of counts, so the
peak height depends on the grid and the kernel.
Two details are not cosmetic. The direct beam is some seven orders of magnitude
above the strongest diffracted beam, so plot_image references the display to
the diffracted maximum and lets the centre clip, as a plate does. And the
kernel is tapered to a disc: the Lorentzian decays only as \(1/r^2\), so a square
truncation leaves a visible square halo around every strong reflection.
Limits¶
Modulated ADPs are not implemented.
Bis constant per atom.Symmetry expansion of modulation functions works only for operations that map the q-set with no reciprocal-lattice offset (\(G_j = 0\)). A nonzero \(G_j\) makes the phase relation between equivalent modulation functions depend on the atom’s position;
expand_atomsraises rather than guessing. List such structures in \(P1\).transform_reflectionis general and has no such limit.Genuinely incommensurate structures have no dynamical path here. They have no finite supercell. Reaching them means teaching the Fortran engine \((3{+}d)\) indices, which
difpar.hkl—integer(3, nbeams)— cannot hold, since satellites are not at integral positions.A dynamical calculation needs a commensurate approximant. The refined NC structure is incommensurate, so
tas2.structure(commensurate=True)evaluates the same refined modulation functions on \(\mathbf q_c = (3\mathbf a_0^* + \mathbf b_0^*)/13\), giving the \(\sqrt{13}a \times \sqrt{13}a \times 3c\) cell with 117 atoms.Higher-order satellites carry the refinement’s own limits. Table II of the reference reports \(R_F\) rising from 0.038 on main reflections to 0.15 at order \((2,0)\) and past 0.4 for the sparse high-order groups.
Reference¶
A. Spijkerman, J. L. de Boer, A. Meetsma, G. A. Wiegers and S. van Smaalen, “X-ray crystal-structure refinement of the nearly commensurate phase of 1T-TaS₂ in (3+2)-dimensional superspace”, Phys. Rev. B 56, 13757 (1997), doi:10.1103/PhysRevB.56.13757.