dichromatic-map 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dichromatic_map/__init__.py +14 -0
- dichromatic_map/__main__.py +88 -0
- dichromatic_map/cells.py +287 -0
- dichromatic_map/compute.py +257 -0
- dichromatic_map/crystal.py +524 -0
- dichromatic_map/matching.py +294 -0
- dichromatic_map/state.py +130 -0
- dichromatic_map/strain.py +479 -0
- dichromatic_map/ui/__init__.py +15 -0
- dichromatic_map/ui/controls.py +902 -0
- dichromatic_map/ui/plot.py +731 -0
- dichromatic_map/ui/resources/icons/chevron-down.svg +3 -0
- dichromatic_map/ui/resources/icons/chevron-up.svg +3 -0
- dichromatic_map/ui/resources/theme.qss +168 -0
- dichromatic_map/ui/window.py +2210 -0
- dichromatic_map-0.1.0.dist-info/METADATA +189 -0
- dichromatic_map-0.1.0.dist-info/RECORD +21 -0
- dichromatic_map-0.1.0.dist-info/WHEEL +5 -0
- dichromatic_map-0.1.0.dist-info/entry_points.txt +2 -0
- dichromatic_map-0.1.0.dist-info/licenses/LICENSE +18 -0
- dichromatic_map-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""DichromaticMap numerical package. Importing it does not load Qt."""
|
|
2
|
+
|
|
3
|
+
from .crystal import get_geometry, projected_columns
|
|
4
|
+
from .cells import count_cell_atoms
|
|
5
|
+
from .matching import exact_csl_cell, local_near_pairs, same_layer_coincidence_sites
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"get_geometry",
|
|
9
|
+
"projected_columns",
|
|
10
|
+
"count_cell_atoms",
|
|
11
|
+
"exact_csl_cell",
|
|
12
|
+
"local_near_pairs",
|
|
13
|
+
"same_layer_coincidence_sites",
|
|
14
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Command-line entry point; GUI imports happen only when starting a viewer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import argparse
|
|
5
|
+
import multiprocessing
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from .state import PatternParameters
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_arguments() -> argparse.Namespace:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
description="Standalone FCC/BCC integer-axis tilt-GB viewer; coordinates in a0"
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--angle",
|
|
17
|
+
type=float,
|
|
18
|
+
default=None,
|
|
19
|
+
help="degrees; default is Sigma9 for 110, Sigma5 for 100, lowest-Sigma preset otherwise",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--lattice", choices=("FCC", "BCC"), default="FCC")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--axis",
|
|
24
|
+
default="110",
|
|
25
|
+
help='Tilt axis: 100, 110, 111, 112, or an integer triple, e.g. "1 -1 3"',
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--lattice-constant",
|
|
29
|
+
type=float,
|
|
30
|
+
default=3.52,
|
|
31
|
+
help="reference a0 in Angstrom; display is normalized by a0",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--width", type=float, default=12.0, help="base view width in a0 (not Angstrom)"
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--height",
|
|
38
|
+
type=float,
|
|
39
|
+
default=9.0,
|
|
40
|
+
help="base view height in a0 (not Angstrom)",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--marker-size", type=float, default=32.0)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--view-scale",
|
|
45
|
+
type=float,
|
|
46
|
+
default=1.0,
|
|
47
|
+
help="initial field-size multiplier, 0.1 to 5 (default: %(default)s)",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--workers",
|
|
51
|
+
type=int,
|
|
52
|
+
default=min(4, max(1, os.cpu_count() or 1)),
|
|
53
|
+
help="calculation processes; use 1 to disable multiprocessing (default: %(default)s)",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("--save", type=Path, metavar="PNG")
|
|
56
|
+
return parser.parse_args()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> None:
|
|
60
|
+
multiprocessing.freeze_support()
|
|
61
|
+
arguments = parse_arguments()
|
|
62
|
+
from .ui.controls import create_application
|
|
63
|
+
from .ui.window import DichromaticPatternWindow
|
|
64
|
+
|
|
65
|
+
parameters = PatternParameters(
|
|
66
|
+
angle_deg=arguments.angle,
|
|
67
|
+
lattice_constant=arguments.lattice_constant,
|
|
68
|
+
width=arguments.width,
|
|
69
|
+
height=arguments.height,
|
|
70
|
+
marker_size=arguments.marker_size,
|
|
71
|
+
view_scale=arguments.view_scale,
|
|
72
|
+
lattice=arguments.lattice,
|
|
73
|
+
axis=arguments.axis,
|
|
74
|
+
)
|
|
75
|
+
application = create_application()
|
|
76
|
+
window = DichromaticPatternWindow(parameters, worker_count=arguments.workers)
|
|
77
|
+
window.show()
|
|
78
|
+
application.processEvents()
|
|
79
|
+
if arguments.save is not None:
|
|
80
|
+
window.save(arguments.save)
|
|
81
|
+
print(f"Saved dichromatic pattern to {arguments.save}")
|
|
82
|
+
window.close()
|
|
83
|
+
return
|
|
84
|
+
raise SystemExit(application.exec())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
dichromatic_map/cells.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Cell geometry, common-cell results and whole-region atom counting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import numpy as np
|
|
6
|
+
from .crystal import get_geometry, projected_columns, GeometryLimitError
|
|
7
|
+
|
|
8
|
+
SIDE_TOLERANCE = 1.0e-9
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_cell_vertices(vertices):
|
|
12
|
+
"""Four distinct convex vertices in perimeter order, in model coordinates."""
|
|
13
|
+
vertices = np.asarray(vertices, dtype=float)
|
|
14
|
+
if vertices.shape != (4, 2) or not np.all(np.isfinite(vertices)):
|
|
15
|
+
raise ValueError("Select four finite vertices in perimeter order")
|
|
16
|
+
edges = np.roll(vertices, -1, axis=0) - vertices
|
|
17
|
+
lengths = np.linalg.norm(edges, axis=1)
|
|
18
|
+
tolerance = 1e-8 * max(1.0, float(lengths.max()))
|
|
19
|
+
turns = edges[:, 0] * np.roll(edges[:, 1], -1) - edges[:, 1] * np.roll(
|
|
20
|
+
edges[:, 0], -1
|
|
21
|
+
)
|
|
22
|
+
if np.any(lengths <= tolerance) or not (
|
|
23
|
+
np.all(turns > tolerance * lengths) or np.all(turns < -tolerance * lengths)
|
|
24
|
+
):
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"Vertices must form a convex, non-crossing cell; pick them around its perimeter"
|
|
27
|
+
)
|
|
28
|
+
return vertices
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cell_membership(points, vertices):
|
|
32
|
+
"""Interior, boundary and (if parallelogram) half-open membership masks.
|
|
33
|
+
|
|
34
|
+
Half-open means p0 + u*(p1-p0) + v*(p3-p0), 0 <= u,v < 1.
|
|
35
|
+
It avoids counting the upper two edges twice when cells are repeated.
|
|
36
|
+
Geometric parallelogram shape does not prove crystal periodicity.
|
|
37
|
+
"""
|
|
38
|
+
vertices = validate_cell_vertices(vertices)
|
|
39
|
+
points = np.asarray(points, dtype=float).reshape(-1, 2)
|
|
40
|
+
edges = np.roll(vertices, -1, axis=0) - vertices
|
|
41
|
+
lengths = np.linalg.norm(edges, axis=1)
|
|
42
|
+
tolerance = 1e-8 * max(1.0, float(lengths.max()))
|
|
43
|
+
orientation = np.sign(np.linalg.det(np.column_stack((edges[0], -edges[-1]))))
|
|
44
|
+
delta = points[:, None, :] - vertices
|
|
45
|
+
distances = (
|
|
46
|
+
orientation
|
|
47
|
+
* (edges[None, :, 0] * delta[:, :, 1] - edges[None, :, 1] * delta[:, :, 0])
|
|
48
|
+
/ lengths
|
|
49
|
+
)
|
|
50
|
+
closed = np.all(distances >= -tolerance, axis=1)
|
|
51
|
+
interior = np.all(distances > tolerance, axis=1)
|
|
52
|
+
half_open = None
|
|
53
|
+
if (
|
|
54
|
+
np.linalg.norm((vertices[2] - vertices[1]) - (vertices[3] - vertices[0]))
|
|
55
|
+
<= tolerance
|
|
56
|
+
):
|
|
57
|
+
basis = np.column_stack((vertices[1] - vertices[0], vertices[3] - vertices[0]))
|
|
58
|
+
inverse = np.linalg.inv(basis)
|
|
59
|
+
uv = (points - vertices[0]) @ inverse.T
|
|
60
|
+
eps = tolerance * np.linalg.norm(inverse, axis=1)
|
|
61
|
+
half_open = np.all((uv >= -eps) & (uv < 1 - eps), axis=1)
|
|
62
|
+
return interior, closed & ~interior, half_open
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class CellAtomCounts:
|
|
67
|
+
interior: np.ndarray # grain x axial layer, one full axial repeat
|
|
68
|
+
boundary: np.ndarray
|
|
69
|
+
half_open: np.ndarray | None
|
|
70
|
+
areas: np.ndarray # one actual polygon area per grain, a0 squared
|
|
71
|
+
half_open_available: np.ndarray # parallelogram test per grain
|
|
72
|
+
half_open_edges: np.ndarray | None # unique edge atoms retained
|
|
73
|
+
half_open_corners: np.ndarray | None # unique corner atoms retained
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def area(self):
|
|
77
|
+
"""Compatibility for a shared-area cell; never average different cells."""
|
|
78
|
+
if not np.allclose(self.areas, self.areas[0], atol=1e-10, rtol=1e-10):
|
|
79
|
+
raise ValueError("Grain polygon areas differ; use areas[grain_index]")
|
|
80
|
+
return float(self.areas[0])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def count_cell_atoms(
|
|
84
|
+
vertices,
|
|
85
|
+
angle,
|
|
86
|
+
deformations,
|
|
87
|
+
lattice="FCC",
|
|
88
|
+
axis="110",
|
|
89
|
+
boundary_points=None,
|
|
90
|
+
region_states=(True, True, True, True),
|
|
91
|
+
layer=-1,
|
|
92
|
+
translations=None,
|
|
93
|
+
):
|
|
94
|
+
"""Count the entire selected cell, independently of viewport and rendering.
|
|
95
|
+
|
|
96
|
+
Vertices may be one shared (4,2) polygon or two independent (2,4,2)
|
|
97
|
+
polygons built from each grain's actual atom positions. Each grain is
|
|
98
|
+
counted inside its own polygon; the viewer passes the picked layer.
|
|
99
|
+
layer=-1 retains all-layer counting for non-GUI callers.
|
|
100
|
+
Generation uses the same interactive allocation guard as normal rendering.
|
|
101
|
+
"""
|
|
102
|
+
vertices = np.asarray(vertices, dtype=float)
|
|
103
|
+
if vertices.shape == (4, 2):
|
|
104
|
+
vertices = np.stack((vertices, vertices))
|
|
105
|
+
if vertices.shape != (2, 4, 2):
|
|
106
|
+
raise ValueError("Provide a (4,2) common polygon or (2,4,2) grain polygons")
|
|
107
|
+
for polygon in vertices:
|
|
108
|
+
validate_cell_vertices(polygon)
|
|
109
|
+
geometry = get_geometry(lattice, axis)
|
|
110
|
+
if (
|
|
111
|
+
not isinstance(layer, (int, np.integer))
|
|
112
|
+
or not -1 <= layer < geometry.layer_count
|
|
113
|
+
):
|
|
114
|
+
raise ValueError("Count layer must be -1 or a valid axial layer index")
|
|
115
|
+
inside_counts = np.zeros((2, geometry.layer_count), dtype=int)
|
|
116
|
+
translations = (
|
|
117
|
+
np.zeros((2, 2))
|
|
118
|
+
if translations is None
|
|
119
|
+
else np.asarray(translations, dtype=float)
|
|
120
|
+
)
|
|
121
|
+
if translations.shape != (2, 2) or not np.all(np.isfinite(translations)):
|
|
122
|
+
raise ValueError("Provide two finite grain translations")
|
|
123
|
+
edge_counts = np.zeros_like(inside_counts)
|
|
124
|
+
half_counts = np.zeros_like(inside_counts)
|
|
125
|
+
half_edge_counts = np.zeros_like(inside_counts)
|
|
126
|
+
half_corner_counts = np.zeros_like(inside_counts)
|
|
127
|
+
is_parallelogram = np.zeros(2, dtype=bool)
|
|
128
|
+
areas = np.zeros(2)
|
|
129
|
+
for grain_index, sign in enumerate((1, -1)):
|
|
130
|
+
polygon = vertices[grain_index]
|
|
131
|
+
low, high = polygon.min(axis=0), polygon.max(axis=0)
|
|
132
|
+
margin = 1e-7 * max(1.0, float(np.max(high - low)))
|
|
133
|
+
width, height = high - low + 2 * margin
|
|
134
|
+
center = (low + high) / 2
|
|
135
|
+
try:
|
|
136
|
+
grain = projected_columns(
|
|
137
|
+
width,
|
|
138
|
+
height,
|
|
139
|
+
sign * angle / 2,
|
|
140
|
+
center,
|
|
141
|
+
deformations[grain_index],
|
|
142
|
+
lattice,
|
|
143
|
+
axis,
|
|
144
|
+
translations[grain_index],
|
|
145
|
+
)
|
|
146
|
+
except GeometryLimitError as error:
|
|
147
|
+
raise GeometryLimitError(
|
|
148
|
+
"Manual cell exceeds the atom enumeration limit; select a smaller cell. "
|
|
149
|
+
"View zoom does not affect counting."
|
|
150
|
+
) from error
|
|
151
|
+
# A manual cell belongs to one axial layer. Filter before polygon and
|
|
152
|
+
# corner distance calculations, which otherwise process every layer.
|
|
153
|
+
selected = grain.layers == layer if layer >= 0 else slice(None)
|
|
154
|
+
positions = grain.positions[selected]
|
|
155
|
+
layers = grain.layers[selected]
|
|
156
|
+
inside, boundary, half_open = cell_membership(positions, polygon)
|
|
157
|
+
half_open_edges = None
|
|
158
|
+
half_open_corners = None
|
|
159
|
+
if half_open is not None:
|
|
160
|
+
corner_tolerance = 1e-8 * max(1.0, float(np.max(high - low)))
|
|
161
|
+
at_corner = np.any(
|
|
162
|
+
np.linalg.norm(
|
|
163
|
+
positions[:, None, :] - polygon[None, :, :], axis=2
|
|
164
|
+
)
|
|
165
|
+
<= corner_tolerance,
|
|
166
|
+
axis=1,
|
|
167
|
+
)
|
|
168
|
+
half_open_corners = half_open & at_corner
|
|
169
|
+
half_open_edges = half_open & boundary & ~at_corner
|
|
170
|
+
visible = np.ones(len(positions), dtype=bool)
|
|
171
|
+
if boundary_points is not None and len(boundary_points) == 2:
|
|
172
|
+
start, end = np.asarray(boundary_points)
|
|
173
|
+
direction = end - start
|
|
174
|
+
cross = direction[0] * (positions[:, 1] - start[1]) - direction[1] * (
|
|
175
|
+
positions[:, 0] - start[0]
|
|
176
|
+
)
|
|
177
|
+
visible &= (region_states[2 * grain_index] & (cross >= -1e-9)) | (
|
|
178
|
+
region_states[2 * grain_index + 1] & (cross <= 1e-9)
|
|
179
|
+
)
|
|
180
|
+
for mask, counts in (
|
|
181
|
+
(inside, inside_counts),
|
|
182
|
+
(boundary, edge_counts),
|
|
183
|
+
(half_open, half_counts),
|
|
184
|
+
(half_open_edges, half_edge_counts),
|
|
185
|
+
(half_open_corners, half_corner_counts),
|
|
186
|
+
):
|
|
187
|
+
if mask is not None:
|
|
188
|
+
counts[grain_index] = np.bincount(
|
|
189
|
+
layers[mask & visible], minlength=geometry.layer_count
|
|
190
|
+
)
|
|
191
|
+
is_parallelogram[grain_index] = half_open is not None
|
|
192
|
+
# Translation-stable area of this grain's actual polygon.
|
|
193
|
+
relative = polygon - polygon[0]
|
|
194
|
+
areas[grain_index] = (
|
|
195
|
+
abs(
|
|
196
|
+
np.sum(
|
|
197
|
+
relative[:, 0] * np.roll(relative[:, 1], -1)
|
|
198
|
+
- relative[:, 1] * np.roll(relative[:, 0], -1)
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
/ 2
|
|
202
|
+
)
|
|
203
|
+
half_open_result = np.any(is_parallelogram)
|
|
204
|
+
return CellAtomCounts(
|
|
205
|
+
interior=inside_counts,
|
|
206
|
+
boundary=edge_counts,
|
|
207
|
+
half_open=half_counts if half_open_result else None,
|
|
208
|
+
areas=areas,
|
|
209
|
+
half_open_available=is_parallelogram,
|
|
210
|
+
half_open_edges=half_edge_counts if half_open_result else None,
|
|
211
|
+
half_open_corners=half_corner_counts if half_open_result else None,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def selected_region_mask(
|
|
216
|
+
points: np.ndarray,
|
|
217
|
+
first: np.ndarray,
|
|
218
|
+
second: np.ndarray,
|
|
219
|
+
keep_left: bool,
|
|
220
|
+
keep_right: bool,
|
|
221
|
+
) -> np.ndarray:
|
|
222
|
+
direction = second - first
|
|
223
|
+
signed_cross_product = direction[0] * (points[:, 1] - first[1]) - direction[1] * (
|
|
224
|
+
points[:, 0] - first[0]
|
|
225
|
+
)
|
|
226
|
+
keep = np.zeros(len(points), dtype=bool)
|
|
227
|
+
if keep_left:
|
|
228
|
+
keep |= signed_cross_product >= -SIDE_TOLERANCE
|
|
229
|
+
if keep_right:
|
|
230
|
+
keep |= signed_cross_product <= SIDE_TOLERANCE
|
|
231
|
+
return keep
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def bases(angle, lattice="FCC", axis="110"):
|
|
235
|
+
t = np.deg2rad(angle / 2)
|
|
236
|
+
c, s = np.cos(t), np.sin(t)
|
|
237
|
+
r = np.array([[c, -s], [s, c]])
|
|
238
|
+
b = get_geometry(lattice, axis).planar_basis
|
|
239
|
+
return r @ b, r.T @ b
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def determinant(m):
|
|
243
|
+
return int(m[0, 0] * m[1, 1] - m[0, 1] * m[1, 0])
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class StrainedCell:
|
|
248
|
+
m1: np.ndarray
|
|
249
|
+
m2: np.ndarray
|
|
250
|
+
f1: np.ndarray
|
|
251
|
+
f2: np.ndarray
|
|
252
|
+
cell: np.ndarray # columns, laboratory projection, in units of a0
|
|
253
|
+
max_strain: float
|
|
254
|
+
lattice: str = "FCC"
|
|
255
|
+
axis: str = "110"
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def atoms(self):
|
|
259
|
+
layers = get_geometry(self.lattice, self.axis).layer_count
|
|
260
|
+
return tuple(layers * abs(determinant(m)) for m in (self.m1, self.m2))
|
|
261
|
+
|
|
262
|
+
def label(self):
|
|
263
|
+
n1, n2 = self.atoms
|
|
264
|
+
return f"{n1}/{n2} atoms | max strain {100*self.max_strain:.3f}%"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def reduce_cell(m1, m2, cell):
|
|
268
|
+
"""Gauss-reduce common vectors with identical integer column operations."""
|
|
269
|
+
m1, m2, cell = m1.copy(), m2.copy(), cell.copy()
|
|
270
|
+
for _ in range(64):
|
|
271
|
+
if np.dot(cell[:, 1], cell[:, 1]) < np.dot(cell[:, 0], cell[:, 0]):
|
|
272
|
+
cell = cell[:, ::-1]
|
|
273
|
+
m1 = m1[:, ::-1]
|
|
274
|
+
m2 = m2[:, ::-1]
|
|
275
|
+
q = int(
|
|
276
|
+
np.rint(np.dot(cell[:, 0], cell[:, 1]) / np.dot(cell[:, 0], cell[:, 0]))
|
|
277
|
+
)
|
|
278
|
+
if q == 0:
|
|
279
|
+
break
|
|
280
|
+
cell[:, 1] -= q * cell[:, 0]
|
|
281
|
+
m1[:, 1] -= q * m1[:, 0]
|
|
282
|
+
m2[:, 1] -= q * m2[:, 0]
|
|
283
|
+
if np.linalg.det(cell) < 0:
|
|
284
|
+
cell[:, 1] *= -1
|
|
285
|
+
m1[:, 1] *= -1
|
|
286
|
+
m2[:, 1] *= -1
|
|
287
|
+
return m1, m2, cell
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Qt-free worker entry points, executor ownership and asynchronous search.
|
|
2
|
+
|
|
3
|
+
Workers are module-level functions so a spawned process imports numerical
|
|
4
|
+
modules only. The UI coordinates operations and polls completed futures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from collections import deque
|
|
9
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
|
|
10
|
+
import multiprocessing
|
|
11
|
+
import os
|
|
12
|
+
import numpy as np
|
|
13
|
+
from .crystal import ProjectedGrain, projected_columns, get_geometry
|
|
14
|
+
from .cells import count_cell_atoms
|
|
15
|
+
from .matching import local_near_pairs, same_layer_coincidence_sites
|
|
16
|
+
from .strain import candidate_vectors, solve_cells_chunk, pareto_cells
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def worker_initializer():
|
|
20
|
+
"""Avoid each process starting its own BLAS thread team when available."""
|
|
21
|
+
global _blas_limit
|
|
22
|
+
try:
|
|
23
|
+
from threadpoolctl import threadpool_limits
|
|
24
|
+
|
|
25
|
+
_blas_limit = threadpool_limits(limits=1)
|
|
26
|
+
except ImportError:
|
|
27
|
+
pass # NumPy-only installations remain supported.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def local_match_layers_worker(grain1, grain2, distance, layers):
|
|
31
|
+
return os.getpid(), local_near_pairs(grain1, grain2, distance, layers)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cell_count_worker(*args):
|
|
35
|
+
return os.getpid(), count_cell_atoms(*args)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def generate_grain_worker(
|
|
39
|
+
width: float,
|
|
40
|
+
height: float,
|
|
41
|
+
rotation_deg: float,
|
|
42
|
+
center: tuple[float, float],
|
|
43
|
+
deformation: np.ndarray | None = None,
|
|
44
|
+
lattice: str = "FCC",
|
|
45
|
+
axis: str = "110",
|
|
46
|
+
translation: np.ndarray | None = None,
|
|
47
|
+
) -> tuple[int, ProjectedGrain]:
|
|
48
|
+
"""Process-pool entry point for one projected grain."""
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
os.getpid(),
|
|
52
|
+
projected_columns(
|
|
53
|
+
width, height, rotation_deg, center, deformation, lattice, axis, translation
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def coincidence_layer_worker(
|
|
59
|
+
grain_1: ProjectedGrain,
|
|
60
|
+
grain_2: ProjectedGrain,
|
|
61
|
+
tolerance: float,
|
|
62
|
+
layer: int,
|
|
63
|
+
) -> tuple[int, np.ndarray]:
|
|
64
|
+
"""Process-pool entry point for one stacking-layer coincidence search."""
|
|
65
|
+
|
|
66
|
+
only_first = grain_1.layers == layer
|
|
67
|
+
only_second = grain_2.layers == layer
|
|
68
|
+
first = ProjectedGrain(
|
|
69
|
+
grain_1.positions[only_first],
|
|
70
|
+
np.zeros(np.count_nonzero(only_first), dtype=np.int16),
|
|
71
|
+
grain_1.half_indices[only_first],
|
|
72
|
+
)
|
|
73
|
+
second = ProjectedGrain(
|
|
74
|
+
grain_2.positions[only_second],
|
|
75
|
+
np.zeros(np.count_nonzero(only_second), dtype=np.int16),
|
|
76
|
+
grain_2.half_indices[only_second],
|
|
77
|
+
)
|
|
78
|
+
return os.getpid(), same_layer_coincidence_sites(first, second, tolerance)[0]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def coincidence_layers_worker(grain_1, grain_2, tolerance, layers):
|
|
82
|
+
"""A bounded batch of phases, avoiding one full-array transfer per layer."""
|
|
83
|
+
return os.getpid(), tuple(
|
|
84
|
+
(
|
|
85
|
+
int(layer),
|
|
86
|
+
coincidence_layer_worker(grain_1, grain_2, tolerance, int(layer))[1],
|
|
87
|
+
)
|
|
88
|
+
for layer in layers
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class NearSearch:
|
|
93
|
+
"""Bounded asynchronous search; stale requests never publish results.
|
|
94
|
+
|
|
95
|
+
Preparation and pair solves run off the UI thread. At most workers jobs
|
|
96
|
+
are in flight. Invalidation drops pending jobs and lets bounded running
|
|
97
|
+
chunks finish before submitting the next request.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, workers=1, executor=None):
|
|
101
|
+
self.workers = max(1, int(workers))
|
|
102
|
+
self.executor = executor
|
|
103
|
+
self.owns_executor = executor is None
|
|
104
|
+
self.generation = 0
|
|
105
|
+
self.jobs = deque()
|
|
106
|
+
self.running = {}
|
|
107
|
+
self.parts = {}
|
|
108
|
+
self.total = self.completed = 0
|
|
109
|
+
self.busy = False
|
|
110
|
+
self.error = None
|
|
111
|
+
self.process_ids = set()
|
|
112
|
+
|
|
113
|
+
def cancel(self):
|
|
114
|
+
self.generation += 1
|
|
115
|
+
self.jobs.clear()
|
|
116
|
+
self.parts.clear()
|
|
117
|
+
self.busy = False
|
|
118
|
+
self.error = None
|
|
119
|
+
self.total = self.completed = 0
|
|
120
|
+
for future in self.running:
|
|
121
|
+
future.cancel()
|
|
122
|
+
|
|
123
|
+
def request(self, angle, percent, extent, lattice="FCC", axis="110"):
|
|
124
|
+
self.cancel()
|
|
125
|
+
geometry = get_geometry(lattice, axis)
|
|
126
|
+
if self.executor is None:
|
|
127
|
+
self.executor = (
|
|
128
|
+
ProcessPoolExecutor(
|
|
129
|
+
max_workers=self.workers,
|
|
130
|
+
mp_context=multiprocessing.get_context("spawn"),
|
|
131
|
+
initializer=worker_initializer,
|
|
132
|
+
)
|
|
133
|
+
if self.workers > 1
|
|
134
|
+
else ThreadPoolExecutor(max_workers=1)
|
|
135
|
+
)
|
|
136
|
+
self.args = (angle, percent, extent, geometry.lattice, geometry.axis)
|
|
137
|
+
self.jobs.append(("prepare", candidate_vectors, self.args))
|
|
138
|
+
self.busy = True
|
|
139
|
+
|
|
140
|
+
def poll(self):
|
|
141
|
+
try:
|
|
142
|
+
for future in list(self.running):
|
|
143
|
+
if not future.done():
|
|
144
|
+
continue
|
|
145
|
+
generation, stage, start = self.running.pop(future)
|
|
146
|
+
if generation != self.generation:
|
|
147
|
+
continue
|
|
148
|
+
result = future.result()
|
|
149
|
+
if stage == "prepare":
|
|
150
|
+
i, j = result
|
|
151
|
+
for start in range(0, len(i), 8):
|
|
152
|
+
self.jobs.append(
|
|
153
|
+
(
|
|
154
|
+
"solve",
|
|
155
|
+
solve_cells_chunk,
|
|
156
|
+
(
|
|
157
|
+
self.args[0],
|
|
158
|
+
self.args[1],
|
|
159
|
+
i,
|
|
160
|
+
j,
|
|
161
|
+
start,
|
|
162
|
+
start + 8,
|
|
163
|
+
self.args[3],
|
|
164
|
+
self.args[4],
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
self.total = len(self.jobs)
|
|
169
|
+
else:
|
|
170
|
+
pid, cells = result
|
|
171
|
+
self.process_ids.add(pid)
|
|
172
|
+
self.parts[start] = cells
|
|
173
|
+
self.completed += 1
|
|
174
|
+
while self.jobs and len(self.running) < self.workers:
|
|
175
|
+
stage, function, args = self.jobs.popleft()
|
|
176
|
+
future = self.executor.submit(function, *args)
|
|
177
|
+
self.running[future] = (
|
|
178
|
+
self.generation, stage, args[4] if stage == "solve" else -1
|
|
179
|
+
)
|
|
180
|
+
if self.busy and not self.jobs and not self.running:
|
|
181
|
+
# Equal-area/strain candidates retain serial search order,
|
|
182
|
+
# independently of which process happens to finish first.
|
|
183
|
+
result = pareto_cells([
|
|
184
|
+
cell for start in sorted(self.parts) for cell in self.parts[start]
|
|
185
|
+
])
|
|
186
|
+
self.parts.clear()
|
|
187
|
+
self.busy = False
|
|
188
|
+
return result
|
|
189
|
+
except Exception as error:
|
|
190
|
+
self.cancel()
|
|
191
|
+
self.error = str(error)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def close(self):
|
|
195
|
+
self.cancel()
|
|
196
|
+
if self.owns_executor and self.executor is not None:
|
|
197
|
+
self.executor.shutdown(wait=False, cancel_futures=True)
|
|
198
|
+
self.executor = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ComputeSession:
|
|
202
|
+
"""Own the shared executors and pending work for one viewer session."""
|
|
203
|
+
|
|
204
|
+
def __init__(self):
|
|
205
|
+
self.worker_count = 1
|
|
206
|
+
self.executor = None
|
|
207
|
+
self.local_thread_executor = None
|
|
208
|
+
self.near_search = None
|
|
209
|
+
self.parallel_stage = None
|
|
210
|
+
self.parallel_futures = []
|
|
211
|
+
self.parallel_payload = {}
|
|
212
|
+
self.parallel_generation = 0
|
|
213
|
+
self.worker_process_ids = set()
|
|
214
|
+
self.manual_count_future = None
|
|
215
|
+
self.manual_count_pending = None
|
|
216
|
+
self.manual_count_running_key = None
|
|
217
|
+
|
|
218
|
+
def configure(self, worker_count):
|
|
219
|
+
old_executor, self.executor = self.executor, None
|
|
220
|
+
if old_executor is not None:
|
|
221
|
+
old_executor.shutdown(wait=False, cancel_futures=True)
|
|
222
|
+
self.worker_count = int(worker_count)
|
|
223
|
+
if self.worker_count > 1:
|
|
224
|
+
self.executor = ProcessPoolExecutor(
|
|
225
|
+
max_workers=self.worker_count,
|
|
226
|
+
mp_context=multiprocessing.get_context("spawn"),
|
|
227
|
+
initializer=worker_initializer,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def background_executor(self):
|
|
231
|
+
if self.executor is not None:
|
|
232
|
+
return self.executor
|
|
233
|
+
if self.local_thread_executor is None:
|
|
234
|
+
self.local_thread_executor = ThreadPoolExecutor(max_workers=1)
|
|
235
|
+
return self.local_thread_executor
|
|
236
|
+
|
|
237
|
+
def cancel_parallel(self):
|
|
238
|
+
self.parallel_generation += 1
|
|
239
|
+
for future in self.parallel_futures:
|
|
240
|
+
future.cancel()
|
|
241
|
+
self.parallel_futures = []
|
|
242
|
+
self.parallel_stage = None
|
|
243
|
+
self.parallel_payload = {}
|
|
244
|
+
|
|
245
|
+
def close(self):
|
|
246
|
+
self.manual_count_pending = None
|
|
247
|
+
if self.manual_count_future is not None:
|
|
248
|
+
self.manual_count_future.cancel()
|
|
249
|
+
if self.near_search is not None:
|
|
250
|
+
self.near_search.close()
|
|
251
|
+
self.cancel_parallel()
|
|
252
|
+
executor, self.executor = self.executor, None
|
|
253
|
+
if executor is not None:
|
|
254
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
255
|
+
if self.local_thread_executor is not None:
|
|
256
|
+
self.local_thread_executor.shutdown(wait=False, cancel_futures=True)
|
|
257
|
+
self.local_thread_executor = None
|