espei/utils.py
def sigfigs(x, n):
    """Round x to n significant digits"""
    if x != 0:
        return np.around(x, -(np.floor(np.log10(np.abs(x)))).astype(np.int_) + (n - 1))
    else:
        return x
espei/database_utils.py
def initialize_database(phase_models, ref_state, dbf=None, fallback_ref_state="SGTE91"):
    """Return a Database boostraped with elements, species, phases and unary lattice stabilities.

    Parameters
    ----------
    phase_models : Dict[str, Any]
        Dictionary of components and phases to fit.
    ref_state : str
        String of the reference data to use, e.g. 'SGTE91' or 'SR2016'
    dbf : Optional[Database]
        Initial pycalphad Database that can have parameters that would not be fit by ESPEI
    fallback_ref_state : str
        String of the reference data to use for SER data, defaults to 'SGTE91'

    Returns
    -------
    Database
        A new pycalphad Database object, or a modified one if it was given.

    """
    if dbf is None:
        dbf = Database()
    lattice_stabilities = getattr(espei.refdata, ref_state)
    ser_stability = getattr(espei.refdata, ref_state + "Stable")
    aliases = extract_aliases(phase_models)
    phases = sorted({ph.upper() for ph in phase_models["phases"].keys()})
    elements = {el.upper() for el in phase_models["components"]}
    dbf.elements.update(elements)
    dbf.species.update({v.Species(el, {el: 1}, 0) for el in elements})

    # Add SER reference data for this element
    for element in dbf.elements:
        if element in dbf.refstates:
            continue  # Do not clobber user reference states
        el_ser_data = _get_ser_data(element, ref_state, fallback_ref_state=fallback_ref_state)
        # Try to look up the alias that we are using in this fitting
        el_ser_data["phase"] = aliases.get(el_ser_data["phase"], el_ser_data["phase"])
        # Don't warn if the element is a species with no atoms because per-atom
        # formation energies are not possible (e.g. VA (VACUUM) or /- (ELECTRON_GAS))
        if el_ser_data["phase"] not in phases and v.Species(element).number_of_atoms != 0:
            # We have the Gibbs energy expression that we need in the reference
            # data, but this phase is not a candidate in the phase models. The
            # phase won't be added to the database, so looking up the phases's
            # energy won't work.
            _log.warning(
                "The reference phase for %s, %s, is not in the supplied phase models "
                "and won't be added to the Database phases. Fitting formation "
                "energies will not be possible.", element, el_ser_data["phase"]
            )
        dbf.refstates[element] = el_ser_data

    # Add the phases
    for phase_name, phase_data in phase_models['phases'].items():
        if phase_name not in dbf.phases.keys():  # Do not clobber user phases
            # TODO: Need to support model hints for: magnetic, order-disorder, etc.
            site_ratios = phase_data['sublattice_site_ratios']
            subl_model = phase_data['sublattice_model']
            # Only generate the sublattice model for active components
            subl_model = [sorted(set(subl).intersection(dbf.elements)) for subl in subl_model]
            if all(len(subl) > 0 for subl in subl_model):
                dbf.add_phase(phase_name, dict(), site_ratios)
                dbf.add_phase_constituents(phase_name, subl_model)

    # Add the GHSER functions to the Database
    for element in dbf.elements:
        # Use setdefault here to not clobber user-provided functions
        if element == "VA":
            dbf.symbols.setdefault("GHSERVA", 0)
        else:
            # note that `c.upper()*2)[:2]` returns "AL" for "Al" and "BB" for "B"
            # Using this ensures that GHSER functions will be unique, e.g.
            # GHSERC would be an abbreviation for GHSERCA.
            sym_name = "GHSER" + (element.upper()*2)[:2]
            dbf.symbols.setdefault(sym_name, ser_stability[element])
    return dbf
