Coverage for dynasor/qpoints/spherical_qpoints.py: 100%

59 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-20 20:02 +0000

1from typing import Optional 

2import itertools 

3import numpy as np 

4from numpy.typing import NDArray 

5from dynasor.logging_tools import logger 

6 

7 

8def get_spherical_qpoints( 

9 cell: NDArray[float], 

10 q_max: float, 

11 q_min: Optional[float] = 0.0, 

12 max_points: Optional[int] = None, 

13 seed: Optional[int] = 42, 

14 chunk_size: Optional[int] = 100000, 

15) -> NDArray[float]: 

16 r"""Generates all q-points on the reciprocal lattice inside a given radius 

17 :attr:`q_max`. This approach is suitable if an isotropic sampling of 

18 q-space is desired. The function returns the resulting q-points in 

19 Cartesian coordinates as an ``Nx3`` array. 

20 

21 If the number of generated q-points are large, points can be removed by 

22 specifying :attr:`max_points`. The q-points will be randomly removed in 

23 such a way that the q-points inside are roughly uniformly distributed with 

24 respect to :math:`|q|`. If the number of q-points are binned w.r.t. their 

25 norm the function would increase quadratically up until some distance P 

26 from which point the distribution would be constant. 

27 

28 Parameters 

29 ---------- 

30 cell 

31 Real cell with cell vectors as rows. 

32 q_max 

33 Maximum norm of generated q-points 

34 (in units of rad/Å, i.e. including factor of :math:`2\pi`). 

35 q_min 

36 Minimum norm of generated q-points 

37 (in units of rad/Å, i.e. including factor of :math:`2\pi`). 

38 max_points 

39 Optionally limit the set to __approximately__ :attr:`max_points` points 

40 by randomly removing points from a "fully populated mesh". The points 

41 are removed in such a way that for :math:`q > q_\mathrm{prune}`, the 

42 points will be radially uniformly distributed. The value of 

43 :math:`q_\mathrm{prune}` is calculated from :attr:`q_max`, 

44 :attr:`max_points`, and the shape of the cell. 

45 seed 

46 Seed used for stochastic pruning. 

47 chunk_size 

48 Number of q-points to process at once (controls memory use). 

49 """ 

50 

51 if q_min >= q_max: 

52 raise ValueError('q_min must be smaller than q_max.') 

53 if q_min < 0: 

54 raise ValueError('q_min cannot be negative.') 

55 

56 # inv(A.T) == inv(A).T 

57 # The physicists reciprocal cell 

58 cell = cell.astype(np.float64) 

59 rec_cell = np.linalg.inv(cell.T) * 2 * np.pi 

60 

61 # We want to find all points on the lattice defined by the reciprocal cell 

62 # such that all points within q_max are in this set 

63 inv_rec_cell = np.linalg.inv(rec_cell.T) # cell / 2pi 

64 

65 # h is the height of the rec_cell perpendicular to the other two vectors 

66 h = 1 / np.linalg.norm(inv_rec_cell, axis=1) 

67 

68 # If a q_point has a coordinate larger than this number it must be further away than q_max 

69 N = np.ceil(q_max / h).astype(int) 

70 

71 # Generate q-points on the grid with in chunks to keep memory consumption low 

72 iterator = itertools.product(*[range(-n, n+1) for n in N]) 

73 q_points = [] 

74 

75 while True: 

76 

77 # Take a chunk of points 

78 lattice_points_chunk = list(itertools.islice(iterator, chunk_size)) 

79 if not lattice_points_chunk: 

80 break 

81 lattice_points_chunk = np.array(lattice_points_chunk) 

82 q_points_chunk = lattice_points_chunk @ rec_cell # (chunk, 3) 

83 

84 # Filter by norm 

85 q_distances = np.linalg.norm(q_points_chunk, axis=1) 

86 mask = np.logical_and(q_distances >= q_min, q_distances <= q_max) 

87 if np.any(mask): 

88 q_points.append(q_points_chunk[mask]) 

89 

90 if len(q_points) == 0: 

91 return np.empty((0, 3)) 

92 q_points = np.vstack(q_points) 

93 

94 # Pruning based on max_points 

95 if max_points is not None and max_points < len(q_points): 

