|
| 1 | +""" |
| 2 | +Module for handling Asymmetric Unit (ASU) constraints with complex conditions. |
| 3 | +
|
| 4 | +This module provides tools to: |
| 5 | +1. Define ASU box constraints (x_min ≤ x ≤ x_max, etc.) |
| 6 | +2. Define additional inequality constraints (e.g., x ≤ y, y ≤ 1/2 - x) |
| 7 | +3. Check if coordinates satisfy ASU conditions |
| 8 | +4. Generate random points within the ASU |
| 9 | +5. Project coordinates into the ASU |
| 10 | +
|
| 11 | +Examples of ASU conditions: |
| 12 | +- Space Group 1 (P1): 0 ≤ x ≤ 1, 0 ≤ y ≤ 1, 0 ≤ z ≤ 1 |
| 13 | +- Space Group 75 (P4): 0 ≤ x ≤ 1/2, 0 ≤ y ≤ 1/2, 0 ≤ z ≤ 1, with x ≤ y |
| 14 | +- Space Group 146 (R3): x ≤ (1+y)/2, y ≤ min(1-x, (1+x)/2), z ≤ 1 |
| 15 | +""" |
| 16 | + |
| 17 | +import numpy as np |
| 18 | +from fractions import Fraction |
| 19 | +from typing import List, Callable, Optional |
| 20 | + |
| 21 | + |
| 22 | +class ASUCondition: |
| 23 | + """ |
| 24 | + Represents a single inequality condition for ASU. |
| 25 | + |
| 26 | + Examples: |
| 27 | + x ≤ y --> lambda coords: coords[0] <= coords[1] |
| 28 | + y ≤ 1/2 - x --> lambda coords: coords[1] <= 0.5 - coords[0] |
| 29 | + x ≤ (1+y)/2 --> lambda coords: coords[0] <= (1 + coords[1])/2 |
| 30 | + """ |
| 31 | + |
| 32 | + def __init__(self, condition: Callable, description: str): |
| 33 | + """ |
| 34 | + Initialize an ASU condition. |
| 35 | + |
| 36 | + Args: |
| 37 | + condition: A callable that takes coords [x, y, z] and returns True/False |
| 38 | + description: A human-readable description of the condition |
| 39 | + """ |
| 40 | + self.condition = condition |
| 41 | + self.description = description |
| 42 | + |
| 43 | + def check(self, coords: np.ndarray) -> bool: |
| 44 | + """ |
| 45 | + Check if coordinates satisfy this condition. |
| 46 | + |
| 47 | + Args: |
| 48 | + coords: Array of shape (..., 3) with [x, y, z] coordinates |
| 49 | + |
| 50 | + Returns: |
| 51 | + Boolean or array of booleans indicating if condition is satisfied |
| 52 | + """ |
| 53 | + return self.condition(coords) |
| 54 | + |
| 55 | + def __repr__(self): |
| 56 | + return f"ASUCondition({self.description})" |
| 57 | + |
| 58 | + |
| 59 | +class ASU: |
| 60 | + """ |
| 61 | + Represents the Asymmetric Unit for a space group with box constraints |
| 62 | + and additional inequality conditions. |
| 63 | + """ |
| 64 | + |
| 65 | + def __init__(self, |
| 66 | + x_min: float, x_max: float, |
| 67 | + y_min: float, y_max: float, |
| 68 | + z_min: float, z_max: float, |
| 69 | + conditions: Optional[List[ASUCondition]] = None): |
| 70 | + """ |
| 71 | + Initialize ASU with box bounds and optional additional conditions. |
| 72 | + |
| 73 | + Args: |
| 74 | + x_min, x_max: x coordinate bounds |
| 75 | + y_min, y_max: y coordinate bounds |
| 76 | + z_min, z_max: z coordinate bounds |
| 77 | + conditions: List of additional ASUCondition objects |
| 78 | + """ |
| 79 | + self.bounds = np.array([x_min, x_max, y_min, y_max, z_min, z_max]) |
| 80 | + self.conditions = conditions or [] |
| 81 | + |
| 82 | + @property |
| 83 | + def x_min(self): return self.bounds[0] |
| 84 | + |
| 85 | + @property |
| 86 | + def x_max(self): return self.bounds[1] |
| 87 | + |
| 88 | + @property |
| 89 | + def y_min(self): return self.bounds[2] |
| 90 | + |
| 91 | + @property |
| 92 | + def y_max(self): return self.bounds[3] |
| 93 | + |
| 94 | + @property |
| 95 | + def z_min(self): return self.bounds[4] |
| 96 | + |
| 97 | + @property |
| 98 | + def z_max(self): return self.bounds[5] |
| 99 | + |
| 100 | + def check_box_bounds(self, coords: np.ndarray) -> np.ndarray: |
| 101 | + """ |
| 102 | + Check if coordinates are within the box bounds. |
| 103 | + |
| 104 | + Args: |
| 105 | + coords: Array of shape (..., 3) with [x, y, z] coordinates |
| 106 | + |
| 107 | + Returns: |
| 108 | + Boolean array indicating if each point is within bounds |
| 109 | + """ |
| 110 | + coords = np.asarray(coords) |
| 111 | + if coords.ndim == 1: |
| 112 | + coords = coords.reshape(1, -1) |
| 113 | + |
| 114 | + in_bounds = ( |
| 115 | + (coords[..., 0] >= self.x_min) & (coords[..., 0] <= self.x_max) & |
| 116 | + (coords[..., 1] >= self.y_min) & (coords[..., 1] <= self.y_max) & |
| 117 | + (coords[..., 2] >= self.z_min) & (coords[..., 2] <= self.z_max) |
| 118 | + ) |
| 119 | + return in_bounds |
| 120 | + |
| 121 | + def check_conditions(self, coords: np.ndarray) -> np.ndarray: |
| 122 | + """ |
| 123 | + Check if coordinates satisfy all additional conditions. |
| 124 | + |
| 125 | + Args: |
| 126 | + coords: Array of shape (..., 3) with [x, y, z] coordinates |
| 127 | + |
| 128 | + Returns: |
| 129 | + Boolean array indicating if each point satisfies all conditions |
| 130 | + """ |
| 131 | + if not self.conditions: |
| 132 | + return np.ones(coords.shape[:-1], dtype=bool) |
| 133 | + |
| 134 | + coords = np.asarray(coords) |
| 135 | + result = np.ones(coords.shape[:-1], dtype=bool) |
| 136 | + |
| 137 | + for condition in self.conditions: |
| 138 | + result &= condition.check(coords) |
| 139 | + |
| 140 | + return result |
| 141 | + |
| 142 | + def is_valid(self, coords: np.ndarray) -> np.ndarray: |
| 143 | + """ |
| 144 | + Check if coordinates are within the ASU (both box bounds and conditions). |
| 145 | + |
| 146 | + Args: |
| 147 | + coords: Array of shape (..., 3) with [x, y, z] coordinates |
| 148 | + |
| 149 | + Returns: |
| 150 | + Boolean array indicating if each point is in the ASU |
| 151 | + """ |
| 152 | + return self.check_box_bounds(coords) & self.check_conditions(coords) |
| 153 | + |
| 154 | + def generate_random_points(self, n: int, max_attempts: int = 10000) -> np.ndarray: |
| 155 | + """ |
| 156 | + Generate random points uniformly within the ASU. |
| 157 | + |
| 158 | + Args: |
| 159 | + n: Number of points to generate |
| 160 | + max_attempts: Maximum number of attempts per point |
| 161 | + |
| 162 | + Returns: |
| 163 | + Array of shape (n, 3) with valid ASU coordinates |
| 164 | + """ |
| 165 | + points = [] |
| 166 | + attempts = 0 |
| 167 | + |
| 168 | + while len(points) < n and attempts < max_attempts: |
| 169 | + # Generate random points in the box |
| 170 | + candidates = np.random.rand(n - len(points), 3) |
| 171 | + candidates[:, 0] = candidates[:, 0] * (self.x_max - self.x_min) + self.x_min |
| 172 | + candidates[:, 1] = candidates[:, 1] * (self.y_max - self.y_min) + self.y_min |
| 173 | + candidates[:, 2] = candidates[:, 2] * (self.z_max - self.z_min) + self.z_min |
| 174 | + |
| 175 | + # Check which ones satisfy all conditions |
| 176 | + valid = self.check_conditions(candidates) |
| 177 | + points.extend(candidates[valid]) |
| 178 | + |
| 179 | + attempts += 1 |
| 180 | + |
| 181 | + if len(points) < n: |
| 182 | + raise ValueError(f"Could not generate {n} valid points after {max_attempts} attempts") |
| 183 | + |
| 184 | + return np.array(points[:n]) |
| 185 | + |
| 186 | + def project_to_asu(self, coords: np.ndarray, method: str = 'clamp') -> np.ndarray: |
| 187 | + """ |
| 188 | + Project coordinates to be within the ASU. |
| 189 | + |
| 190 | + Args: |
| 191 | + coords: Array of shape (..., 3) with [x, y, z] coordinates |
| 192 | + method: Projection method ('clamp' or 'modulo') |
| 193 | + |
| 194 | + Returns: |
| 195 | + Projected coordinates within the ASU |
| 196 | + """ |
| 197 | + coords = np.asarray(coords, dtype=float).copy() |
| 198 | + original_shape = coords.shape |
| 199 | + coords = coords.reshape(-1, 3) |
| 200 | + |
| 201 | + if method == 'clamp': |
| 202 | + # Clamp to box bounds |
| 203 | + coords[:, 0] = np.clip(coords[:, 0], self.x_min, self.x_max) |
| 204 | + coords[:, 1] = np.clip(coords[:, 1], self.y_min, self.y_max) |
| 205 | + coords[:, 2] = np.clip(coords[:, 2], self.z_min, self.z_max) |
| 206 | + |
| 207 | + # Handle additional conditions (simple approach: iterative adjustment) |
| 208 | + for _ in range(10): # Max iterations |
| 209 | + if np.all(self.check_conditions(coords)): |
| 210 | + break |
| 211 | + |
| 212 | + for condition in self.conditions: |
| 213 | + invalid = ~condition.check(coords) |
| 214 | + if np.any(invalid): |
| 215 | + # Simple heuristic: move slightly toward center of ASU |
| 216 | + center = np.array([ |
| 217 | + (self.x_min + self.x_max) / 2, |
| 218 | + (self.y_min + self.y_max) / 2, |
| 219 | + (self.z_min + self.z_max) / 2 |
| 220 | + ]) |
| 221 | + coords[invalid] = 0.9 * coords[invalid] + 0.1 * center |
| 222 | + |
| 223 | + elif method == 'modulo': |
| 224 | + # Apply periodic boundary conditions first |
| 225 | + coords[:, 0] = coords[:, 0] % 1.0 |
| 226 | + coords[:, 1] = coords[:, 1] % 1.0 |
| 227 | + coords[:, 2] = coords[:, 2] % 1.0 |
| 228 | + |
| 229 | + # Then clamp to ASU box |
| 230 | + coords[:, 0] = np.clip(coords[:, 0], self.x_min, self.x_max) |
| 231 | + coords[:, 1] = np.clip(coords[:, 1], self.y_min, self.y_max) |
| 232 | + coords[:, 2] = np.clip(coords[:, 2], self.z_min, self.z_max) |
| 233 | + |
| 234 | + return coords.reshape(original_shape) |
| 235 | + |
| 236 | + def __repr__(self): |
| 237 | + bounds_str = (f"x: [{self.x_min}, {self.x_max}], " |
| 238 | + f"y: [{self.y_min}, {self.y_max}], " |
| 239 | + f"z: [{self.z_min}, {self.z_max}]") |
| 240 | + if self.conditions: |
| 241 | + cond_str = ", conditions: " + ", ".join(str(c) for c in self.conditions) |
| 242 | + else: |
| 243 | + cond_str = "" |
| 244 | + return f"ASU({bounds_str}{cond_str})" |
| 245 | + |
| 246 | + |
| 247 | +# ============================================================================ |
| 248 | +# Example ASU definitions with additional conditions |
| 249 | +# ============================================================================ |
| 250 | + |
| 251 | +def create_asu_for_space_group(space_group: int) -> ASU: |
| 252 | + """ |
| 253 | + Create ASU object for a given space group number. |
| 254 | + |
| 255 | + This is a demonstration with a few examples. For complete implementation, |
| 256 | + you would need to add all 230 space groups. |
| 257 | + |
| 258 | + Args: |
| 259 | + space_group: Space group number (1-230) |
| 260 | + |
| 261 | + Returns: |
| 262 | + ASU object with appropriate bounds and conditions |
| 263 | + """ |
| 264 | + # Basic ASU bounds for common space groups (partial list for demonstration) |
| 265 | + # Full data can be loaded from pyxtal/database/asymmetric_unit.txt |
| 266 | + ASU_BOUNDS_DICT = { |
| 267 | + 1: [0, 1, 0, 1, 0, 1], |
| 268 | + 2: [0, 0.5, 0, 1, 0, 1], |
| 269 | + 75: [0, 0.5, 0, 0.5, 0, 1], |
| 270 | + 143: [0, 2/3, 0, 2/3, 0, 1], |
| 271 | + 146: [0, 2/3, 0, 2/3, 0, 1/3], |
| 272 | + 195: [0, 0.5, 0, 0.5, 0, 0.5], |
| 273 | + } |
| 274 | + |
| 275 | + bounds = ASU_BOUNDS_DICT.get(space_group, [0, 1, 0, 1, 0, 1]) |
| 276 | + x_min, x_max, y_min, y_max, z_min, z_max = bounds |
| 277 | + |
| 278 | + # Define additional conditions for specific space groups |
| 279 | + conditions = [] |
| 280 | + |
| 281 | + # Space Group 75 (P4): Additional condition x ≤ y |
| 282 | + if space_group == 75: |
| 283 | + conditions.append(ASUCondition( |
| 284 | + lambda c: c[..., 0] <= c[..., 1], |
| 285 | + "x ≤ y" |
| 286 | + )) |
| 287 | + |
| 288 | + # Space Group 143 (P3): Additional conditions |
| 289 | + elif space_group == 143: |
| 290 | + conditions.extend([ |
| 291 | + ASUCondition( |
| 292 | + lambda c: c[..., 0] <= c[..., 1], |
| 293 | + "x ≤ y" |
| 294 | + ), |
| 295 | + ASUCondition( |
| 296 | + lambda c: c[..., 1] <= 0.5, |
| 297 | + "y ≤ 1/2" |
| 298 | + ) |
| 299 | + ]) |
| 300 | + |
| 301 | + # Space Group 146 (R3): More complex conditions |
| 302 | + elif space_group == 146: |
| 303 | + conditions.extend([ |
| 304 | + ASUCondition( |
| 305 | + lambda c: c[..., 0] <= (1 + c[..., 1]) / 2, |
| 306 | + "x ≤ (1+y)/2" |
| 307 | + ), |
| 308 | + ASUCondition( |
| 309 | + lambda c: c[..., 1] <= np.minimum(1 - c[..., 0], (1 + c[..., 0]) / 2), |
| 310 | + "y ≤ min(1-x, (1+x)/2)" |
| 311 | + ) |
| 312 | + ]) |
| 313 | + |
| 314 | + # Space Group 195 (P23): Additional condition x ≤ y and y ≤ z |
| 315 | + elif space_group == 195: |
| 316 | + conditions.extend([ |
| 317 | + ASUCondition( |
| 318 | + lambda c: c[..., 0] <= c[..., 1], |
| 319 | + "x ≤ y" |
| 320 | + ), |
| 321 | + ASUCondition( |
| 322 | + lambda c: c[..., 1] <= c[..., 2], |
| 323 | + "y ≤ z" |
| 324 | + ) |
| 325 | + ]) |
| 326 | + |
| 327 | + # Add more space groups as needed... |
| 328 | + |
| 329 | + return ASU(x_min, x_max, y_min, y_max, z_min, z_max, conditions) |
| 330 | + |
| 331 | + |
| 332 | +# ============================================================================ |
| 333 | +# Utility functions |
| 334 | +# ============================================================================ |
| 335 | + |
| 336 | +def parse_fraction(frac_str: str) -> float: |
| 337 | + """Convert fraction string like '1/2' to float.""" |
| 338 | + frac_str = frac_str.strip() |
| 339 | + if '/' in frac_str: |
| 340 | + return float(Fraction(frac_str)) |
| 341 | + return float(frac_str) |
| 342 | + |
| 343 | + |
| 344 | +def test_asu_implementation(): |
| 345 | + """Test the ASU implementation with examples.""" |
| 346 | + print("Testing ASU implementation\n" + "=" * 60) |
| 347 | + |
| 348 | + # Test 1: Simple box constraint (Space Group 1) |
| 349 | + print("\n1. Space Group 1 (P1) - Simple box") |
| 350 | + asu1 = create_asu_for_space_group(1) |
| 351 | + print(asu1) |
| 352 | + |
| 353 | + test_points = np.array([ |
| 354 | + [0.5, 0.5, 0.5], # Valid |
| 355 | + [1.5, 0.5, 0.5], # Invalid (x > 1) |
| 356 | + [0.0, 0.0, 0.0], # Valid |
| 357 | + ]) |
| 358 | + valid = asu1.is_valid(test_points) |
| 359 | + print(f"Test points validity: {valid}") |
| 360 | + |
| 361 | + # Test 2: Box with condition (Space Group 75) |
| 362 | + print("\n2. Space Group 75 (P4) - Box with x ≤ y") |
| 363 | + asu75 = create_asu_for_space_group(75) |
| 364 | + print(asu75) |
| 365 | + |
| 366 | + test_points = np.array([ |
| 367 | + [0.3, 0.4, 0.5], # Valid (x < y) |
| 368 | + [0.4, 0.3, 0.5], # Invalid (x > y) |
| 369 | + [0.25, 0.25, 0.5], # Valid (x = y) |
| 370 | + ]) |
| 371 | + valid = asu75.is_valid(test_points) |
| 372 | + print(f"Test points validity: {valid}") |
| 373 | + |
| 374 | + # Test 3: Generate random points |
| 375 | + print("\n3. Generate random points in ASU 75") |
| 376 | + random_points = asu75.generate_random_points(5) |
| 377 | + print(f"Generated {len(random_points)} random points:") |
| 378 | + for i, pt in enumerate(random_points): |
| 379 | + print(f" Point {i+1}: x={pt[0]:.4f}, y={pt[1]:.4f}, z={pt[2]:.4f}, valid={asu75.is_valid(pt)}") |
| 380 | + |
| 381 | + # Test 4: Project to ASU |
| 382 | + print("\n4. Project points to ASU") |
| 383 | + outside_points = np.array([ |
| 384 | + [0.6, 0.3, 0.5], # Outside condition (x > y) |
| 385 | + [1.5, 0.5, 0.5], # Outside box |
| 386 | + ]) |
| 387 | + projected = asu75.project_to_asu(outside_points) |
| 388 | + print(f"Original points:\n{outside_points}") |
| 389 | + print(f"Projected points:\n{projected}") |
| 390 | + print(f"Projected points valid: {asu75.is_valid(projected)}") |
| 391 | + |
| 392 | + |
| 393 | +if __name__ == "__main__": |
| 394 | + test_asu_implementation() |
0 commit comments