espei/utils.py
def extract_aliases(phase_models):
    """Map possible phase name aliases to the desired phase model phase name.

    This function enforces that each alias is only claimed by one phase.
    Each phase name in the phase models is added as an alias for itself to
    support an "identity" operation.

    Parameters
    ----------
    phase_models
        Phase models ESPEI uses to initialize databases. Must contain a mapping
        of phase names to phase data (sublattices, site ratios, aliases)

    Returns
    -------
    Dict[str, str]

    """
    # Intialize aliases with identity for each phase first
    aliases = {phase_name: phase_name for phase_name in phase_models["phases"].keys()}
    for phase_name, phase_dict in phase_models["phases"].items():
        for alias in phase_dict.get("aliases", []):
            if alias not in aliases:
                aliases[alias] = phase_name
            else:
                raise ValueError(f"Cannot add {alias} as an alias for {phase_name} because it is already used by {aliases[alias]}")
    return aliases
espei/sublattice_tools.py
def recursive_tuplify(x):
    """Recursively convert a nested list to a tuple"""
    def _tuplify(y):
        if isinstance(y, list) or isinstance(y, tuple):
            return tuple(_tuplify(i) if isinstance(i, (list, tuple)) else i for i in y)
        else:
            return y
    return tuple(map(_tuplify, x))
espei/sublattice_tools.py
def interaction_test(configuration, order=None):
    """
    Returns True if the configuration has an interaction

    Parameters
    ----------
    order : int, optional
        Specific order to check for. E.g. a value of 3 checks for ternary interactions

    Returns
    -------
    bool
        True if there is an interaction.

    Examples
    --------
    >>> configuration = [['A'], ['A','B']]
    >>> interaction_test(configuration)
    True
    >>> interaction_test(configuration, order=2)
    True
    >>> interaction_test(configuration, order=3)
    False

    """
    interacting_species = [len(subl) for subl in configuration if isinstance(subl, (tuple,list))]
    if order is None:  # checking for any interaction
        return any([subl_occupation > 1 for subl_occupation in interacting_species])
    else:
        return any([subl_occupation == order for subl_occupation in interacting_species])
espei/parameter_selection/utils.py
def shift_reference_state(desired_data, feature_transform, fixed_model, mole_atoms_per_mole_formula_unit):
def get_data_quantities(desired_property, fixed_model, fixed_portions, data, sample_condition_dicts):
def _get_sample_condition_dicts(calculate_dict, sublattice_dof) -> List[Dict[Symbol, float]]:
    YS = Symbol('YS')
    Z = Symbol('Z')
    V_I, V_J, V_K = Symbol('V_I'), Symbol('V_J'), Symbol('V_K')
espei/sublattice_tools.py
def generate_symmetric_group(configuration, symmetry):
    """
    For a particular configuration and list of sublattices with symmetry,
    generate all the symmetrically equivalent configurations.

    Parameters
    ----------
    configuration : tuple
        Tuple of a sublattice configuration.
    symmetry : list of lists
        List of lists containing symmetrically equivalent sublattice indices,
        e.g. [[0, 1], [2, 3]] means that sublattices 0 and 1 are equivalent and
        sublattices 2 and 3 are also equivalent.

    Returns
    -------
    tuple
        Tuple of configuration tuples that are all symmetrically equivalent.

    """
    configurations = [recursive_tuplify(configuration)]
    permutation = np.array(symmetry, dtype=np.object_)

    def permute(x):
        if len(x) == 0:
            return x
        x[0] = np.roll(x[0], 1)
        x[:] = np.roll(x, 1, axis=0)
        return x

    if symmetry is not None:
        while np.any(np.array(symmetry, dtype=np.object_) != permute(permutation)):
            new_conf = np.array(configurations[0], dtype=np.object_)
            subgroups = []
            # There is probably a more efficient way to do this
            for subl in permutation:
                subgroups.append([configuration[idx] for idx in subl])
            # subgroup is ordered according to current permutation
            # but we'll index it based on the original symmetry
            # This should permute the configurations
            for subl, subgroup in zip(symmetry, subgroups):
                for subl_idx, conf_idx in enumerate(subl):
                    new_conf[conf_idx] = subgroup[subl_idx]
            configurations.append(recursive_tuplify(new_conf.tolist()))

    return sorted(set(configurations), key=canonical_sort_key)
