diff --git a/python/damask/_config.py b/python/damask/_config.py index db215b097..25fcbfa75 100644 --- a/python/damask/_config.py +++ b/python/damask/_config.py @@ -2,6 +2,7 @@ import copy from io import StringIO from collections.abc import Iterable import abc +import platform from typing import Optional, Union, Dict, Any, Type, TypeVar import numpy as np @@ -69,13 +70,30 @@ class Config(dict): **kwargs: arbitray keyword-value pairs, optional Top level entries of the configuration. - """ - if isinstance(config,str): - kwargs.update(yaml.load(config, Loader=SafeLoader)) - elif isinstance(config,dict): - kwargs.update(config) + Notes + ----- + Values given as keyword-value pairs take precedence + over entries with the same keyword in 'config'. + + """ + if int(platform.python_version_tuple()[1]) >= 9: + if isinstance(config,str): + kwargs = yaml.load(config, Loader=SafeLoader) | kwargs + elif isinstance(config,dict): + kwargs = config | kwargs # type: ignore + + super().__init__(**kwargs) + else: + if isinstance(config,str): + c = yaml.load(config, Loader=SafeLoader) + elif isinstance(config,dict): + c = config.copy() + else: + c = {} + c.update(kwargs) + + super().__init__(**c) - super().__init__(**kwargs) def __repr__(self) -> str: """ diff --git a/python/damask/_configmaterial.py b/python/damask/_configmaterial.py index a6c6f3bc8..2eba905a6 100644 --- a/python/damask/_configmaterial.py +++ b/python/damask/_configmaterial.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, Sequence, Dict, Any, Collection +from typing import Optional, Union, Sequence, Dict, Any, List import numpy as np import h5py @@ -8,6 +8,7 @@ from . import Config from . import Rotation from . import Orientation from . import util +from . import tensor from . import Table @@ -23,8 +24,10 @@ class ConfigMaterial(Config): """ def __init__(self, - config: Optional[Dict[str, Any]] = None, - **kwargs): + config: Optional[Union[str,Dict[str,Any]]] = None,*, + homogenization: Optional[Dict[str,Dict]] = None, + phase: Optional[Dict[str,Dict]] = None, + material: Optional[List[Dict[str,Any]]] = None): """ New material configuration. @@ -32,16 +35,23 @@ class ConfigMaterial(Config): ---------- config : dict or str, optional Material configuration. String needs to be valid YAML. - Defaults to None, in which case empty entries for - any missing material, homogenization, and phase entry are created. - kwargs : key=value pairs, optional - Initial content specified as pairs of key=value. + homogenization : dict, optional + Homogenization configuration. + Defaults to an empty dict if 'config' is not given. + phase : dict, optional + Phase configuration. + Defaults to an empty dict if 'config' is not given. + material : dict, optional + Materialpoint configuration. + Defaults to an empty list if 'config' is not given. """ - default: Collection - if config is None: - for section, default in {'material':[],'homogenization':{},'phase':{}}.items(): - if section not in kwargs: kwargs.update({section:default}) + kwargs: Dict[str,Union[Dict[str,Dict],List[Dict[str,Any]]]] = {} + for arg,value in zip(['homogenization','phase','material'],[homogenization,phase,material]): + if value is None and config is None: + kwargs[arg] = [] if arg == 'material' else {} + elif value is not None: + kwargs[arg] = value super().__init__(config,**kwargs) @@ -170,8 +180,12 @@ class ConfigMaterial(Config): @staticmethod - def from_table(table: Table, - **kwargs) -> 'ConfigMaterial': + def from_table(table: Table,*, + homogenization: Optional[Union[str,StrSequence]] = None, + phase: Optional[Union[str,StrSequence]] = None, + v: Optional[Union[str,FloatSequence]] = None, + O: Optional[Union[str,FloatSequence]] = None, + V_e: Optional[Union[str,FloatSequence]] = None) -> 'ConfigMaterial': """ Generate from an ASCII table. @@ -179,16 +193,33 @@ class ConfigMaterial(Config): ---------- table : damask.Table Table that contains material information. - **kwargs - Keyword arguments where the key is the property name and - the value specifies either the label of the data column in the table - or a constant value. + homogenization: (array-like) of str, optional + Homogenization label. + phase: (array-like) of str, optional + Phase label (per constituent). + v: (array-like) of float or str, optional + Constituent volume fraction (per constituent). + Defaults to 1/N_constituent. + O: (array-like) of damask.Rotation or np.array/list of shape(4) or str, optional + Orientation as unit quaternion (per constituent). + V_e: (array-like) of np.array/list of shape(3,3) or str, optional + Left elastic stretch (per constituent). + Returns ------- new : damask.ConfigMaterial Material configuration from values in table. + Notes + ----- + If the value of an argument is a string that is a column label, + data from the table is used to fill the corresponding entry in + the material configuration. Otherwise, the value is used directly. + + First index of array-like values that are defined per constituent + runs over materials, whereas second index runs over constituents. + Examples -------- >>> import damask @@ -230,15 +261,16 @@ class ConfigMaterial(Config): phase: {Aluminum: null, Steel: null} """ - kwargs_ = {k:table.get(v) if v in table.labels else np.atleast_2d([v]*len(table)).T for k,v in kwargs.items()} + kwargs = {} + for arg,val in zip(['homogenization','phase','v','O','V_e'],[homogenization,phase,v,O,V_e]): + if val is not None: + kwargs[arg] = table.get(val) if val in table.labels else np.atleast_2d([val]*len(table)).T # type: ignore - _,idx = np.unique(np.hstack(list(kwargs_.values())),return_index=True,axis=0) + _,idx = np.unique(np.hstack(list(kwargs.values())),return_index=True,axis=0) idx = np.sort(idx) - kwargs_ = {k:np.atleast_1d(v[idx].squeeze()) for k,v in kwargs_.items()} - for what in ['phase','homogenization']: - if what not in kwargs_: kwargs_[what] = what+'_label' + kwargs = {k:np.atleast_1d(v[idx].squeeze()) for k,v in kwargs.items()} - return ConfigMaterial().material_add(**kwargs_) + return ConfigMaterial().material_add(**kwargs) @property @@ -434,7 +466,7 @@ class ConfigMaterial(Config): Phase label (per constituent). v: (array-like) of float, optional Constituent volume fraction (per constituent). - Defaults to 1/N_constituents + Defaults to 1/N_constituent. O: (array-like) of damask.Rotation or np.array/list of shape(4), optional Orientation as unit quaternion (per constituent). V_e: (array-like) of np.array/list of shape(3,3), optional @@ -527,49 +559,48 @@ class ConfigMaterial(Config): phase: {Austenite: null, Ferrite: null} """ - kwargs = {} - for keyword,value in zip(['homogenization','phase','v','O','V_e'],[homogenization,phase,v,O,V_e]): - if value is not None: kwargs[keyword] = value - - _constituent_properties = ['phase','O','v','V_e'] - _dim = {'O':(4,),'V_e':(3,3,)} - _ex = dict((k, -len(v)) for k, v in _dim.items()) + dim = {'O':(4,),'V_e':(3,3,)} + ex = dict((keyword, -len(val)) for keyword,val in dim.items()) N_materials,N_constituents = 1,1 - shaped : Dict[str, Union[None,np.ndarray]] = \ - {'v': None, - 'phase': None, - 'homogenization': None, - } - - for arg,value in kwargs.items(): - shaped[arg] = np.array(value) - s = shaped[arg].shape[:_ex.get(arg,None)] # type: ignore + shape = {} + for arg,val in zip(['homogenization','phase','v','O','V_e'],[homogenization,phase,v,O,V_e]): + if val is None: continue + shape[arg] = np.array(val) + s = shape[arg].shape[:ex.get(arg,None)] # type: ignore N_materials = max(N_materials,s[0]) if len(s)>0 else N_materials N_constituents = max(N_constituents,s[1]) if len(s)>1 else N_constituents - shaped['v'] = np.array(1./N_constituents) if shaped['v'] is None else shaped['v'] + shape['v'] = np.array(shape.get('v',1./N_constituents),float) mat: Sequence[dict] = [{'constituents':[{} for _ in range(N_constituents)]} for _ in range(N_materials)] - for k,v in shaped.items(): - target = (N_materials,N_constituents) + _dim.get(k,()) - obj = np.broadcast_to(np.array(v).reshape(util.shapeshifter(() if v is None else v.shape, - target, - mode = 'right')), - target) + for k,v in shape.items(): + target = (N_materials,N_constituents) + dim.get(k,()) + broadcasted = np.broadcast_to(np.array(v).reshape(util.shapeshifter(np.array(v).shape,target,'right')),target) + if k == 'v': + if np.min(broadcasted) < 0 or np.max(broadcasted) > 1: + raise ValueError('volume fraction "v" out of range') + if len(np.atleast_1d(broadcasted)) > 1: + total = np.sum(broadcasted,axis=-1) + if np.min(total) < 0 or np.max(total) > 1: + raise ValueError('volume fraction "v" out of range') + if k == 'O' and not np.allclose(1.0,np.linalg.norm(broadcasted,axis=-1)): + raise ValueError('orientation "O" is not a unit quaterion') + elif k == 'V_e' and not np.allclose(broadcasted,tensor.symmetric(broadcasted)): + raise ValueError('elastic stretch "V_e" is not symmetric') for i in range(N_materials): - if k in _constituent_properties: - for j in range(N_constituents): - mat[i]['constituents'][j][k] = obj[i,j].item() if isinstance(obj[i,j],np.generic) else obj[i,j] + if k == 'homogenization': + mat[i][k] = broadcasted[i,0] else: - mat[i][k] = obj[i,0].item() if isinstance(obj[i,0],np.generic) else obj[i,0] + for j in range(N_constituents): + mat[i]['constituents'][j][k] = broadcasted[i,j] dup = self.copy() dup['material'] = dup['material'] + mat if 'material' in dup else mat - for what in [item for item in ['phase','homogenization'] if shaped[item] is not None]: - for k in np.unique(shaped[what]): # type: ignore + for what in [item for item in ['phase','homogenization'] if item in shape]: + for k in np.unique(shape[what]): # type: ignore if k not in dup[what]: dup[what][str(k)] = None return dup diff --git a/python/tests/test_Config.py b/python/tests/test_Config.py index 0fce31106..ebf5ec9a8 100644 --- a/python/tests/test_Config.py +++ b/python/tests/test_Config.py @@ -7,6 +7,17 @@ from damask import Orientation class TestConfig: + def test_init_keyword(self): + assert Config(p=4)['p'] == 4 + + @pytest.mark.parametrize('config',[{'p':1},'{p: 1}']) + def test_init_config(self,config): + assert Config(config)['p'] == 1 + + @pytest.mark.parametrize('config',[{'p':1},'{p: 1}']) + def test_init_both(self,config): + assert Config(config,p=2)['p'] == 2 + @pytest.mark.parametrize('flow_style',[None,True,False]) def test_load_save_str(self,tmp_path,flow_style): config = Config() @@ -36,7 +47,6 @@ class TestConfig: assert (config | Config(dummy)).delete({ 'hello':1,'foo':2 }) == config assert (config | Config(dummy)).delete(Config({'hello':1 })) == config | {'foo':'bar'} - def test_repr(self,tmp_path): config = Config() config['A'] = 1 diff --git a/python/tests/test_ConfigMaterial.py b/python/tests/test_ConfigMaterial.py index a9cf9089d..971a4c5a9 100644 --- a/python/tests/test_ConfigMaterial.py +++ b/python/tests/test_ConfigMaterial.py @@ -16,6 +16,27 @@ def ref_path(ref_path_base): class TestConfigMaterial: + def test_init_empty(self): + c = ConfigMaterial() + assert len(c) == 3 + assert c['homogenization'] == {} + assert c['phase'] == {} + assert c['material'] == [] + + def test_init_d(self): + c = ConfigMaterial(config={'phase':4}) + assert len(c) == 1 + assert c['phase'] == 4 + + @pytest.mark.parametrize('kwargs',[{'homogenization':{'SX':{}}}, + {'phase':{'Aluminum':{}}}, + {'material':[{'A':1},{'B':2}]}]) + def test_init_some(self,kwargs): + c = ConfigMaterial(**kwargs) + assert len(c) == 3 + for k,v in kwargs.items(): + if k in kwargs: assert v == kwargs[k] + @pytest.mark.parametrize('fname',[None,'test.yaml']) def test_load_save(self,ref_path,tmp_path,fname): reference = ConfigMaterial.load(ref_path/'material.yaml') @@ -88,14 +109,14 @@ class TestConfigMaterial: def test_from_table(self): N = np.random.randint(3,10) a = np.vstack((np.hstack((np.arange(N),np.arange(N)[::-1])), - np.ones(N*2),np.zeros(N*2),np.ones(N*2),np.ones(N*2), + np.zeros(N*2),np.ones(N*2),np.zeros(N*2),np.zeros(N*2), np.ones(N*2), )).T t = Table({'varying':1,'constant':4,'ones':1},a) c = ConfigMaterial.from_table(t,**{'phase':'varying','O':'constant','homogenization':'ones'}) assert len(c['material']) == N for i,m in enumerate(c['material']): - assert m['homogenization'] == 1 and (m['constituents'][0]['O'] == [1,0,1,1]).all() + assert m['homogenization'] == 1 and (m['constituents'][0]['O'] == [0,1,0,0]).all() def test_updated_dicts(self,ref_path): m1 = ConfigMaterial().material_add(phase=['Aluminum'],O=[1.0,0.0,0.0,0.0],homogenization='SX') @@ -109,14 +130,14 @@ class TestConfigMaterial: def test_from_table_with_constant(self): N = np.random.randint(3,10) a = np.vstack((np.hstack((np.arange(N),np.arange(N)[::-1])), - np.ones(N*2),np.zeros(N*2),np.ones(N*2),np.ones(N*2), + np.zeros(N*2),np.ones(N*2),np.zeros(N*2),np.zeros(N*2), np.ones(N*2), )).T t = Table({'varying':1,'constant':4,'ones':1},a) c = ConfigMaterial.from_table(t,**{'phase':'varying','O':'constant','homogenization':1}) assert len(c['material']) == N for i,m in enumerate(c['material']): - assert m['homogenization'] == 1 and (m['constituents'][0]['O'] == [1,0,1,1]).all() + assert m['homogenization'] == 1 and (m['constituents'][0]['O'] == [0,1,0,0]).all() @pytest.mark.parametrize('N,n,kw',[ (1,1,{'phase':'Gold', @@ -137,6 +158,19 @@ class TestConfigMaterial: assert len(m['material']) == N assert len(m['material'][0]['constituents']) == n + @pytest.mark.parametrize('shape',[(),(4,),(5,2)]) + @pytest.mark.parametrize('kw',[{'V_e':np.random.rand(3,3)}, + {'O':np.random.rand(4)}, + {'v':np.array(2)}]) + def test_material_add_invalid(self,kw,shape): + kw = {arg:np.broadcast_to(val,shape+val.shape) for arg,val in kw.items()} + with pytest.raises(ValueError): + ConfigMaterial().material_add(**kw) + + @pytest.mark.parametrize('v',[2,np.ones(3)*2,np.ones((2,2))]) + def test_material_add_invalid_v(self,v): + with pytest.raises(ValueError): + ConfigMaterial().material_add(v=v) @pytest.mark.parametrize('cell_ensemble_data',[None,'CellEnsembleData']) def test_load_DREAM3D(self,ref_path,cell_ensemble_data):