materialite 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.
- materialite/__init__.py +28 -0
- materialite/basis_operations.py +118 -0
- materialite/get_ipf_colors.py +199 -0
- materialite/importers.py +182 -0
- materialite/json_operations.py +122 -0
- materialite/material.py +1085 -0
- materialite/models/__init__.py +17 -0
- materialite/models/convolution_gpu_model.py +1408 -0
- materialite/models/convolution_microstructure_model.py +2097 -0
- materialite/models/convolution_model.py +1397 -0
- materialite/models/decoupled_crystal_elasticity.py +37 -0
- materialite/models/grain_coarsening_model.py +288 -0
- materialite/models/model.py +30 -0
- materialite/models/small_strain_fft/__init__.py +19 -0
- materialite/models/small_strain_fft/elastic.py +49 -0
- materialite/models/small_strain_fft/elastic_viscoplastic.py +280 -0
- materialite/models/small_strain_fft/hardening_laws.py +94 -0
- materialite/models/small_strain_fft/isotropic_elastic_plastic.py +187 -0
- materialite/models/small_strain_fft/load_schedule.py +119 -0
- materialite/models/small_strain_fft/multiphase.py +92 -0
- materialite/models/small_strain_fft/small_strain_fft.py +331 -0
- materialite/models/taylor_model.py +130 -0
- materialite/slip_system.py +173 -0
- materialite/tensor.py +1557 -0
- materialite/util/__init__.py +13 -0
- materialite/util/util.py +88 -0
- materialite-0.1.0.dist-info/METADATA +376 -0
- materialite-0.1.0.dist-info/RECORD +31 -0
- materialite-0.1.0.dist-info/WHEEL +5 -0
- materialite-0.1.0.dist-info/licenses/LICENSE +202 -0
- materialite-0.1.0.dist-info/top_level.txt +1 -0
materialite/material.py
ADDED
|
@@ -0,0 +1,1085 @@
|
|
|
1
|
+
# Copyright 2025 United States Government as represented by the Administrator of the
|
|
2
|
+
# National Aeronautics and Space Administration. All Rights Reserved.
|
|
3
|
+
#
|
|
4
|
+
# The Materialite platform is licensed under the Apache License, Version 2.0
|
|
5
|
+
# (the "License"); you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software distributed
|
|
9
|
+
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
10
|
+
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
|
11
|
+
# specific language governing permissions and limitations under the License.
|
|
12
|
+
|
|
13
|
+
from abc import abstractmethod
|
|
14
|
+
from copy import deepcopy
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import pyvista as pv
|
|
21
|
+
except ImportError:
|
|
22
|
+
pv = None
|
|
23
|
+
from scipy import spatial
|
|
24
|
+
|
|
25
|
+
from materialite.tensor import Order2SymmetricTensor, Orientation, Scalar, Vector
|
|
26
|
+
from materialite.util import cartesian_grid, power_of_two_below, repeat_data
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Material:
|
|
30
|
+
"""
|
|
31
|
+
A 3D point grid representation for materials modeling.
|
|
32
|
+
|
|
33
|
+
The Material class provides a unified data structure for representing
|
|
34
|
+
materials as 3D grids of points with associated spatial numerical or
|
|
35
|
+
tensorial fields.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
dimensions : list, default [16, 16, 16]
|
|
40
|
+
Number of points in each direction [x, y, z].
|
|
41
|
+
origin : list, default [0, 0, 0]
|
|
42
|
+
Coordinates of the domain origin [x, y, z].
|
|
43
|
+
spacing : list, default [1, 1, 1]
|
|
44
|
+
Distance between points in each direction [x, y, z].
|
|
45
|
+
sizes : list, optional
|
|
46
|
+
Total size of the domain in each direction. If provided, overrides spacing.
|
|
47
|
+
fields : dict or DataFrame, optional
|
|
48
|
+
Initial field data. If None, creates coordinate fields only.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
dimensions=[16, 16, 16],
|
|
54
|
+
origin=[0, 0, 0],
|
|
55
|
+
spacing=[1, 1, 1],
|
|
56
|
+
sizes=None,
|
|
57
|
+
fields=None,
|
|
58
|
+
):
|
|
59
|
+
self._dimensions = np.array(dimensions)
|
|
60
|
+
self._origin = np.array(origin)
|
|
61
|
+
spacing = np.array(spacing)
|
|
62
|
+
sizes = np.array(sizes) if sizes is not None else None
|
|
63
|
+
self._spacing, self._sizes = self._get_spacing_and_sizes(
|
|
64
|
+
spacing, sizes, self.dimensions
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.fields = self._initialize_fields(fields)
|
|
68
|
+
self.state = dict()
|
|
69
|
+
self._regional_fields = dict()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def origin(self):
|
|
73
|
+
"""Origin coordinates of the domain."""
|
|
74
|
+
return self._origin.copy()
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def dimensions(self):
|
|
78
|
+
"""Number of points in each direction."""
|
|
79
|
+
return self._dimensions.copy()
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def spacing(self):
|
|
83
|
+
"""Spacing between points in each direction."""
|
|
84
|
+
return self._spacing.copy()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def sizes(self):
|
|
88
|
+
"""Total size of the domain in each direction."""
|
|
89
|
+
return self._sizes.copy()
|
|
90
|
+
|
|
91
|
+
def _get_spacing_and_sizes(self, spacing, sizes, dimensions):
|
|
92
|
+
"""Calculate spacing and sizes from given parameters."""
|
|
93
|
+
if sizes is not None:
|
|
94
|
+
spacing = sizes / (dimensions - 1)
|
|
95
|
+
else:
|
|
96
|
+
sizes = spacing * (dimensions - 1)
|
|
97
|
+
return spacing, sizes
|
|
98
|
+
|
|
99
|
+
def _initialize_fields(self, fields):
|
|
100
|
+
"""Initialize the fields DataFrame with coordinate information."""
|
|
101
|
+
grid = cartesian_grid(self.dimensions)
|
|
102
|
+
if fields is None:
|
|
103
|
+
fields = pd.DataFrame(
|
|
104
|
+
{
|
|
105
|
+
"x": grid[:, 0] * self.spacing[0] + self.origin[0],
|
|
106
|
+
"y": grid[:, 1] * self.spacing[1] + self.origin[1],
|
|
107
|
+
"z": grid[:, 2] * self.spacing[2] + self.origin[2],
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
fields = self._initialize_fields_from_user_input(fields, grid)
|
|
112
|
+
|
|
113
|
+
return fields.assign(x_id=grid[:, 0], y_id=grid[:, 1], z_id=grid[:, 2])
|
|
114
|
+
|
|
115
|
+
def _initialize_fields_from_user_input(self, fields, grid):
|
|
116
|
+
"""Process user-provided fields and validate against grid dimensions."""
|
|
117
|
+
fields = pd.DataFrame(fields)
|
|
118
|
+
if len(fields) != self.num_points:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"length of fields does not match number of points from dimensions"
|
|
121
|
+
)
|
|
122
|
+
provided_labels = fields.columns
|
|
123
|
+
if np.any(
|
|
124
|
+
["x" in provided_labels, "y" in provided_labels, "z" in provided_labels]
|
|
125
|
+
):
|
|
126
|
+
# user provided at least one of x, y, and z
|
|
127
|
+
# fill singleton dimensions with ones in fields
|
|
128
|
+
singleton_dimensions = np.where(self.dimensions == 1)[0]
|
|
129
|
+
xyz = np.array(["x", "y", "z"])
|
|
130
|
+
fields = fields.assign(
|
|
131
|
+
**dict(
|
|
132
|
+
zip(xyz[singleton_dimensions], self.origin[singleton_dimensions])
|
|
133
|
+
)
|
|
134
|
+
).sort_values(by=["x", "y", "z"], ignore_index=True)
|
|
135
|
+
inferred_dims = [len(fields[s].unique()) for s in ["x", "y", "z"]]
|
|
136
|
+
if not np.array_equal(inferred_dims, self.dimensions):
|
|
137
|
+
raise ValueError(
|
|
138
|
+
"provided x, y, and z fields do not match provided dimensions"
|
|
139
|
+
)
|
|
140
|
+
inferred_sizes = (
|
|
141
|
+
fields[["x", "y", "z"]].max() - fields[["x", "y", "z"]].min()
|
|
142
|
+
)
|
|
143
|
+
if not np.array_equal(inferred_sizes, self.sizes):
|
|
144
|
+
raise ValueError(
|
|
145
|
+
"provided x, y, and z fields do not match provided sizes"
|
|
146
|
+
)
|
|
147
|
+
return fields
|
|
148
|
+
else:
|
|
149
|
+
# x, y, and z were all not provided by the user
|
|
150
|
+
return fields.assign(
|
|
151
|
+
x=grid[:, 0] * self.spacing[0] + self.origin[0],
|
|
152
|
+
y=grid[:, 1] * self.spacing[1] + self.origin[1],
|
|
153
|
+
z=grid[:, 2] * self.spacing[2] + self.origin[2],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def num_points(self):
|
|
158
|
+
"""Total number of points in the material."""
|
|
159
|
+
return self.dimensions[0] * self.dimensions[1] * self.dimensions[2]
|
|
160
|
+
|
|
161
|
+
def run(self, model, **kwargs):
|
|
162
|
+
"""
|
|
163
|
+
Run a model on this material.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
model : callable
|
|
168
|
+
A model object or function that takes a Material as first argument.
|
|
169
|
+
**kwargs
|
|
170
|
+
Additional arguments passed to the model.
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
Result of the model evaluation.
|
|
175
|
+
"""
|
|
176
|
+
return model(self, **kwargs)
|
|
177
|
+
|
|
178
|
+
def copy(self):
|
|
179
|
+
"""Create a deep copy of the material."""
|
|
180
|
+
return deepcopy(self)
|
|
181
|
+
|
|
182
|
+
def get_fields(self):
|
|
183
|
+
"""
|
|
184
|
+
Get all fields including regional fields merged in.
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
pandas.DataFrame
|
|
189
|
+
Combined field data with regional fields merged.
|
|
190
|
+
"""
|
|
191
|
+
fields = self.fields.copy()
|
|
192
|
+
for k, v in self._regional_fields.items():
|
|
193
|
+
try:
|
|
194
|
+
fields = fields.merge(
|
|
195
|
+
v, on=k, how="left", suffixes=(False, False), validate="many_to_one"
|
|
196
|
+
)
|
|
197
|
+
except ValueError:
|
|
198
|
+
raise ValueError(
|
|
199
|
+
"Error when merging regional fields. Two different regional fields defined values for the same field."
|
|
200
|
+
)
|
|
201
|
+
return fields
|
|
202
|
+
|
|
203
|
+
def extract(self, labels):
|
|
204
|
+
"""
|
|
205
|
+
Extract field data as arrays or tensor objects.
|
|
206
|
+
|
|
207
|
+
Parameters
|
|
208
|
+
----------
|
|
209
|
+
labels : str or list
|
|
210
|
+
Field name(s) to extract.
|
|
211
|
+
|
|
212
|
+
Returns
|
|
213
|
+
-------
|
|
214
|
+
numpy.ndarray or tensor object
|
|
215
|
+
Extracted field data.
|
|
216
|
+
"""
|
|
217
|
+
if isinstance(labels, str):
|
|
218
|
+
data = self.get_fields()[labels].to_list()
|
|
219
|
+
try:
|
|
220
|
+
result = type(data[0]).from_list(data)
|
|
221
|
+
except AttributeError:
|
|
222
|
+
result = np.array(data)
|
|
223
|
+
else:
|
|
224
|
+
result = self.get_fields()[labels].to_numpy()
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
def create_fields(self, fields):
|
|
228
|
+
"""
|
|
229
|
+
Create a new material with additional fields.
|
|
230
|
+
|
|
231
|
+
Parameters
|
|
232
|
+
----------
|
|
233
|
+
fields : dict
|
|
234
|
+
Dictionary of field_name: field_data pairs.
|
|
235
|
+
|
|
236
|
+
Returns
|
|
237
|
+
-------
|
|
238
|
+
Material
|
|
239
|
+
New material with the added fields.
|
|
240
|
+
"""
|
|
241
|
+
material = self.copy()
|
|
242
|
+
material.fields = material.fields.assign(**fields)
|
|
243
|
+
return material
|
|
244
|
+
|
|
245
|
+
def create_random_integer_field(
|
|
246
|
+
self, label, low, high=None, rng=np.random.default_rng()
|
|
247
|
+
):
|
|
248
|
+
"""
|
|
249
|
+
Create a field with random integer values.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
label : str
|
|
254
|
+
Name of the new field.
|
|
255
|
+
low : int
|
|
256
|
+
Lower bound (inclusive).
|
|
257
|
+
high : int, optional
|
|
258
|
+
Upper bound (exclusive).
|
|
259
|
+
rng : numpy.random.Generator
|
|
260
|
+
Random number generator.
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
Material
|
|
265
|
+
New material with the random field added.
|
|
266
|
+
"""
|
|
267
|
+
values = rng.integers(
|
|
268
|
+
low=low, high=high, size=self.num_points
|
|
269
|
+
) # Excludes high value
|
|
270
|
+
return self.create_fields({label: values})
|
|
271
|
+
|
|
272
|
+
def create_uniform_field(self, label, value):
|
|
273
|
+
"""
|
|
274
|
+
Create a field with uniform values at all points.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
label : str
|
|
279
|
+
Name of the new field.
|
|
280
|
+
value : any
|
|
281
|
+
Value to assign to all points.
|
|
282
|
+
|
|
283
|
+
Returns
|
|
284
|
+
-------
|
|
285
|
+
Material
|
|
286
|
+
New material with the uniform field added.
|
|
287
|
+
"""
|
|
288
|
+
field = {label: [value] * self.num_points}
|
|
289
|
+
return self.create_fields(field)
|
|
290
|
+
|
|
291
|
+
def segment(self, label, threshold, low=0, high=1, new_label=None):
|
|
292
|
+
"""
|
|
293
|
+
Create a binary segmentation of a field based on a threshold.
|
|
294
|
+
|
|
295
|
+
Parameters
|
|
296
|
+
----------
|
|
297
|
+
label : str
|
|
298
|
+
Name of the field to segment.
|
|
299
|
+
threshold : float
|
|
300
|
+
Threshold value for segmentation.
|
|
301
|
+
low : any, default 0
|
|
302
|
+
Value assigned to points below threshold.
|
|
303
|
+
high : any, default 1
|
|
304
|
+
Value assigned to points above threshold.
|
|
305
|
+
new_label : str, optional
|
|
306
|
+
Name for the new field. Defaults to "segmented_{label}".
|
|
307
|
+
|
|
308
|
+
Returns
|
|
309
|
+
-------
|
|
310
|
+
Material
|
|
311
|
+
New material with the segmented field added.
|
|
312
|
+
"""
|
|
313
|
+
if new_label is None:
|
|
314
|
+
new_label = "segmented_" + label
|
|
315
|
+
return self.create_fields(
|
|
316
|
+
{new_label: np.where(self.extract(label) > threshold, high, low)}
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def create_voronoi(
|
|
320
|
+
self,
|
|
321
|
+
num_regions=10,
|
|
322
|
+
label="region",
|
|
323
|
+
rng=np.random.default_rng(),
|
|
324
|
+
periodic=False,
|
|
325
|
+
):
|
|
326
|
+
"""
|
|
327
|
+
Create Voronoi regions in the material.
|
|
328
|
+
|
|
329
|
+
Parameters
|
|
330
|
+
----------
|
|
331
|
+
num_regions : int, default 10
|
|
332
|
+
Number of Voronoi regions to create.
|
|
333
|
+
label : str, default "region"
|
|
334
|
+
Name for the region field.
|
|
335
|
+
rng : numpy.random.Generator
|
|
336
|
+
Random number generator for seed points.
|
|
337
|
+
periodic : bool, default False
|
|
338
|
+
Whether to enforce periodic boundary conditions.
|
|
339
|
+
|
|
340
|
+
Returns
|
|
341
|
+
-------
|
|
342
|
+
Material
|
|
343
|
+
New material with Voronoi regions added.
|
|
344
|
+
"""
|
|
345
|
+
voronoi_points = rng.random((num_regions, 3)) * self.sizes + self.origin
|
|
346
|
+
material_points = self.fields[["x", "y", "z"]].to_numpy()
|
|
347
|
+
|
|
348
|
+
if periodic:
|
|
349
|
+
voronoi_ref = np.tile(np.arange(num_regions), 27)
|
|
350
|
+
voronoi_all = np.arange(num_regions * 27)
|
|
351
|
+
voronoi_dict = dict(zip(voronoi_all, voronoi_ref))
|
|
352
|
+
voronoi_points_periodic = repeat_data(voronoi_points, *self.sizes)
|
|
353
|
+
material_points_periodic = repeat_data(material_points, *self.sizes)
|
|
354
|
+
_, point_regions_periodic = spatial.cKDTree(voronoi_points_periodic).query(
|
|
355
|
+
material_points_periodic, k=1
|
|
356
|
+
)
|
|
357
|
+
point_regions = [
|
|
358
|
+
voronoi_dict[p] for p in point_regions_periodic[: self.num_points]
|
|
359
|
+
]
|
|
360
|
+
else:
|
|
361
|
+
_, point_regions = spatial.cKDTree(voronoi_points).query(
|
|
362
|
+
material_points, k=1
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return self.create_fields({label: point_regions})
|
|
366
|
+
|
|
367
|
+
def assign_random_orientations(
|
|
368
|
+
self,
|
|
369
|
+
region_label="region",
|
|
370
|
+
orientation_label="orientation",
|
|
371
|
+
rng=np.random.default_rng(),
|
|
372
|
+
):
|
|
373
|
+
"""
|
|
374
|
+
Assign random crystallographic orientations to regions.
|
|
375
|
+
|
|
376
|
+
Parameters
|
|
377
|
+
----------
|
|
378
|
+
region_label : str, default "region"
|
|
379
|
+
Name of the field containing region IDs.
|
|
380
|
+
orientation_label : str, default "orientation"
|
|
381
|
+
Name for the new orientation field.
|
|
382
|
+
rng : numpy.random.Generator
|
|
383
|
+
Random number generator.
|
|
384
|
+
|
|
385
|
+
Returns
|
|
386
|
+
-------
|
|
387
|
+
Material
|
|
388
|
+
New material with random orientations assigned to regions.
|
|
389
|
+
"""
|
|
390
|
+
unique_regions = self.fields[region_label].unique()
|
|
391
|
+
num_regions = len(unique_regions)
|
|
392
|
+
orientations = Orientation.random(num=num_regions, rng=rng)
|
|
393
|
+
regional_field = {region_label: unique_regions, orientation_label: orientations}
|
|
394
|
+
return self.create_regional_fields(
|
|
395
|
+
region_label,
|
|
396
|
+
pd.DataFrame(regional_field).sort_values(
|
|
397
|
+
by=region_label, ignore_index=True
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def insert_feature(self, feature, fields):
|
|
402
|
+
"""
|
|
403
|
+
Insert field values into points inside a geometric feature.
|
|
404
|
+
|
|
405
|
+
Parameters
|
|
406
|
+
----------
|
|
407
|
+
feature : Feature
|
|
408
|
+
Geometric feature object defining the region.
|
|
409
|
+
fields : dict
|
|
410
|
+
Dictionary of field_name: value pairs to insert.
|
|
411
|
+
|
|
412
|
+
Returns
|
|
413
|
+
-------
|
|
414
|
+
Material
|
|
415
|
+
New material with field values inserted in the feature region.
|
|
416
|
+
"""
|
|
417
|
+
is_inside = feature.check_inside(self.fields.x, self.fields.y, self.fields.z)
|
|
418
|
+
new_fields = {}
|
|
419
|
+
for label, values in fields.items():
|
|
420
|
+
new_values = self.extract(label)
|
|
421
|
+
new_values[is_inside] = values
|
|
422
|
+
if isinstance(new_values, np.ndarray) and len(new_values.shape) > 1:
|
|
423
|
+
new_fields[label] = new_values.tolist()
|
|
424
|
+
else:
|
|
425
|
+
new_fields[label] = new_values
|
|
426
|
+
return self.create_fields(new_fields)
|
|
427
|
+
|
|
428
|
+
def remove_field(self, field_label, in_regional_field=None):
|
|
429
|
+
"""
|
|
430
|
+
Remove a field from the material.
|
|
431
|
+
|
|
432
|
+
Parameters
|
|
433
|
+
----------
|
|
434
|
+
field_label : str
|
|
435
|
+
Name of the field to remove.
|
|
436
|
+
in_regional_field : str, optional
|
|
437
|
+
Name of regional field if removing from regional fields.
|
|
438
|
+
|
|
439
|
+
Returns
|
|
440
|
+
-------
|
|
441
|
+
Material
|
|
442
|
+
New material with the specified field removed.
|
|
443
|
+
"""
|
|
444
|
+
material = self.copy()
|
|
445
|
+
if in_regional_field is not None:
|
|
446
|
+
material._regional_fields[in_regional_field] = material._regional_fields[
|
|
447
|
+
in_regional_field
|
|
448
|
+
].drop(field_label, axis=1)
|
|
449
|
+
elif field_label in list(self.fields):
|
|
450
|
+
material.fields = self.fields.drop(field_label, axis=1)
|
|
451
|
+
if field_label in self._regional_fields.keys():
|
|
452
|
+
del material._regional_fields[field_label]
|
|
453
|
+
return material
|
|
454
|
+
|
|
455
|
+
def create_regional_fields(self, region_label, regional_fields):
|
|
456
|
+
"""
|
|
457
|
+
Create fields that vary by region rather than by point.
|
|
458
|
+
|
|
459
|
+
Parameters
|
|
460
|
+
----------
|
|
461
|
+
region_label : str
|
|
462
|
+
Name of the field containing region IDs.
|
|
463
|
+
regional_fields : dict or DataFrame
|
|
464
|
+
Regional field data with region_label as key column.
|
|
465
|
+
|
|
466
|
+
Returns
|
|
467
|
+
-------
|
|
468
|
+
Material
|
|
469
|
+
New material with regional fields added.
|
|
470
|
+
"""
|
|
471
|
+
regional_fields = pd.DataFrame(regional_fields)
|
|
472
|
+
if regional_fields[region_label].duplicated().any():
|
|
473
|
+
raise ValueError(
|
|
474
|
+
f"Could not create regional field: {region_label} has non-unique values in regional field"
|
|
475
|
+
)
|
|
476
|
+
material = self.copy()
|
|
477
|
+
if region_label in self._regional_fields:
|
|
478
|
+
# merge with existing regional fields and overwrite any duplicated fields
|
|
479
|
+
old_df = material._regional_fields[region_label].copy()
|
|
480
|
+
old_regions = np.sort(old_df[region_label].to_numpy())
|
|
481
|
+
new_regions = np.sort(regional_fields[region_label].to_numpy())
|
|
482
|
+
if not np.array_equal(old_regions, new_regions):
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"Values of {region_label} do not match between new and existing regional field"
|
|
485
|
+
)
|
|
486
|
+
regional_fields = (
|
|
487
|
+
old_df[[region_label]]
|
|
488
|
+
.merge(regional_fields, on=region_label, how="left")
|
|
489
|
+
.combine_first(old_df)
|
|
490
|
+
)
|
|
491
|
+
elif region_label not in list(self.fields):
|
|
492
|
+
raise ValueError(
|
|
493
|
+
f"Could not create regional field: {region_label} is not a field"
|
|
494
|
+
)
|
|
495
|
+
# make sure merges won't produce any NaNs
|
|
496
|
+
try:
|
|
497
|
+
data_check = material.fields.merge(
|
|
498
|
+
regional_fields,
|
|
499
|
+
on=region_label,
|
|
500
|
+
how="left",
|
|
501
|
+
suffixes=(False, False),
|
|
502
|
+
indicator=True,
|
|
503
|
+
)
|
|
504
|
+
except ValueError:
|
|
505
|
+
raise ValueError(
|
|
506
|
+
f"Could not create regional field: the provided regional fields include a field that already exists in the Material"
|
|
507
|
+
)
|
|
508
|
+
if (data_check["_merge"] == "left_only").any():
|
|
509
|
+
missing_keys = (
|
|
510
|
+
data_check.loc[data_check["_merge"] == "left_only", region_label]
|
|
511
|
+
.unique()
|
|
512
|
+
.tolist()
|
|
513
|
+
)
|
|
514
|
+
raise ValueError(
|
|
515
|
+
f"Values of {region_label} not found in regional field: {missing_keys}"
|
|
516
|
+
)
|
|
517
|
+
material._regional_fields[region_label] = regional_fields
|
|
518
|
+
return material
|
|
519
|
+
|
|
520
|
+
def extract_regional_field(self, region_label, field_label=None):
|
|
521
|
+
"""
|
|
522
|
+
Extract regional field data.
|
|
523
|
+
|
|
524
|
+
Parameters
|
|
525
|
+
----------
|
|
526
|
+
region_label : str
|
|
527
|
+
Name of the regional field.
|
|
528
|
+
field_label : str, optional
|
|
529
|
+
Specific field within the regional data to extract.
|
|
530
|
+
|
|
531
|
+
Returns
|
|
532
|
+
-------
|
|
533
|
+
DataFrame or array
|
|
534
|
+
Regional field data.
|
|
535
|
+
"""
|
|
536
|
+
if field_label is None:
|
|
537
|
+
return self._regional_fields[region_label]
|
|
538
|
+
else:
|
|
539
|
+
data = self._regional_fields[region_label][field_label].to_list()
|
|
540
|
+
try:
|
|
541
|
+
result = type(data[0]).from_list(data)
|
|
542
|
+
except AttributeError:
|
|
543
|
+
result = data
|
|
544
|
+
return result
|
|
545
|
+
|
|
546
|
+
def get_region_volume_fractions(self, region_label="region"):
|
|
547
|
+
"""
|
|
548
|
+
Calculate volume fraction of each region.
|
|
549
|
+
|
|
550
|
+
Parameters
|
|
551
|
+
----------
|
|
552
|
+
region_label : str, default "region"
|
|
553
|
+
Name of the field containing region IDs.
|
|
554
|
+
|
|
555
|
+
Returns
|
|
556
|
+
-------
|
|
557
|
+
dict
|
|
558
|
+
Dictionary mapping region ID to volume fraction.
|
|
559
|
+
"""
|
|
560
|
+
features = self.fields[region_label].to_numpy()
|
|
561
|
+
unique_features, points_per_feature = np.unique(features, return_counts=True)
|
|
562
|
+
return dict(zip(unique_features, points_per_feature / self.num_points))
|
|
563
|
+
|
|
564
|
+
def get_region_indices(self, region_label="region"):
|
|
565
|
+
"""
|
|
566
|
+
Get point indices for each region.
|
|
567
|
+
|
|
568
|
+
Parameters
|
|
569
|
+
----------
|
|
570
|
+
region_label : str, default "region"
|
|
571
|
+
Name of the field containing region IDs.
|
|
572
|
+
|
|
573
|
+
Returns
|
|
574
|
+
-------
|
|
575
|
+
dict
|
|
576
|
+
Dictionary mapping region ID to list of point indices.
|
|
577
|
+
"""
|
|
578
|
+
regions = self.fields[region_label]
|
|
579
|
+
return {
|
|
580
|
+
region: indices.tolist()
|
|
581
|
+
for region, indices in regions.groupby(regions).groups.items()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
def plot(
|
|
585
|
+
self,
|
|
586
|
+
label,
|
|
587
|
+
component=None,
|
|
588
|
+
kind="voxel",
|
|
589
|
+
colormap="coolwarm",
|
|
590
|
+
show_grid=False,
|
|
591
|
+
show_edges=False,
|
|
592
|
+
color_lims=None,
|
|
593
|
+
opacity=1.0,
|
|
594
|
+
):
|
|
595
|
+
"""
|
|
596
|
+
Plot a field using PyVista visualization.
|
|
597
|
+
|
|
598
|
+
Parameters
|
|
599
|
+
----------
|
|
600
|
+
label : str
|
|
601
|
+
Name of the field to plot.
|
|
602
|
+
component : int or list, optional
|
|
603
|
+
Component(s) to plot for multi-component fields.
|
|
604
|
+
kind : str, default "voxel"
|
|
605
|
+
Plot type: "voxel", "voxelnode", "point", or "ipf_map".
|
|
606
|
+
colormap : str, default "coolwarm"
|
|
607
|
+
Colormap name.
|
|
608
|
+
show_grid : bool, default False
|
|
609
|
+
Whether to show grid lines.
|
|
610
|
+
show_edges : bool, default False
|
|
611
|
+
Whether to show voxel edges.
|
|
612
|
+
color_lims : tuple, optional
|
|
613
|
+
Color scale limits (min, max).
|
|
614
|
+
opacity : float, default 1.0
|
|
615
|
+
Transparency level.
|
|
616
|
+
"""
|
|
617
|
+
fields = self.get_fields().sort_values(by=["z", "y", "x"])
|
|
618
|
+
|
|
619
|
+
# Determine slices to grab components of the field
|
|
620
|
+
slices = [slice(None)]
|
|
621
|
+
if component is not None:
|
|
622
|
+
component = [component] if not isinstance(component, list) else component
|
|
623
|
+
for c in component:
|
|
624
|
+
slices.append(slice(c, c + 1))
|
|
625
|
+
slices = tuple(slices)
|
|
626
|
+
|
|
627
|
+
if label not in list(fields):
|
|
628
|
+
raise TypeError(f"{label} not found in fields")
|
|
629
|
+
|
|
630
|
+
data = fields[label].to_list()
|
|
631
|
+
if isinstance(data[0], Order2SymmetricTensor):
|
|
632
|
+
result = type(data[0]).from_list(data).stress_voigt
|
|
633
|
+
elif isinstance(data[0], Scalar) or isinstance(data[0], Vector):
|
|
634
|
+
result = type(data[0]).from_list(data).components
|
|
635
|
+
else:
|
|
636
|
+
result = np.array(data)
|
|
637
|
+
plot_array = np.squeeze(result[slices])
|
|
638
|
+
if len(plot_array.shape) > 1 and kind.lower() != "ipf_map":
|
|
639
|
+
raise ValueError(
|
|
640
|
+
"Tried to plot a field with multiple dimensions. "
|
|
641
|
+
+ "You may need to specify a component."
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
# Material points are displayed as voxel centroid (cell) values
|
|
645
|
+
if kind.lower() == "voxel":
|
|
646
|
+
grid = pv.ImageData()
|
|
647
|
+
grid.dimensions = self.dimensions + 1
|
|
648
|
+
grid.origin = list(self.origin - self.spacing / 2)
|
|
649
|
+
grid.spacing = self.spacing
|
|
650
|
+
grid.cell_data[label] = plot_array
|
|
651
|
+
grid.plot(
|
|
652
|
+
cmap=colormap,
|
|
653
|
+
clim=color_lims,
|
|
654
|
+
show_edges=show_edges,
|
|
655
|
+
show_grid=show_grid,
|
|
656
|
+
scalar_bar_args=dict(vertical=True, interactive=True),
|
|
657
|
+
opacity=opacity,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# Material points are displayed as voxel node values
|
|
661
|
+
elif kind.lower() == "voxelnode":
|
|
662
|
+
grid = pv.ImageData()
|
|
663
|
+
grid.dimensions = self.dimensions
|
|
664
|
+
grid.origin = self.origin
|
|
665
|
+
grid.spacing = self.spacing
|
|
666
|
+
grid.point_data[label] = plot_array
|
|
667
|
+
grid.plot(
|
|
668
|
+
cmap=colormap,
|
|
669
|
+
clim=color_lims,
|
|
670
|
+
show_edges=show_edges,
|
|
671
|
+
show_grid=show_grid,
|
|
672
|
+
scalar_bar_args=dict(vertical=True, interactive=True),
|
|
673
|
+
opacity=opacity,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
# Material points are displayed as a point cloud
|
|
677
|
+
elif kind.lower() == "point":
|
|
678
|
+
point_cloud = pv.PolyData(
|
|
679
|
+
fields[["x", "y", "z"]].to_numpy(dtype=np.float32)
|
|
680
|
+
)
|
|
681
|
+
point_cloud[label] = plot_array
|
|
682
|
+
point_cloud.plot(
|
|
683
|
+
render_points_as_spheres=True,
|
|
684
|
+
cmap=colormap,
|
|
685
|
+
clim=color_lims,
|
|
686
|
+
show_grid=show_grid,
|
|
687
|
+
scalar_bar_args=dict(vertical=True, interactive=True),
|
|
688
|
+
opacity=opacity,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
elif kind.lower() == "ipf_map":
|
|
692
|
+
grid = pv.ImageData()
|
|
693
|
+
grid.dimensions = self.dimensions + 1
|
|
694
|
+
grid.origin = list(self.origin - self.spacing / 2)
|
|
695
|
+
grid.spacing = self.spacing
|
|
696
|
+
grid["colors"] = plot_array
|
|
697
|
+
grid.plot(scalars="colors", rgb=True)
|
|
698
|
+
else:
|
|
699
|
+
raise ValueError(f"{kind} is not a valid plot kind")
|
|
700
|
+
|
|
701
|
+
@staticmethod
|
|
702
|
+
def _get_cropped_fields(fields, points_above):
|
|
703
|
+
"""Filter fields to keep only points below specified indices."""
|
|
704
|
+
return fields.query(
|
|
705
|
+
f"x_id <= {points_above[0]} and y_id <= {points_above[1]} and z_id <= {points_above[2]}"
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
def crop_by_range(self, x_range=None, y_range=None, z_range=None):
|
|
709
|
+
"""
|
|
710
|
+
Crop the material by coordinate ranges. The returned material will include points at both
|
|
711
|
+
endpoints of the provided ranges (i.e., the endpoints are inclusive).
|
|
712
|
+
|
|
713
|
+
Parameters
|
|
714
|
+
----------
|
|
715
|
+
x_range, y_range, z_range : tuple, optional
|
|
716
|
+
Coordinate ranges (min, max) for each direction.
|
|
717
|
+
|
|
718
|
+
Returns
|
|
719
|
+
-------
|
|
720
|
+
Material
|
|
721
|
+
Cropped material.
|
|
722
|
+
"""
|
|
723
|
+
fields = self.fields.copy()
|
|
724
|
+
x_range = (self.origin[0], np.inf) if x_range is None else x_range
|
|
725
|
+
y_range = (self.origin[1], np.inf) if y_range is None else y_range
|
|
726
|
+
z_range = (self.origin[2], np.inf) if z_range is None else z_range
|
|
727
|
+
fields = fields.query(
|
|
728
|
+
f"x >= {x_range[0]} and x <= {x_range[1]} and y >= {y_range[0]} and y <= {y_range[1]} and z >= {z_range[0]} and z <= {z_range[1]}"
|
|
729
|
+
)
|
|
730
|
+
dimensions = [
|
|
731
|
+
fields.x_id.max() - fields.x_id.min() + 1,
|
|
732
|
+
fields.y_id.max() - fields.y_id.min() + 1,
|
|
733
|
+
fields.z_id.max() - fields.z_id.min() + 1,
|
|
734
|
+
]
|
|
735
|
+
origin = np.array([fields.x.min(), fields.y.min(), fields.z.min()])
|
|
736
|
+
new_material = Material(
|
|
737
|
+
dimensions=dimensions,
|
|
738
|
+
origin=origin,
|
|
739
|
+
spacing=self.spacing,
|
|
740
|
+
fields=fields.drop(columns=["x_id", "y_id", "z_id"]).reset_index(drop=True),
|
|
741
|
+
)
|
|
742
|
+
for k, v in self._regional_fields.items():
|
|
743
|
+
new_material = new_material.create_regional_fields(k, v)
|
|
744
|
+
return new_material
|
|
745
|
+
|
|
746
|
+
def crop_by_id_range(self, x_id_range=None, y_id_range=None, z_id_range=None):
|
|
747
|
+
"""
|
|
748
|
+
Crop the material by point ID ranges. The returned material will include points at both
|
|
749
|
+
endpoints of the provided ranges (i.e., the endpoints are inclusive).
|
|
750
|
+
|
|
751
|
+
Parameters
|
|
752
|
+
----------
|
|
753
|
+
x_id_range, y_id_range, z_id_range : tuple, optional
|
|
754
|
+
Point ID ranges (min, max) for each direction.
|
|
755
|
+
|
|
756
|
+
Returns
|
|
757
|
+
-------
|
|
758
|
+
Material
|
|
759
|
+
Cropped material.
|
|
760
|
+
"""
|
|
761
|
+
fields = self.fields.copy()
|
|
762
|
+
x_id_range = (fields.x_id.min(), np.inf) if x_id_range is None else x_id_range
|
|
763
|
+
y_id_range = (fields.y_id.min(), np.inf) if y_id_range is None else y_id_range
|
|
764
|
+
z_id_range = (fields.z_id.min(), np.inf) if z_id_range is None else z_id_range
|
|
765
|
+
fields = fields.query(
|
|
766
|
+
f"x_id >= {x_id_range[0]} and x_id <= {x_id_range[1]} and y_id >= {y_id_range[0]} and y_id <= {y_id_range[1]} and z_id >= {z_id_range[0]} and z_id <= {z_id_range[1]}"
|
|
767
|
+
)
|
|
768
|
+
dimensions = [
|
|
769
|
+
fields.x_id.max() - fields.x_id.min() + 1,
|
|
770
|
+
fields.y_id.max() - fields.y_id.min() + 1,
|
|
771
|
+
fields.z_id.max() - fields.z_id.min() + 1,
|
|
772
|
+
]
|
|
773
|
+
origin = np.array([fields.x.min(), fields.y.min(), fields.z.min()])
|
|
774
|
+
new_material = Material(
|
|
775
|
+
dimensions=dimensions,
|
|
776
|
+
origin=origin,
|
|
777
|
+
spacing=self.spacing,
|
|
778
|
+
fields=fields.drop(columns=["x_id", "y_id", "z_id"]).reset_index(drop=True),
|
|
779
|
+
)
|
|
780
|
+
for k, v in self._regional_fields.items():
|
|
781
|
+
new_material = new_material.create_regional_fields(k, v)
|
|
782
|
+
return new_material
|
|
783
|
+
|
|
784
|
+
def chop_by_point_count(self, x=None, y=None, z=None):
|
|
785
|
+
"""
|
|
786
|
+
Create a subset by removing points from domain boundaries.
|
|
787
|
+
|
|
788
|
+
Parameters
|
|
789
|
+
----------
|
|
790
|
+
x, y, z : tuple, optional
|
|
791
|
+
Number of points to remove (min_side, max_side) in each direction.
|
|
792
|
+
|
|
793
|
+
Returns
|
|
794
|
+
-------
|
|
795
|
+
Material
|
|
796
|
+
New material with boundary points removed.
|
|
797
|
+
"""
|
|
798
|
+
|
|
799
|
+
# Filter the fields in prescribed coordinate ranges
|
|
800
|
+
fields = deepcopy(self.fields)
|
|
801
|
+
|
|
802
|
+
# Set the number of points to chop to zero if arguments are None
|
|
803
|
+
x = (0, 0) if x is None else x
|
|
804
|
+
y = (0, 0) if y is None else y
|
|
805
|
+
z = (0, 0) if z is None else z
|
|
806
|
+
chopped_range = {
|
|
807
|
+
"x_id": (fields.x_id.min() + x[0], fields.x_id.max() - x[1]),
|
|
808
|
+
"y_id": (fields.y_id.min() + y[0], fields.y_id.max() - y[1]),
|
|
809
|
+
"z_id": (fields.z_id.min() + z[0], fields.z_id.max() - z[1]),
|
|
810
|
+
}
|
|
811
|
+
mask = np.array(
|
|
812
|
+
[True] * self.num_points
|
|
813
|
+
) # repeated to handle the no argument case
|
|
814
|
+
for label, chopped_range in chopped_range.items():
|
|
815
|
+
if chopped_range is not None:
|
|
816
|
+
mask = (
|
|
817
|
+
mask
|
|
818
|
+
& (fields[label] >= chopped_range[0])
|
|
819
|
+
& (fields[label] <= chopped_range[1])
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
fields = fields.loc[mask].reset_index(drop=True)
|
|
823
|
+
|
|
824
|
+
# Generate a new fieldless submaterial
|
|
825
|
+
material = Material(
|
|
826
|
+
dimensions=list(
|
|
827
|
+
fields[["x_id", "y_id", "z_id"]].max()
|
|
828
|
+
- fields[["x_id", "y_id", "z_id"]].min()
|
|
829
|
+
+ 1
|
|
830
|
+
),
|
|
831
|
+
origin=list(fields[["x", "y", "z"]].min()),
|
|
832
|
+
spacing=self.spacing,
|
|
833
|
+
fields=fields.drop(columns=["x", "y", "z", "x_id", "y_id", "z_id"]),
|
|
834
|
+
)
|
|
835
|
+
return material
|
|
836
|
+
|
|
837
|
+
def export_to_vtk(self, output="fields.vtk", labels=None):
|
|
838
|
+
"""
|
|
839
|
+
Export material data to VTK format file.
|
|
840
|
+
|
|
841
|
+
Parameters
|
|
842
|
+
----------
|
|
843
|
+
output : str, default "fields.vtk"
|
|
844
|
+
Output file path.
|
|
845
|
+
labels : list, optional
|
|
846
|
+
Specific field labels to export. If None, exports all fields.
|
|
847
|
+
"""
|
|
848
|
+
if labels is None:
|
|
849
|
+
fields = self.get_fields().sort_values(by=["z", "y", "x"])
|
|
850
|
+
else:
|
|
851
|
+
fields = self.get_fields()[labels].sort_values(by=["z", "y", "x"])
|
|
852
|
+
|
|
853
|
+
output_text = (
|
|
854
|
+
"# vtk DataFile Version 2.0\n"
|
|
855
|
+
+ "Material Export\n"
|
|
856
|
+
+ "ASCII\n"
|
|
857
|
+
+ "DATASET STRUCTURED_POINTS\n"
|
|
858
|
+
+ f"DIMENSIONS {self.dimensions[0] + 1} {self.dimensions[1] + 1} {self.dimensions[2] + 1}\n"
|
|
859
|
+
+ "ASPECT_RATIO 1 1 1\n"
|
|
860
|
+
+ "ORIGIN 0 0 0\n"
|
|
861
|
+
+ f"CELL_DATA {len(fields)}\n"
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
for label in fields.columns:
|
|
865
|
+
output_text += (
|
|
866
|
+
f"SCALARS {label} float\n"
|
|
867
|
+
+ "LOOKUP_TABLE default\n"
|
|
868
|
+
+ "\n".join(fields[label].astype(str))
|
|
869
|
+
+ "\n"
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
with open(output, "w") as output_file:
|
|
873
|
+
output_file.write(output_text)
|
|
874
|
+
|
|
875
|
+
def export_to_evpfft(
|
|
876
|
+
self,
|
|
877
|
+
euler_angles_labels=["euler_angles_1", "euler_angles_2", "euler_angles_3"],
|
|
878
|
+
feature_label="feature",
|
|
879
|
+
phase_label="phase",
|
|
880
|
+
output="fields.txt",
|
|
881
|
+
euler_angles_to_degrees=False,
|
|
882
|
+
):
|
|
883
|
+
"""
|
|
884
|
+
Export material data for EVP-FFT crystal plasticity solver.
|
|
885
|
+
|
|
886
|
+
Parameters
|
|
887
|
+
----------
|
|
888
|
+
euler_angles_labels : list, default ["euler_angles_1", "euler_angles_2", "euler_angles_3"]
|
|
889
|
+
Names of fields containing Bunge Euler angles.
|
|
890
|
+
feature_label : str, default "feature"
|
|
891
|
+
Name of field containing feature/grain IDs.
|
|
892
|
+
phase_label : str, default "phase"
|
|
893
|
+
Name of field containing phase IDs.
|
|
894
|
+
output : str, default "fields.txt"
|
|
895
|
+
Output file path.
|
|
896
|
+
euler_angles_to_degrees : bool, default False
|
|
897
|
+
Whether to convert Euler angles from radians to degrees.
|
|
898
|
+
"""
|
|
899
|
+
valid_dimensions = np.array(
|
|
900
|
+
[power_of_two_below(dim) for dim in self.dimensions]
|
|
901
|
+
)
|
|
902
|
+
fields = self.get_fields()
|
|
903
|
+
|
|
904
|
+
if not np.array_equal(valid_dimensions, self.dimensions):
|
|
905
|
+
# Going to force a crop out of points above powers of two dimensions in a hidden way for now
|
|
906
|
+
fields = self._get_cropped_fields(fields, points_above=valid_dimensions - 1)
|
|
907
|
+
|
|
908
|
+
unit_factor = 180.0 / np.pi if euler_angles_to_degrees else 1.0
|
|
909
|
+
|
|
910
|
+
export_labels = [
|
|
911
|
+
euler_angles_labels[0],
|
|
912
|
+
euler_angles_labels[1],
|
|
913
|
+
euler_angles_labels[2],
|
|
914
|
+
"x_id",
|
|
915
|
+
"y_id",
|
|
916
|
+
"z_id",
|
|
917
|
+
feature_label,
|
|
918
|
+
phase_label,
|
|
919
|
+
]
|
|
920
|
+
fields.assign(
|
|
921
|
+
euler_angles_1=fields.euler_angles_1 * unit_factor,
|
|
922
|
+
euler_angles_2=fields.euler_angles_2 * unit_factor,
|
|
923
|
+
euler_angles_3=fields.euler_angles_3 * unit_factor,
|
|
924
|
+
x_id=fields.x_id + 1,
|
|
925
|
+
y_id=fields.y_id + 1,
|
|
926
|
+
z_id=fields.z_id + 1,
|
|
927
|
+
).sort_values(by=["z", "y", "x"]).to_csv(
|
|
928
|
+
output, header=False, sep=" ", index=False, columns=export_labels
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
class Feature:
|
|
933
|
+
"""
|
|
934
|
+
Abstract base class for geometric features used to define regions in materials.
|
|
935
|
+
|
|
936
|
+
Features are used with Material.insert_feature() to assign properties to
|
|
937
|
+
specific geometric regions.
|
|
938
|
+
"""
|
|
939
|
+
|
|
940
|
+
@abstractmethod
|
|
941
|
+
def check_inside(self):
|
|
942
|
+
"""Check if points are inside the feature geometry."""
|
|
943
|
+
raise NotImplementedError
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
class Sphere(Feature):
|
|
947
|
+
"""
|
|
948
|
+
Spherical feature for defining spherical regions.
|
|
949
|
+
|
|
950
|
+
Parameters
|
|
951
|
+
----------
|
|
952
|
+
radius : float
|
|
953
|
+
Radius of the sphere.
|
|
954
|
+
centroid : array-like
|
|
955
|
+
Center coordinates [x, y, z] of the sphere.
|
|
956
|
+
"""
|
|
957
|
+
|
|
958
|
+
def __init__(self, radius, centroid):
|
|
959
|
+
self.radius = radius
|
|
960
|
+
self.centroid = centroid
|
|
961
|
+
|
|
962
|
+
def check_inside(self, x, y, z):
|
|
963
|
+
"""
|
|
964
|
+
Check if points are inside the sphere.
|
|
965
|
+
|
|
966
|
+
Parameters
|
|
967
|
+
----------
|
|
968
|
+
x, y, z : array-like
|
|
969
|
+
Point coordinates to check.
|
|
970
|
+
|
|
971
|
+
Returns
|
|
972
|
+
-------
|
|
973
|
+
array of bool
|
|
974
|
+
True for points inside the sphere.
|
|
975
|
+
"""
|
|
976
|
+
c = self.centroid
|
|
977
|
+
return (x - c[0]) ** 2 + (y - c[1]) ** 2 + (z - c[2]) ** 2 <= self.radius**2
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
class Superellipsoid(Feature):
|
|
981
|
+
"""
|
|
982
|
+
Superellipsoid feature for defining various rounded shapes.
|
|
983
|
+
|
|
984
|
+
Parameters
|
|
985
|
+
----------
|
|
986
|
+
major_radius : float
|
|
987
|
+
Semi-axis length in the major direction.
|
|
988
|
+
intermediate_radius : float
|
|
989
|
+
Semi-axis length in the intermediate direction.
|
|
990
|
+
minor_radius : float
|
|
991
|
+
Semi-axis length in the minor direction.
|
|
992
|
+
shape_exponent : float
|
|
993
|
+
Exponent controlling shape (2.0 = ellipsoid, >2 = box-like, <2 = diamond-like).
|
|
994
|
+
centroid : array-like
|
|
995
|
+
Center coordinates [x, y, z] of the superellipsoid.
|
|
996
|
+
"""
|
|
997
|
+
|
|
998
|
+
def __init__(
|
|
999
|
+
self, major_radius, intermediate_radius, minor_radius, shape_exponent, centroid
|
|
1000
|
+
):
|
|
1001
|
+
self.major_radius = major_radius
|
|
1002
|
+
self.intermediate_radius = intermediate_radius
|
|
1003
|
+
self.minor_radius = minor_radius
|
|
1004
|
+
self.shape_exponent = shape_exponent
|
|
1005
|
+
self.centroid = centroid
|
|
1006
|
+
|
|
1007
|
+
def check_inside(self, x, y, z):
|
|
1008
|
+
"""
|
|
1009
|
+
Check if points are inside the superellipsoid.
|
|
1010
|
+
|
|
1011
|
+
Parameters
|
|
1012
|
+
----------
|
|
1013
|
+
x, y, z : array-like
|
|
1014
|
+
Point coordinates to check.
|
|
1015
|
+
|
|
1016
|
+
Returns
|
|
1017
|
+
-------
|
|
1018
|
+
array of bool
|
|
1019
|
+
True for points inside the superellipsoid.
|
|
1020
|
+
"""
|
|
1021
|
+
a, b, c, n, o = (
|
|
1022
|
+
self.major_radius,
|
|
1023
|
+
self.intermediate_radius,
|
|
1024
|
+
self.minor_radius,
|
|
1025
|
+
self.shape_exponent,
|
|
1026
|
+
self.centroid,
|
|
1027
|
+
)
|
|
1028
|
+
return (
|
|
1029
|
+
abs((x - o[0]) / a) ** n
|
|
1030
|
+
+ abs((y - o[1]) / b) ** n
|
|
1031
|
+
+ abs((z - o[2]) / c) ** n
|
|
1032
|
+
- 1
|
|
1033
|
+
<= 0
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
class Box(Feature):
|
|
1038
|
+
"""
|
|
1039
|
+
Rectangular box feature for defining box-shaped regions.
|
|
1040
|
+
|
|
1041
|
+
Parameters
|
|
1042
|
+
----------
|
|
1043
|
+
min_corner : array-like, default [-inf, -inf, -inf]
|
|
1044
|
+
Minimum corner coordinates [x, y, z] of the box.
|
|
1045
|
+
max_corner : array-like, default [inf, inf, inf]
|
|
1046
|
+
Maximum corner coordinates [x, y, z] of the box.
|
|
1047
|
+
"""
|
|
1048
|
+
|
|
1049
|
+
def __init__(
|
|
1050
|
+
self,
|
|
1051
|
+
min_corner=np.array([-np.inf, -np.inf, -np.inf]),
|
|
1052
|
+
max_corner=np.array([np.inf, np.inf, np.inf]),
|
|
1053
|
+
):
|
|
1054
|
+
self.min_corner = np.array(
|
|
1055
|
+
[
|
|
1056
|
+
coordinate if coordinate is not None else -np.inf
|
|
1057
|
+
for coordinate in min_corner
|
|
1058
|
+
]
|
|
1059
|
+
)
|
|
1060
|
+
self.max_corner = np.array(
|
|
1061
|
+
[
|
|
1062
|
+
coordinate if coordinate is not None else np.inf
|
|
1063
|
+
for coordinate in max_corner
|
|
1064
|
+
]
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
def check_inside(self, x, y, z):
|
|
1068
|
+
"""
|
|
1069
|
+
Check if points are inside the box.
|
|
1070
|
+
|
|
1071
|
+
Parameters
|
|
1072
|
+
----------
|
|
1073
|
+
x, y, z : array-like
|
|
1074
|
+
Point coordinates to check.
|
|
1075
|
+
|
|
1076
|
+
Returns
|
|
1077
|
+
-------
|
|
1078
|
+
array of bool
|
|
1079
|
+
True for points inside the box.
|
|
1080
|
+
"""
|
|
1081
|
+
min_x, min_y, min_z = self.min_corner
|
|
1082
|
+
max_x, max_y, max_z = self.max_corner
|
|
1083
|
+
return np.logical_and.reduce(
|
|
1084
|
+
[min_x <= x, x <= max_x, min_y <= y, y <= max_y, min_z <= z, z <= max_z]
|
|
1085
|
+
)
|