espei/core_utils.py
def filter_temperatures(desired_data: List[Dataset]) -> List[Dataset]:
    """
    Return non-equilibrium thermochemical datasets with temperatures below 298.15 K removed.

    The currently provided unary reference data from ESPEI use the SGTE unary data that
    are defined as piecewise in temperature with a lower limit of 298.15 K for most
    elements. Since pycalphad does not extrapolate outside of piecewise temperature
    limits, this filter prevents fitting data to regions of temperature space where
    the energy is zero.

    Parameters
    ----------
    desired_data : List[Dataset]
        List of non-equilibrium thermochemical datasets

    Returns
    -------
    List[Dataset]

    """
    for data in desired_data:
        temp_filter = np.atleast_1d(data['conditions']['T']) >= 298.15
        data['conditions']['T'] = np.atleast_1d(data['conditions']['T'])[temp_filter]
        data['values'] = np.array(data['values'], dtype=np.float_)[..., temp_filter, :].tolist()
    return desired_data
espei/sublattice_tools.py
def tuplify(x):
    """Convert a list to a tuple, or wrap an object in a tuple if it's not a list or tuple."""
    if isinstance(x, list) or isinstance(x, tuple):
        return tuple(x)
    else:
        return tuple([x])
espei/core_utils.py
def symmetry_filter(x, config, symmetry):
    """
    Return True if the candidate sublattice configuration has any symmetry
    which matches the phase model symmetry.

    Parameters
    ----------
    x : dict
        the candidate dataset 'solver' dict. Must contain the "sublattice_configurations" key
    config : list
        the configuration of interest: e.g. ['AL', ['AL', 'NI'], 'VA']
    symmetry : list
        tuple of tuples where each inner tuple is a group of equivalent
        sublattices. A value of ((0, 1), (2, 3, 4)) means that sublattices
        at indices 0 and 1 are symmetrically equivalent to each other and
        sublattices at indices 2, 3, and 4 are symetrically equivalent to
        each other.

    Returns
    -------
    bool

    """
    if x['mode'] == 'manual':
        if len(config) != len(x['sublattice_configurations'][0]):
            return False
        # If even one matches, it's a match
        # We do more filtering downstream
        for data_config in x['sublattice_configurations']:
            if canonicalize(config, symmetry) == canonicalize(data_config, symmetry):
                return True
    return False
espei/utils.py
class PickleableTinyDB(TinyDB):
    """A pickleable version of TinyDB that uses MemoryStorage as a default."""
    def __getstate__(self):
        # first remove the query cache. The cache speed is not important to us.
        for table_name in self.tables():
            self.table(table_name)._query_cache = {}
        pickle_dict = {}
        for key, value in self.__dict__.items():
            if key == '_table':
                pickle_dict[key] = value.all()
            else:
                pickle_dict[key] = value
        return pickle_dict

    def __setstate__(self, state):
        self.__init__(storage=MemoryStorage)
        self.insert_multiple(state['_tables']['_default'])
espei/sublattice_tools.py
def generate_interactions(endmembers, order, symmetry, forbid_cross_interactions=None):
    """
    Returns a list of sorted interactions of a certain order

    Parameters
    ----------
    endmembers : list
        List of tuples/strings of all endmembers (including symmetrically equivalent)
    order : int
        Highest expected interaction order, e.g. ternary interactions should be 3
    symmetry : list of lists
        List of lists containing symmetrically equivalent sublattice indices,
        e.g. [[0, 1], [2, 3]] means that sublattices 0 and 1 are equivalent and
        sublattices 2 and 3 are also equivalent.
    forbid_cross_interactions : Optiona[bool]
        If True, will prevent cross interations from being generated. If None,
        automatically determine based on the symmetry. If there is no symmetry,
        cross interactions are forbidden. Symmetry usually implies that there
        is a disordered state that is interesting, so cross interactions are
        allowed.

    Returns
    -------
    list
        List of interaction tuples, e.g. [('A', ('A', 'B'))]

    """
    if forbid_cross_interactions is None:
        # Profiling indicates that it is worth skipping cross interactions in
        # multi-component systems due to the cost of the sorted_interactions
        if symmetry is None:
            forbid_cross_interactions = True  # symmetry may imply ordering, so keep these
        else:
            forbid_cross_interactions = False
    interactions = []
    for endmembers in itertools.combinations(endmembers, order):
        config = []
        has_desired_order = False  # Some interactions make for degenerate endmember
        for subl in zip(*endmembers):
            subl = set(subl)
            num_constit = len(subl)
            if num_constit == 1:
                config.append(tuple(subl)[0])  # Canonical form: a pure sublattice is not a sequence
            else:
                if has_desired_order and forbid_cross_interactions:
                    has_desired_order = False  # setting this to false ensures the interaction won't be added
                    break
                if num_constit == order:
                    has_desired_order = True
                config.append(tuple(sorted(subl)))
        if has_desired_order:
            interactions.append(config)
    return sorted_interactions(interactions, order, symmetry)
