import numpy as np
from pycalphad.core.errors import ConditionError
from pycalphad.property_framework import as_property, as_quantity
from pycalphad.property_framework.units import Q_
import pycalphad.variables as v
from collections.abc import Iterable
from typing import List, NamedTuple, Optional, TYPE_CHECKING
import warnings
import os
if TYPE_CHECKING:
from pycalphad.core.workspace import Workspace
from pycalphad.property_framework import ComputableProperty
[docs]
class ConditionsEntry(NamedTuple):
prop: "ComputableProperty"
value: "Q_"
_default = object()
[docs]
def unpack_condition(tup):
"""
Convert a condition to a list of values.
Notes
-----
Rules for keys of conditions dicts:
(1) If it's numeric, treat as a point value
(2) If it's a tuple with one element, treat as a point value
(3) If it's a tuple with two elements, treat as lower/upper limits and guess a step size.
(4) If it's a tuple with three elements, treat as lower/upper/step
(5) If it's a list, ndarray or other non-tuple ordered iterable, use those values directly.
"""
if isinstance(tup, tuple):
if len(tup) == 1:
return [float(tup[0])]
elif len(tup) == 2:
return np.arange(tup[0], tup[1], dtype=np.float64)
elif len(tup) == 3:
return np.arange(tup[0], tup[1], tup[2], dtype=np.float64)
else:
raise ValueError('Condition tuple is length {}'.format(len(tup)))
elif isinstance(tup, Q_):
return tup
elif isinstance(tup, Iterable) and np.ndim(tup) != 0:
return [float(x) for x in tup]
else:
return [float(tup)]
[docs]
class Conditions:
_wks: "Workspace"
_conds: List[ConditionsEntry]
minimum_composition: float = 1e-10
def __init__(self, wks: Optional["Workspace"]):
self._wks = wks
self._conds = []
# Default to N=1
self.__setitem__(v.N, Q_(np.atleast_1d(1.0), 'mol'))
[docs]
@classmethod
def from_dict(cls, d):
if isinstance(d, Conditions):
return d
obj = cls(wks=None)
obj.update(d)
return obj
def _find_matching_index(self, prop: "ComputableProperty"):
for idx, (key, _) in enumerate(self._conds):
# TODO: Use more sophisticated matching
if str(prop) == str(key):
return idx
return None
[docs]
@classmethod
def cast_from(cls, key) -> "Conditions":
return cls.from_dict(key)
def __getitem__(self, item):
key = as_property(item)
idx = self._find_matching_index(key)
if idx is None:
raise IndexError(f"{item} is not a condition")
entry = self._conds[idx]
# Important to use the _key_ display_units, and not the entry.prop
# This is because v.T['K'] == v.T['degC'], so conditions can be
# stored and queried with distinct units
return entry.value.to(key.display_units).magnitude
[docs]
def get(self, item, default=_default):
try:
return self.__getitem__(item)
except IndexError:
if default is not _default:
return default
else:
raise
def __delitem__(self, item):
idx = self._find_matching_index(as_property(item))
if idx is None:
raise IndexError(f"{item} is not a condition")
del self._conds[idx]
def __setitem__(self, item, value):
prop = as_property(item)
if isinstance(prop, (v.MoleFraction, v.MassFraction, v.SiteFraction)):
vals = unpack_condition(value)
if isinstance(vals, Q_):
vals = vals.to(prop.implementation_units).magnitude
# "Zero" composition is a common pattern. Do not warn for that case.
if np.any(np.logical_and(np.asarray(vals) < self.minimum_composition, np.asarray(vals) > 0)):
warnings.warn(
f"Some specified compositions are below the minimum allowed composition of {self.minimum_composition}.")
value = [min(max(val, self.minimum_composition), 1-self.minimum_composition) for val in vals]
else:
value = unpack_condition(value)
if len(value) == 0:
raise ConditionError('Condition cannot be zero-length array')
value = as_quantity(prop, value).to(prop.implementation_units)
if isinstance(prop, (v.MoleFraction, v.MassFraction, v.ChemicalPotential)) and prop.species not in self._wks.components:
raise ConditionError('{} refers to non-existent component'.format(prop))
if isinstance(prop, v.SiteFraction) and prop not in self._wks.models[prop.phase_name].variables:
raise ConditionError('{} refers to non-existent constituent'.format(prop))
if (prop == v.N) and np.any(value != Q_(1.0, 'mol')):
raise ConditionError('N!=1 is not yet supported, got N={}'.format(value))
entry = ConditionsEntry(prop=prop, value=value)
idx = self._find_matching_index(prop)
if idx is None:
# Condition is not yet specified
self._conds.append(entry)
else:
self._conds[idx] = entry
self._conds = sorted(self._conds, key=lambda k: str(k[0]))
[docs]
def keys(self):
for key, _ in self._conds:
yield key
[docs]
def str_keys(self):
for key, _ in self._conds:
yield str(key)
[docs]
def values(self, units='display_units'):
for key, value in self._conds:
yield value.to(getattr(key, units, '')).magnitude
[docs]
def update(self, d):
for key, value in d.items():
self.__setitem__(key, value)
[docs]
def items(self, units='display_units'):
for key, value in self._conds:
yield key, value.to(getattr(key, units, '')).magnitude
def __len__(self):
return len(self._conds)
def __iter__(self):
yield from self.keys()
def __str__(self):
result = ""
with np.printoptions(threshold=10):
for key, value in self._conds:
result += str(key) + "=" + str(value) + os.linesep
return result
__repr__ = __str__