96 

97 q_vol = abs(np.linalg.det(rec_cell)) 

98 q_prune = _get_prune_distance(max_points, q_min, q_max, q_vol) 

99 

100 if q_prune < q_max: 

101 logger.info(f'Pruning q-points from the range {q_prune:.3} < |q| < {q_max}') 

102 

103 # Keep point with probability min(1, (q_prune/|q|)^2) -> 

104 # aim for an equal number of points per equally thick "onion peel" 

105 # to get equal number of points per radial unit. 

106 q_distances = np.linalg.norm(q_points, axis=1) 

107 p = np.divide(q_prune**2, q_distances**2, out=np.ones_like(q_distances), 

108 where=np.logical_not(np.isclose(q_distances, 0))) 

109 

110 rs = np.random.RandomState(seed) 

111 q_points = q_points[p > rs.rand(len(q_points))] 

112 

113 logger.info(f'Pruned from {len(q_distances)} q-points to {len(q_points)}') 

114 

115 return q_points 

116 

117 

118def _get_prune_distance( 

119 max_points: int, 

120 q_min: float, 

121 q_max: float, 

122 q_vol: float, 

123) -> NDArray[float]: 

124 r"""Determine distance in q-space beyond which to prune 

125 the q-point mesh to achieve near-isotropic sampling of q-space. 

126 

127 If points are selected from the full mesh with probability 

128 :math:`\min(1, (q_\mathrm{prune} / |q|)^2)`, q-space will 

129 on average be sampled with an equal number of points per radial unit 

130 (for :math:`q > q_\mathrm{prune}`). 

131 

132 The general idea is as follows. 

133 We know that the number of q-points inside a radius :math:`Q` is given by 

134 

135 .. math: 

136 

137 n = v^{-1} \int_0^Q dq 4 \pi q^2 = v^{-1} 4/3 \pi Q^3 

138 

139 where :math:`v` is the volume of one q-point. Now we want to find 

140 a distance :math:`P` such that if all points outside this radius 

141 are weighted by the function :math:`w(q)` the total number of 

142 q-points will equal the target :math:`N` (:attr:`max_points`) 

143 while the number of q-points increases linearly from :math:`P` 

144 outward. One additional constraint is that the weighting function 

145 must be 1 at :math:`P`. The weighting function which accomplishes 

146 this is :math:`w(q)=P^2/q^2` 

147 

148 .. math: 

149 

150 N = v^{-1} \left( \int_0^P 4 \pi q^2 + \int_P^Q 4 \pi q^2 P^2 / q^2 dq \right). 

151 

152 This results in a `cubic equation <https://en.wikipedia.org/wiki/Cubic_equation>`_ 

153 for :math:`P`, which is solved by this function. 

154 

155 Parameters 

156 ---------- 

157 max_points 

158 Maximum number of resulting q-points; :math:`N` below. 

159 q_min 

160 Minimum q-value in the resulting q-point set. 

161 q_max 

162 Maximum q-value in the resulting q-point set; :math:`Q` below. 

163 q_vol 

164 q-space volume for a single q-point. 

165 """ 

166 

167 Q = q_max 

168 V = q_vol 

169 N = max_points 

170 

171 # Coefs 

172 a = 1.0 

173 b = -3 / 2 * Q 

174 c = 0.0 

175 d = 3 / 2 * V * N / (4 * np.pi) + 0.5 * q_min**3 

176 

177 # Eq tol solve 

178 def original_eq(x): 

179 return a * x**3 + b * x**2 + c * x + d 

180 # original_eq = lambda x: a * x**3 + b * x**2 + c * x + d 

181 

182 # Discriminant 

183 p = (3 * a * c - b**2) / (3 * a**2) 

184 q = (2 * b**3 - 9 * a * b * c + 27 * a**2 * d) / (27 * a**3) 

185 

186 D_t = - (4 * p**3 + 27 * q**2) 

187 if D_t < 0: 

188 return q_max 

189 

190 x = Q * (np.cos(1 / 3 * np.arccos(1 - 4 * d / Q**3) - 2 * np.pi / 3) + 0.5) 

191 

192 assert np.isclose(original_eq(x), 0), original_eq(x) 

193 

194 return x