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.
@@ -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()
@@ -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