espei/core_utils.py
def get_prop_data(comps, phase_name, prop, datasets, additional_query=None) -> List[Dataset]:
    """
    Return a copy of datasets that match the components, phase and property.

    The queried datasets are copied to ensure that any modifications are safe.

    Parameters
    ----------
    comps : list
        List of components to get data for
    phase_name : str
        Name of the phase to get data for
    prop : str
        Property to get data for
    datasets : espei.utils.PickleableTinyDB
        Datasets to search for data
    additional_query : tinydb.Query
        A TinyDB Query object to search for. If None, a Query() will be created that does nothing.

    Returns
    -------
    List[Dataset]

    """
    if additional_query is None:
        additional_query = tinydb.Query()
    # TODO: we should only search and get phases that have the same sublattice_site_ratios as the phase in the database
    desired_data = datasets.search(
        (tinydb.where('output').test(lambda x: x in prop)) &
        (tinydb.where('components').test(lambda x: set(x).issubset(comps))) &
        (tinydb.where('phases') == [phase_name]) &
        additional_query
    )
    return copy.deepcopy(desired_data)
espei/parameter_selection/model_building.py
def build_candidate_models(configuration, features):
    """
    Return a dictionary of features and candidate models

    Parameters
    ----------
    configuration : tuple
        Configuration tuple, e.g. (('A', 'B', 'C'), 'A')
    features : dict
        Dictionary of {str: list} of generic features for a model, not
        considering the configuration. For example:
        {'CPM_FORM': [symengine.S.One, v.T, v.T**2, v.T**3]}

    Returns
    -------
    dict
        Dictionary of {feature: [candidate_models])

    Notes
    -----
    Currently only works for binary and ternary interactions.

    Candidate models match the following spec:
    1. Candidates with multiple features specified will have
    2. orders of parameters (L0, L0 and L1, ...) have the same number of temperatures

    Note that high orders of parameters with multiple temperatures are not
    required to contain all the temperatures of the low order parameters. For
    example, the following parameters can be generated
    L0: A
    L1: A + BT
    """
    feature_candidate_models = {}
    if not interaction_test(configuration):  # endmembers only
        for feature_name, temperature_features in features.items():
            interaction_features = (symengine.S.One,)
            feature_candidate_models[feature_name] = build_feature_sets(temperature_features, interaction_features)
    elif interaction_test(configuration, 2):  # has a binary interaction
        YS = symengine.Symbol('YS')  # Product of all nonzero site fractions in all sublattices
        Z = symengine.Symbol('Z')
        for feature_name, temperature_features in features.items():
            # generate increasingly complex interactions (power of Z is Redlich-Kister order)
            interaction_features = (YS, YS*Z, YS*(Z**2), YS*(Z**3))  # L0, L1, L2, L3
            feature_candidate_models[feature_name] = build_feature_sets(temperature_features, interaction_features)
    elif interaction_test(configuration, 3):  # has a ternary interaction
        # Ternaries interactions should have exactly two interaction sets:
        # 1. a single symmetric ternary parameter (YS)
        YS = symengine.Symbol('YS')  # Product of all nonzero site fractions in all sublattices
        # 2. L0, L1, and L2 parameters
        V_I, V_J, V_K = symengine.Symbol('V_I'), symengine.Symbol('V_J'), symengine.Symbol('V_K')
        symmetric_interactions = (YS,) # symmetric L0
        for feature_name, temperature_features in features.items():
            # We are ignoring cases where we have L0 == L1 != L2 (and like
            # permutations) because these cases (where two elements exactly the
            # same behavior) don't exist in reality. Tthe symmetric case is
            # mainly for small corrections and dimensionality reduction.
            # Because we don't want our parameter interactions to be successive
            # (i.e. products of symmetric and asymmetric terms), we'll candidates in two steps
            tern_ix_cands = []
            tern_ix_cands += build_feature_sets(temperature_features, symmetric_interactions)
            # special handling for asymmetric features, we don't want a successive V_I, V_J, V_K, but all three should be present
            asym_feats = (
                build_feature_sets(temperature_features, (YS * V_I,)), # asymmetric L0
                build_feature_sets(temperature_features, (YS * V_J,)), # asymmetric L1
                build_feature_sets(temperature_features, (YS * V_K,)), # asymmetric L2
            )                
            for v_i_feats, v_j_feats, v_k_feats in zip(*asym_feats):
                tern_ix_cands.append(v_i_feats + v_j_feats + v_k_feats)
            feature_candidate_models[feature_name] = tern_ix_cands
    else:
        raise ValueError(f"Interaction order not known for configuration {configuration}")
    return feature_candidate_models
