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