11import os
22import copy
3+ import numbers
34import multiprocessing as mp
45from functools import partial
56from typing import Optional , Union , Sequence
1718from . import Table
1819from . import Colormap
1920from ._typehints import FloatSequence , IntSequence , NumpyRngSeed
20- try :
21- import numba as nb # type: ignore[import-not-found]
22- except ImportError :
23- nb = False
2421
2522
26- def numba_njit_wrapper (** kwargs ):
27- return (lambda function : nb .njit (function ) if nb else function )
23+ class IcDict (dict ):
24+ """Dict that validates and broadcasts initial conditions on assignment."""
25+
26+ def __init__ (self , cells : tuple [int , int , int ]):
27+ """
28+ New initial condition dictionary.
29+
30+ Parameters
31+ ----------
32+ cells : tuple of int, len (3)
33+ Cell counts along x,y,z direction.
34+ """
35+ super ().__init__ ()
36+ self .cells = cells
37+
38+ def __setitem__ (self , k : str , v : Union [int , float , np .ndarray ]):
39+ """
40+ Set a single initial condition key with validation and broadcasting.
41+
42+ Parameters
43+ ----------
44+ k : str
45+ Name of the initial condition field.
46+ v : int, float, or np.ndarray
47+ Value to assign to the field. Allowed types and shapes:
48+ - Scalars (`int` or `float`)
49+ → broadcast to `cells`.
50+ - Small arrays (`np.ndarray`) of shape (), (1,), or (3,)
51+ → broadcast to `cells + value.shape`.
52+ - Full-field arrays (`np.ndarray`) with leading dimensions matching `cells` and trailing shape (), (1,), or (3,)
53+ → copied as-is.
54+
55+ Raises
56+ ------
57+ ValueError
58+ If `v` has an invalid shape that does not match the allowed rules.
59+
60+ Notes
61+ -----
62+ Broadcasting ensures that all initial condition fields conform to the
63+ grid defined by `cells`.
64+ This method is called both for full-property assignment (via the parent setter)
65+ and per-key assignment (`GeomGrid.initial_conditions[key] = value`), so all
66+ validation and broadcasting logic is centralized here.
67+ """
68+ if not ( isinstance (v , numbers .Real )
69+ or (isinstance (v , np .ndarray ) and v .shape in [(), (1 ,), (3 ,)])
70+ or (isinstance (v , np .ndarray ) and len (v .shape ) >= 3 and
71+ v .shape [:3 ] == self .cells and v .shape [3 :] in [(), (1 ,), (3 ,)])
72+ ):
73+ raise ValueError (f'initial condition "{ k } " must be [a field of] scalars or three-dimensional vectors' )
74+
75+ super ().__setitem__ (k ,
76+ v if isinstance (v , np .ndarray ) and len (v .shape ) >= 3 and v .shape [:3 ] == self .cells else
77+ np .broadcast_to (v , self .cells + v .shape ) if isinstance (v , np .ndarray ) else
78+ np .broadcast_to (v , self .cells )
79+ )
80+
2881
2982class GeomGrid :
3083 """
@@ -56,12 +109,15 @@ def __init__(self,
56109 Coordinates of grid origin in meter. Defaults to [0.0,0.0,0.0].
57110 initial_conditions : dictionary, optional
58111 Initial condition label and field values at each grid point.
112+ If leading shape deviates from material shape,
113+ the (constant) value is broadcast across the grid.
59114 comments : (sequence of) str, optional
60115 Additional, human-readable information, e.g. history of operations.
61116 """
62117 self .material = material
63- self .size = size # type: ignore[assignment]
64- self .origin = origin # type: ignore[assignment]
118+ self .size = size
119+ self .origin = origin
120+ self ._ic = IcDict (tuple (self .cells ))
65121 self .initial_conditions = {} if initial_conditions is None else initial_conditions
66122 self .comments = [] if comments is None else \
67123 [comments ] if isinstance (comments ,str ) else \
@@ -82,7 +138,8 @@ def __repr__(self) -> str:
82138 f'origin: { util .srepr (self .origin ," " )} m' ,
83139 f'# materials: { mat_N } ' + ('' if mat_min == 0 and mat_max == mat_N - 1 else
84140 f' (min: { mat_min } , max: { mat_max } )' )
85- ]+ (['initial_conditions:' ]+ [f' - { f } ' for f in self .initial_conditions ] if self .initial_conditions else []))
141+ ]+ (['initial_conditions:' ]+ [f' - { f } ' + (f' { data .shape [3 :]} ' if len (data .shape )> 3 else '' )
142+ for f ,data in self .initial_conditions .items ()] if self .initial_conditions else []))
86143
87144
88145 def __copy__ (self ) -> 'GeomGrid' :
@@ -113,12 +170,40 @@ def __eq__(self,
113170 equal : bool
114171 Whether both arguments are equal.
115172 """
173+ if not isinstance (other , GeomGrid ):
174+ return NotImplemented
175+ return self .match (other ) and bool ( other .initial_conditions .keys () == self .initial_conditions .keys ()
176+ and all (np .allclose (other .initial_conditions [k ], self .initial_conditions [k ])
177+ for k in self .initial_conditions )
178+ )
179+
180+
181+ def match (self ,
182+ other : object ) -> bool :
183+ """
184+ Test geometric equality of other, i.e., ignoring initial conditions.
185+
186+ Parameters
187+ ----------
188+ other : damask.GeomGrid
189+ GeomGrid to compare self against.
190+
191+ Returns
192+ -------
193+ match : bool
194+ Whether both arguments are geometrically equal.
195+
196+ Notes
197+ -----
198+ This comparison does not consider initial conditions.
199+ """
116200 if not isinstance (other , GeomGrid ):
117201 return NotImplemented
118202 return bool ( np .allclose (other .size ,self .size )
119203 and np .allclose (other .origin ,self .origin )
120204 and np .all (other .cells == self .cells )
121- and np .all (other .material == self .material ))
205+ and np .all (other .material == self .material )
206+ )
122207
123208
124209 @property
@@ -149,7 +234,7 @@ def size(self) -> np.ndarray:
149234 @size .setter
150235 def size (self ,
151236 size : FloatSequence ):
152- if len (size ) != 3 or any (np .array (size ) < 0 ):
237+ if len (size ) != 3 or any (np .asarray (size ) < 0 ):
153238 raise ValueError (f'invalid size { size } ' )
154239
155240 self ._size = np .array (size ,np .float64 )
@@ -167,21 +252,22 @@ def origin(self,
167252
168253 self ._origin = np .array (origin ,np .float64 )
169254
255+
170256 @property
171- def initial_conditions (self ) -> dict [str ,np .ndarray ]:
257+ def initial_conditions (self ) -> dict [str , np .ndarray ]:
172258 """Fields of initial conditions."""
173- self ._ic = dict (zip (self ._ic .keys (), # type: ignore[has-type]
174- [v if isinstance (v ,np .ndarray ) else
175- np .broadcast_to (v ,self .cells ) for v in self ._ic .values ()])) # type: ignore[has-type]
176259 return self ._ic
177260
178261 @initial_conditions .setter
179- def initial_conditions (self ,
180- ic : dict [str ,np .ndarray ]):
181- if not isinstance (ic ,dict ):
182- raise TypeError ('initial conditions is not a dictionary' )
262+ def initial_conditions (self , ic : dict [str , np .ndarray ]):
263+ """Set initial conditions, broadcasting to the cell grid shape if necessary."""
264+ if not isinstance (ic , dict ):
265+ raise TypeError ('initial conditions must be a dictionary' )
266+
267+ self ._ic .clear ()
268+ for k , v in ic .items ():
269+ self ._ic [k ] = v
183270
184- self ._ic = ic
185271
186272 @property
187273 def cells (self ) -> np .ndarray :
@@ -214,9 +300,9 @@ def _load(fname: Union[str, Path], label: str) -> 'GeomGrid':
214300 Grid-based geometry from file.
215301 """
216302 v = VTK .load (fname if str (fname ).endswith ('.vti' ) else str (fname )+ '.vti' )
217- cells = np .array (v .vtk_data .GetDimensions ())- 1 # type: ignore[attr-defined]
303+ cells = tuple ( np .array (v .vtk_data .GetDimensions ())- 1 ) # type: ignore[attr-defined]
218304 bbox = np .array (v .vtk_data .GetBounds ()).reshape (3 ,2 ).T
219- ic = {l :v .get (l ).reshape (cells ,order = 'F' ) for l in set (v .labels ['Cell Data' ]) - {label }}
305+ ic = {l :v .get (l ).reshape (cells + v . get ( l ). shape [ 1 :] ,order = 'F' ) for l in set (v .labels ['Cell Data' ]) - {label }}
220306
221307 return GeomGrid (material = v .get (label ).reshape (cells ,order = 'F' ),
222308 size = bbox [1 ] - bbox [0 ],
@@ -714,7 +800,7 @@ def save(self,
714800 v = VTK .from_image_data (self .cells ,self .size ,self .origin )\
715801 .set ('material' ,self .material .flatten (order = 'F' ))
716802 for label ,data in self .initial_conditions .items ():
717- v = v .set (label ,data .flatten ( order = 'F' ))
803+ v = v .set (label ,data .reshape (( - 1 ,) + data . shape [ 3 :], order = 'F' ))
718804 v .comments = self .comments
719805
720806 v .save (fname ,parallel = False ,compress = compress )
@@ -759,6 +845,10 @@ def canvas(self,
759845 updated : damask.GeomGrid
760846 Updated grid-based geometry.
761847
848+ Notes
849+ -----
850+ Existing initial condition fields will be removed.
851+
762852 Examples
763853 --------
764854 Remove lower 1/2 of the microstructure in z-direction.
@@ -839,22 +929,29 @@ def mirror(self,
839929 >>> g.mirror('xy') == g.mirror(['y','x'])
840930 True
841931 """
842- if not set (directions ).issubset (valid := ['x' , 'y' , 'z' ]):
932+ valid = 'xyz'
933+ if not set (directions ).issubset (valid ):
843934 raise ValueError (f'invalid direction "{ set (directions ).difference (valid )} " specified' )
844935
845936 limits : Sequence [Optional [int ]] = [None ,None ] if reflect else [- 2 ,0 ]
937+ selection = (slice (limits [0 ],limits [1 ],- 1 ),slice (None ),slice (None ))
846938 mat = self .material .copy ()
847-
848- if 'x' in directions :
849- mat = np .concatenate ([mat ,mat [limits [0 ]:limits [1 ]:- 1 ,:,:]],0 )
850- if 'y' in directions :
851- mat = np .concatenate ([mat ,mat [:,limits [0 ]:limits [1 ]:- 1 ,:]],1 )
852- if 'z' in directions :
853- mat = np .concatenate ([mat ,mat [:,:,limits [0 ]:limits [1 ]:- 1 ]],2 )
939+ ic = self .initial_conditions .copy ()
940+
941+ for i ,d in enumerate (valid ):
942+ if d in directions :
943+ mat = np .concatenate ([mat ,
944+ mat [selection [0 - i ],selection [1 - i ],selection [2 - i ]]],
945+ axis = i )
946+ for label in ic :
947+ ic [label ] = np .concatenate ([ic [label ],
948+ ic [label ][selection [0 - i ],selection [1 - i ],selection [2 - i ]]],
949+ axis = i )
854950
855951 return GeomGrid (material = mat ,
856952 size = self .size / self .cells * np .asarray (mat .shape ),
857953 origin = self .origin ,
954+ initial_conditions = ic ,
858955 comments = self .comments + [util .execution_stamp ('GeomGrid' ,'mirror' )],
859956 )
860957
@@ -893,14 +990,18 @@ def flip(self,
893990 >>> g.mirror('x',reflect=True) == g.mirror('x',reflect=True).flip('x')
894991 True
895992 """
896- if not set (directions ).issubset (valid := ['x' , 'y' , 'z' ]):
993+ valid = 'xyz'
994+ if not set (directions ).issubset (valid ):
897995 raise ValueError (f'invalid direction "{ set (directions ).difference (valid )} " specified' )
898996
899997 mat = np .flip (self .material , [valid .index (d ) for d in directions if d in valid ])
900-
998+ ic = {}
999+ for label in self .initial_conditions :
1000+ ic [label ] = np .flip (self .initial_conditions [label ],[valid .index (d ) for d in directions if d in valid ])
9011001 return GeomGrid (material = mat ,
9021002 size = self .size ,
9031003 origin = self .origin ,
1004+ initial_conditions = ic ,
9041005 comments = self .comments + [util .execution_stamp ('GeomGrid' ,'flip' )],
9051006 )
9061007
@@ -924,6 +1025,10 @@ def rotate(self,
9241025 updated : damask.GeomGrid
9251026 Updated grid-based geometry.
9261027
1028+ Notes
1029+ -----
1030+ Existing initial condition fields will be removed.
1031+
9271032 Examples
9281033 --------
9291034 Rotation by 180° (π) is equivalent to twice flipping.
@@ -1023,7 +1128,8 @@ def assemble(self,
10231128 """
10241129 cells = idx .shape [:3 ]
10251130 flat = (idx if len (idx .shape )== 3 else grid_filters .ravel_index (idx )).flatten (order = 'F' )
1026- ic = {k : v .flatten (order = 'F' )[flat ].reshape (cells ,order = 'F' ) for k ,v in self .initial_conditions .items ()}
1131+ ic = {k : v .reshape ((- 1 ,)+ v .shape [3 :],order = 'F' )[flat ]
1132+ .reshape (cells + v .shape [3 :],order = 'F' ) for k ,v in self .initial_conditions .items ()}
10271133
10281134 return GeomGrid (material = self .material .flatten (order = 'F' )[flat ].reshape (cells ,order = 'F' ),
10291135 size = self .size ,
@@ -1306,7 +1412,7 @@ def vicinity_offset(self,
13061412 updated : damask.GeomGrid
13071413 Updated grid-based geometry.
13081414 """
1309- @numba_njit_wrapper ()
1415+ @util . numba_njit_wrapper ()
13101416 def tainted_neighborhood (stencil : np .ndarray ,
13111417 selection : Optional [np .ndarray ] = None ):
13121418 me = stencil [stencil .size // 2 ]
@@ -1361,15 +1467,16 @@ def get_grain_boundaries(self,
13611467 grain_boundaries : damask.VTK
13621468 VTK-based geometry of grain boundary network.
13631469 """
1364- if not set (directions ).issubset (valid := ['x' , 'y' , 'z' ]):
1470+ valid = 'xyz'
1471+ if not set (directions ).issubset (valid ):
13651472 raise ValueError (f'invalid direction "{ set (directions ).difference (valid )} " specified' )
13661473
13671474 o = [[0 , self .cells [0 ]+ 1 , np .prod (self .cells [:2 ]+ 1 )+ self .cells [0 ]+ 1 , np .prod (self .cells [:2 ]+ 1 )],
13681475 [0 , np .prod (self .cells [:2 ]+ 1 ), np .prod (self .cells [:2 ]+ 1 )+ 1 , 1 ],
13691476 [0 , 1 , self .cells [0 ]+ 1 + 1 , self .cells [0 ]+ 1 ]] # offset for connectivity
13701477
13711478 connectivity = []
1372- for i ,d in enumerate ([ 'x' , 'y' , 'z' ] ):
1479+ for i ,d in enumerate (valid ):
13731480 if d not in directions : continue
13741481 mask = self .material != np .roll (self .material ,1 ,i )
13751482 for j in [0 ,1 ,2 ]:
@@ -1378,8 +1485,8 @@ def get_grain_boundaries(self,
13781485 if i == 1 and not periodic : mask [:,0 ,:] = mask [:,- 1 ,:] = False
13791486 if i == 2 and not periodic : mask [:,:,0 ] = mask [:,:,- 1 ] = False
13801487
1381- base_nodes = np .argwhere (mask .flatten (order = 'F' )).reshape (- 1 ,1 )
1488+ base_nodes = np .argwhere (mask .flatten (order = 'F' )).reshape (( - 1 ,1 ) )
13821489 connectivity .append (np .block ([base_nodes + o [i ][k ] for k in range (4 )]))
13831490
1384- coords = grid_filters .coordinates0_node (self .cells ,self .size ,self .origin ).reshape (- 1 ,3 ,order = 'F' )
1491+ coords = grid_filters .coordinates0_node (self .cells ,self .size ,self .origin ).reshape (( - 1 ,3 ) ,order = 'F' )
13851492 return VTK .from_unstructured_grid (coords ,np .vstack (connectivity ),'QUADRILATERAL' )
0 commit comments