espei/parameter_selection/selection.py
def select_model(candidate_models, ridge_alpha, weights, aicc_factor=None):
    """
    Select a model from a series of candidates by fitting and scoring them

    Parameters
    ----------
    candidate_models : list
        List of tuples of (features, feature_matrix, data_quantities)
    ridge_alpha : float
        Value of the :math:`\\alpha` hyperparameter used in ridge regression. Defaults to 1.0e-100, which should be degenerate
        with ordinary least squares regression. For now, the parameter is applied to all features.
    aicc_factor : float
        Multiplication factor for the AICc's parameter penalty.

    Returns
    -------
    tuple
        Tuple of (feature_list, model_coefficients) for the highest scoring model
    """
    opt_model_score = np.inf
    opt_model = None  # will hold a (feature_list, model_coefficients)
    for feature_list, feature_matrix, data_quantities in candidate_models:
        model_coefficients = fit_model(feature_matrix, data_quantities, ridge_alpha, weights=weights)
        model_score = score_model(feature_matrix, data_quantities, model_coefficients, feature_list, weights, aicc_factor=aicc_factor)
        if np.isneginf(model_score):  # exact fit, stop here
            return (feature_list, model_coefficients)
        if model_score < opt_model_score:
            opt_model_score = model_score
            opt_model = (feature_list, model_coefficients)
    return opt_model
espei/sublattice_tools.py
def generate_endmembers(sublattice_model, symmetry=None):
    """Return all the unique endmembers by symmetry for a given sublattice model.

    Parameters
    ----------
    sublattice_model : list of lists
        General sublattice model, with each sublattice as a sublist.
    symmetry : list of lists, optional
        List of lists containing symmetrically equivalent sublattice indices.
        If None (default), all endmembers will be returned.

    Returns
    -------
    list
        List of endmember tuples

    Examples
    --------
    >>> subl_model = [['A', 'B'], ['A','B']]
    >>> generate_endmembers(subl_model)  # four endmembers
    [('A', 'A'), ('A', 'B'), ('B', 'A'), ('B', 'B')]
    >>> # three endmembers, ('A', 'B') is equivalent to ('B', 'A') by symmetry.
    >>> generate_endmembers(subl_model, [[0, 1]])  # the first and second sublattices are symmetrically equivalent.
    [('A', 'A'), ('A', 'B'), ('B', 'B')]

    """
    return sorted(set(canonicalize(i, symmetry) for i in list(itertools.product(*sublattice_model))))
espei/error_functions/non_equilibrium_thermochemical_error.py
def get_prop_samples(desired_data, constituents):
    """
    Return data values and the conditions to calculate them using pycalphad.calculate

    Parameters
    ----------
    desired_data : List[Dict[str, Any]]
        List of dataset dictionaries that contain the values to sample
    constituents : List[List[str]]
        Names of constituents in each sublattice.

    Returns
    -------
    Dict[str, Union[float, ArrayLike, List[float]]]
        Dictionary of condition kwargs for pycalphad's calculate and the expected values

    """
    # TODO: assumes T, P as conditions
    # calculate needs points, state variable lists, and values to compare to
    num_dof = sum(map(len, constituents))
    calculate_dict = {
        'P': np.array([]),
        'T': np.array([]),
        'points': np.atleast_2d([[]]).reshape(-1, num_dof),
        'values': np.array([]),
        'weights': [],
        'references': [],
    }

    for datum in desired_data:
        # extract the data we care about
        datum_T = datum['conditions']['T']
        datum_P = datum['conditions']['P']
        configurations = datum['solver']['sublattice_configurations']
        occupancies = datum['solver'].get('sublattice_occupancies')
        values = np.array(datum['values'])
        if values.size == 0:
            # Skip any data that don't have any values left (e.g. after filtering)
            continue
        # Broadcast the weights to the shape of the values. This ensures that
        # the sizes of the weights and values are the same, which is important
        # because they are flattened later (so the shape information is lost).
        weights = np.broadcast_to(np.asarray(datum.get('weight', 1.0)), values.shape)

        # broadcast and flatten the conditions arrays
        P, T = ravel_conditions(values, datum_P, datum_T)
        if occupancies is None:
            occupancies = [None] * len(configurations)

        # calculate the points arrays, should be 2d array of points arrays
        points = np.array([calculate_points_array(constituents, config, occup) for config, occup in zip(configurations, occupancies)])
        assert values.shape == weights.shape, f"Values data shape {values.shape} does not match weights shape {weights.shape}"

        # add everything to the calculate_dict
        calculate_dict['P'] = np.concatenate([calculate_dict['P'], P])
        calculate_dict['T'] = np.concatenate([calculate_dict['T'], T])
        calculate_dict['points'] = np.concatenate([calculate_dict['points'], np.tile(points, (values.shape[0]*values.shape[1], 1))], axis=0)
        calculate_dict['values'] = np.concatenate([calculate_dict['values'], values.flatten()])
        calculate_dict['weights'].extend(weights.flatten())
        calculate_dict['references'].extend([datum.get('reference', "") for _ in range(values.flatten().size)])
    return calculate_dict
espei/sublattice_tools.py
def endmembers_from_interaction(configuration):
    """For a given configuration with possible interactions, return all the endmembers"""
    config = []
    for c in configuration:
        if isinstance(c, (list, tuple)):
            config.append(c)
        else:
            config.append([c])
    return list(itertools.product(*[tuple(c) for c in config]))
espei/core_utils.py
def filter_configurations(desired_data: List[Dataset], configuration, symmetry) -> List[Dataset]:
    """
    Return non-equilibrium thermochemical datasets with invalid configurations removed.

    Parameters
    ----------
    desired_data : List[Dataset]
        List of non-equilibrium thermochemical datasets
    configuration : tuple
        Sublattice configuration as a tuple, e.g. ("CU", ("CU", "MG"))
    symmetry : list of lists
        List of sublattice indices with symmetry

    Returns
    -------
    List[Dataset]

    """
    for data in desired_data:
        # Filter output values to only contain data for matching sublattice configurations
        matching_configs = np.array([(canonicalize(sblconf, symmetry) == canonicalize(configuration, symmetry))
                                     for sblconf in data['solver']['sublattice_configurations']])
        matching_configs = np.arange(len(data['solver']['sublattice_configurations']))[matching_configs]
        # Rewrite output values with filtered data
        data['values'] = np.array(data['values'], dtype=np.float_)[..., matching_configs]
        data['solver']['sublattice_configurations'] = recursive_tuplify(np.array(data['solver']['sublattice_configurations'], dtype=np.object_)[matching_configs].tolist())
        if 'sublattice_occupancies' in data['solver']:
            data['solver']['sublattice_occupancies'] = np.array(data['solver']['sublattice_occupancies'], dtype=np.object_)[matching_configs].tolist()
    return desired_data


Question: import sys
import logging
import operator
import numpy as np
import symengine
import tinydb
import espei.refdata
from typing import List, Dict
from collections import OrderedDict
from symengine import Symbol
from tinydb import where
from pycalphad import Database, Model, variables as v
from espei.database_utils import initialize_database
from espei.core_utils import get_prop_data, filter_configurations, filter_temperatures, symmetry_filter
from espei.error_functions.non_equilibrium_thermochemical_error import get_prop_samples
from espei.parameter_selection.model_building import build_candidate_models
from espei.parameter_selection.selection import select_model
from espei.parameter_selection.utils import get_data_quantities, feature_transforms, _get_sample_condition_dicts
from espei.sublattice_tools import generate_symmetric_group, generate_interactions, \
    tuplify, recursive_tuplify, interaction_test, endmembers_from_interaction, generate_endmembers
from espei.utils import PickleableTinyDB, sigfigs, extract_aliases

Choose parameter set with best AICc score.

"""




_log = logging.getLogger(__name__)


def _param_present_in_database(dbf, phase_name, configuration, param_type):
    const_arr = tuple([tuple(map(lambda x: v.Species(x), subl)) for subl in map(tuplify, configuration)])
    # parameter order doesn't matter here, since the generated might not exactly match. Always override.
    query = (where('phase_name') == phase_name) & \
            (where('parameter_type') == param_type) & \
            (where('constituent_array') == const_arr)
    search_result = dbf._parameters.search(query)
    if len(search_result) > 0:
        return True


def _stable_sort_key(x):
    return str(sorted(x[0].args, key=str))


def _poly_degrees(expr):
    poly_dict = {}
    for at in expr.atoms(symengine.Symbol):
        poly_dict[at] = 1
    for at in expr.atoms(symengine.log):
        poly_dict[at] = 1
    for at in expr.atoms(symengine.Pow):
        poly_dict[at.args[0]] = at.args[1]
    return poly_dict


def has_symbol(expr, check_symbol):
    """
    Workaround for SymEngine not supporting Basic.has() with non-Symbol arguments.
    Only works for detecting subsets of multiplication of variables.
    """
    try:
        return expr.has(check_symbol)
    except TypeError:
        expr_poly = _poly_degrees(expr)
        check_poly = _poly_degrees(check_symbol)
        for cs, check_degree in check_poly.items():
            eps = expr_poly.get(cs, 0)
            if check_degree > eps:
                return False
        return True


def _build_feature_matrix(sample_condition_dicts: List[Dict[Symbol, float]], symbolic_coefficients: List[Symbol]):
    """
    Builds A for solving x = A\\b. A is an MxN matrix of M sampled data points and N is the symbolic coefficients.

    Parameters
    ----------
    sample_condition_dicts : List[Dict[Symbol, float]]
        List of length ``M`` containing the conditions (T, P, YS, Z, V_I, V_J, V_K) for
        each sampled point.
    symbolic_coefficients : List[Symbol]
        Symbolic coefficients of length ```N`` (e.g. ``v.T``, ``YS``) of the features
        corresponding to the variables that will be fit.

    Returns
    -------
    ArrayLike
        MxN array of coefficients with sampled data conditions plugged in.
    """
    M = len(sample_condition_dicts)
    N = len(symbolic_coefficients)
    feature_matrix = np.empty((M, N))
    for i in range(M):
        for j in range(N):
            feature_matrix[i, j] = symbolic_coefficients[j].subs(sample_condition_dicts[i])
    return feature_matrix


def fit_formation_energy(dbf, comps, phase_name, configuration, symmetry, datasets, ridge_alpha=None, aicc_phase_penalty=None, features=None):
    """
    Find suitable linear model parameters for the given phase.
    We do this by successively fitting heat capacities, entropies and
    enthalpies of formation, and selecting against criteria to prevent
    overfitting. The "best" set of parameters minimizes the error
    without overfitting.

    Parameters
    ----------
    dbf : Database
        pycalphad Database. Partially complete, so we know what degrees of freedom to fix.
    comps : [str]
        Names of the relevant components.
    phase_name : str
        Name of the desired phase for which the parameters will be found.
    configuration : ndarray
        Configuration of the sublattices for the fitting procedure.
    symmetry : [[int]]
        Symmetry of the sublattice configuration.
    datasets : PickleableTinyDB
        All the datasets desired to fit to.
    ridge_alpha : float
        Value of the :math:`\\alpha` hyperparameter used in ridge regression. Defaults to 1.0e-100, which should be degenerate
        with ordinary least squares regression. For now, the parameter is applied to all features.
    aicc_feature_factors : dict
        Map of phase name to feature to a multiplication factor for the AICc's parameter penalty.
    features : dict
        Maps "property" to a list of features for the linear model.
        These will be transformed from "GM" coefficients
        e.g., {"CPM_FORM": (v.T*symengine.log(v.T), v.T**2, v.T**-1, v.T**3)} (Default value = None)

    Returns
    -------
    dict
        {feature: estimated_value}

    """
    aicc_feature_factors = aicc_phase_penalty if aicc_phase_penalty is not None else {}

Answer:     if interaction_test(configuration):