def get_starting_simplex(self): """ Calculate convex hull and find a suitable starting point. Returns (DataFrame of phase compositions, ndarray of phase fractions) """ phase_compositions, phase_fracs, pots = \ lower_convex_hull(self.data, self.components, self.conditions) if phase_compositions is None: logger.error('Unable to find starting point for calculation') raise EquilibriumError('Unable to find starting point for calculation') logger.debug(self.data.iloc[phase_compositions]) independent_indices = \ check_degenerate_phases(self.data.iloc[phase_compositions], mindist=0.1) logger.debug('phase_fracs: %s', phase_fracs) logger.debug('independent_indices: %s', independent_indices) # renormalize phase fractions to 1 after eliminating redundant phases phase_fracs = phase_fracs[independent_indices] phase_fracs /= np.sum(phase_fracs) return [self.data.iloc[phase_compositions[independent_indices]], phase_fracs]
def lower_convex_hull(data, comps, conditions): """ Find the simplex on the lower convex hull satisfying the specified conditions. Parameters ---------- data : DataFrame A sample of the energy surface of the system. comps : list All the components in the system. conditions : dict StateVariables and their corresponding value. Returns ------- A tuple containing: (1) A numpy array of indices corresponding to vertices of the simplex. (2) A numpy array corresponding to the phase fractions. (3) A numpy array of chemical potentials in sorted(comps) order (no 'VA') Note: This routine will not check if the simplex is degenerate. Examples -------- None yet. """ # determine column indices for degrees of freedom comps = sorted(list(comps)) dof = ['X({0})'.format(c) for c in comps if c != 'VA'] dof_values = np.zeros(len(dof)) marked_dof_values = list(range(len(dof))) for cond, value in conditions.items(): if not isinstance(cond, v.Composition): continue # ignore phase-specific composition conditions if cond.phase_name is not None: continue if cond.species == 'VA': continue dof_values[dof.index('X({0})'.format(cond.species))] = value marked_dof_values.remove(dof.index('X({0})'.format(cond.species))) dof.append('GM') if len(marked_dof_values) == 1: dof_values[marked_dof_values[0]] = 1-sum(dof_values) else: logger.error('Not enough composition conditions specified') raise ValueError('Not enough composition conditions specified.') # convert DataFrame of independent columns to ndarray dat = data[dof].values temperature = data.at[0, 'T'] # Build a fictitious hyperplane which has an energy greater than the max # energy in the system # This guarantees our starting point is feasible but also makes it likely # it won't be part of the solution energy_ceiling = np.amax(dat[:, -1]) if np.isnan(energy_ceiling): raise ValueError('Input energy surface contains one or more NaNs.') if energy_ceiling < 0: energy_ceiling *= 0.1 else: energy_ceiling *= 10 start_matrix = np.empty([len(dof)-1, len(dof)]) start_matrix[:, :-1] = np.eye(len(dof)-1) start_matrix[:, -1] = energy_ceiling # set energy dat = np.concatenate([start_matrix, dat]) max_iterations = min(100, dat.shape[0]) # Need to choose a feasible starting point # initialize simplex as first n points of fictitious hyperplane candidate_simplex = np.array(range(len(dof)-1), dtype=np.int) # Calculate chemical potentials candidate_potentials = np.linalg.solve(dat[candidate_simplex, :-1], dat[candidate_simplex, -1]) # Calculate driving forces for reducing our candidate potentials driving_forces = np.dot(dat[:, :-1], candidate_potentials) - dat[:, -1] # Mask points with negative (or nearly zero) driving force point_mask = driving_forces/(8.3145*temperature) < 1e-4 #logger.debug(point_mask) #logger.debug(np.array(range(dat.shape[0]), dtype=np.int)[~point_mask]) candidate_energy = np.dot(candidate_potentials, dof_values) fractions = np.empty(len(dof_values)) iteration = 0 found_solution = False index_array = np.array(range(dat.shape[0]), dtype=np.int) while (found_solution == False) and (iteration < max_iterations): iteration += 1 for new_point in index_array[~point_mask]: found_point = False # Need to successively replace columns with the new point # The goal is to find positive phase fraction values new_simplex = np.empty(dat.shape[1] - 1, dtype=np.int) for col in range(dat.shape[1] - 1): #print(candidate_simplex) new_simplex[:] = candidate_simplex # [:] forces copy new_simplex[col] = new_point #print(new_simplex) logger.debug('trial matrix: %s', dat[new_simplex, :-1].T) try: fractions = np.linalg.solve(dat[new_simplex, :-1].T, dof_values) except np.linalg.LinAlgError: # singular matrix means the trial simplex is degenerate # this usually happens due to collisions between points on # the fictitious hyperplane and the endmembers continue logger.debug('fractions: %s', fractions) if np.all(fractions > -1e-8): # Positive phase fractions # Do I reduce the energy with this solution? # Recalculate chemical potentials and energy #logger.debug('new matrix: {0}'.format(dat[new_simplex, :-1])) #logger.debug('new energies: {0}'.format(dat[new_simplex, -1])) new_potentials = np.linalg.solve(dat[new_simplex, :-1], dat[new_simplex, -1]) #logger.debug('new_potentials: {0}'.format(new_potentials)) new_energy = np.dot(new_potentials, dof_values) # differences of less than 1mJ/mol are irrelevant new_energy = np.around(new_energy, decimals=3) if new_energy <= candidate_energy: #logger.debug('New simplex {2} reduces energy from \ # {0} to {1}'.format(candidate_energy, new_energy, \ # new_simplex)) # [:] notation forces a copy candidate_simplex[:] = new_simplex candidate_potentials[:] = new_potentials # np.array() forces a copy candidate_energy = np.array(new_energy) # Recalculate driving forces with new potentials driving_forces[:] = np.dot(dat[:, :-1], \ candidate_potentials) - dat[:, -1] #logger.debug('driving_forces: %s', driving_forces) point_mask = driving_forces/(8.3145*temperature) < 1e-4 # Don't test points on the fictitious hyperplane point_mask[list(range(len(dof)-1))] = True found_point = True break #else: # logger.debug('Trial simplex {2} increases energy from {0} to {1}'\ # .format(candidate_energy, new_energy, new_simplex)) # logger.debug('%s points with positive driving force remain', # list(driving_forces >= 1e-4).count(True)) if found_point: logger.debug('Found feasible simplex: moving to next iteration') #logger.debug('%s points with positive driving force remain', # list(driving_forces >= 1e-4).count(True)) break # If there is no positive driving force, we have the solution #print('Checking point mask') #print(point_mask) logger.debug('Iteration count: {0}'.format(iteration)) if np.all(point_mask) == True: logger.debug('Unadjusted candidate_simplex: %s', candidate_simplex) logger.debug(dat[candidate_simplex]) # Fix candidate simplex indices to remove fictitious points candidate_simplex = candidate_simplex - (len(dof)-1) logger.debug('Adjusted candidate_simplex: %s', candidate_simplex) # Remove fictitious points from the candidate simplex # These can inadvertently show up if we only calculate a phase with # limited solubility # Also remove points with very small estimated phase fractions candidate_simplex, fractions = zip(*[(c, f) for c, f in zip(candidate_simplex, fractions) if c >= 0 and f >= 1e-12]) candidate_simplex = np.array(candidate_simplex) fractions = np.array(fractions) fractions /= np.sum(fractions) logger.debug('Final candidate_simplex: %s', candidate_simplex) logger.debug('Final phase fractions: %s', fractions) found_solution = True logger.debug('Solution:') logger.debug(candidate_potentials) logger.debug(candidate_energy) return candidate_simplex, fractions, candidate_potentials logger.error('Iterations exceeded') logger.debug('Positive driving force still exists for these points') logger.debug(np.where(driving_forces/(8.3145*temperature) > 1e-4)[0]) return None, None, None
def lower_convex_hull(global_grid, result_array): """ Find the simplices on the lower convex hull satisfying the specified conditions in the result array. Parameters ---------- global_grid : Dataset A sample of the energy surface of the system. result_array : Dataset This object will be modified! Coordinates correspond to conditions axes. Returns ------- None. Results are written to result_array. Notes ----- This routine will not check if any simplex is degenerate. Degenerate simplices will manifest with duplicate or NaN indices. Examples -------- None yet. """ conditions = [ x for x in result_array.coords.keys() if x not in ['vertex', 'component'] ] indep_conds = sorted( [x for x in sorted(result_array.coords.keys()) if x in ['T', 'P']]) indep_shape = tuple(len(result_array.coords[x]) for x in indep_conds) comp_conds = sorted( [x for x in sorted(result_array.coords.keys()) if x.startswith('X_')]) comp_shape = tuple(len(result_array.coords[x]) for x in comp_conds) pot_conds = sorted( [x for x in sorted(result_array.coords.keys()) if x.startswith('MU_')]) # force conditions to have particular ordering conditions = indep_conds + pot_conds + comp_conds trial_shape = (len(result_array.coords['component']), ) trial_points = None _initialize_array(global_grid, result_array) # Enforce ordering of shape result_array['points'] = result_array['points'].transpose(*(conditions + ['vertex'])) result_array['GM'] = result_array['GM'].transpose(*(conditions)) result_array['NP'] = result_array['NP'].transpose(*(conditions + ['vertex'])) # Determine starting combinations of chemical potentials and compositions # TODO: Check Gibbs phase rule compliance if len(pot_conds) > 0: raise NotImplementedError( 'Chemical potential conditions are not yet supported') # FIRST CASE: Only composition conditions specified # We only need to compute the dependent composition value directly # Initialize trial points as lowest energy point in the system if (len(comp_conds) > 0) and (len(pot_conds) == 0): trial_points = np.empty(result_array['GM'].T.shape) trial_points.fill(np.inf) trial_points[...] = global_grid['GM'].argmin(dim='points').values.T trial_points = trial_points.T comp_values = cartesian( [result_array.coords[cond] for cond in comp_conds]) # Insert dependent composition value # TODO: Handle W(comp) as well as X(comp) here specified_components = set([x[2:] for x in comp_conds]) dependent_component = set( result_array.coords['component'].values) - specified_components dependent_component = list(dependent_component) if len(dependent_component) != 1: raise ValueError( 'Number of dependent components is different from one') insert_idx = sorted(result_array.coords['component'].values).index( dependent_component[0]) comp_values = np.concatenate( (comp_values[..., :insert_idx], 1 - np.sum(comp_values, keepdims=True, axis=-1), comp_values[..., insert_idx:]), axis=-1) # SECOND CASE: Only chemical potential conditions specified # TODO: Implementation of chemical potential # THIRD CASE: Mixture of composition and chemical potential conditions # TODO: Implementation of mixed conditions if trial_points is None: raise ValueError('Invalid conditions') driving_forces = np.zeros(result_array.GM.values.shape + (len(global_grid.points), ), dtype=np.float) max_iterations = 50 iterations = 0 while iterations < max_iterations: iterations += 1 trial_simplices = np.empty(result_array['points'].values.shape + \ (result_array['points'].values.shape[-1],), dtype=np.int) # Initialize trial simplices with values from best guess simplices trial_simplices[..., :, :] = result_array['points'].values[ ..., np.newaxis, :] # Trial simplices will be the current simplex with each vertex # replaced by the trial point # Exactly one of those simplices will contain a given test point, # excepting edge cases trial_simplices.T[np.diag_indices(trial_shape[0])] = trial_points.T #print('trial_simplices.shape', trial_simplices.shape) #print('global_grid.X.values.shape', global_grid.X.values.shape) flat_statevar_indices = np.unravel_index( np.arange(np.multiply.reduce(result_array.MU.values.shape)), result_array.MU.values.shape)[:len(indep_conds)] #print('flat_statevar_indices', flat_statevar_indices) trial_matrix = global_grid.X.values[np.index_exp[ flat_statevar_indices + (trial_simplices.reshape(-1, trial_simplices.shape[-1]).T, )]] trial_matrix = np.rollaxis(trial_matrix, 0, -1) #print('trial_matrix', trial_matrix) # Partially ravel the array to make indexing operations easier trial_matrix.shape = (-1, ) + trial_matrix.shape[-2:] # We have to filter out degenerate simplices before # phase fraction computation # This is because even one degenerate simplex causes the entire tensor # to be singular nondegenerate_indices = np.all( np.linalg.svd(trial_matrix, compute_uv=False) > 1e-12, axis=-1, keepdims=True) # Determine how many trial simplices remain for each target point. # In principle this would always be one simplex per point, but once # some target values reach equilibrium, trial_points starts # to contain points already on our best guess simplex. # This causes trial_simplices to create degenerate simplices. # We can safely filter them out since those target values are # already at equilibrium. sum_array = np.sum(nondegenerate_indices, axis=-1, dtype=np.int) index_array = np.repeat(np.arange(trial_matrix.shape[0], dtype=np.int), sum_array) comp_shape = trial_simplices.shape[:len(indep_conds)+len(pot_conds)] + \ (comp_values.shape[0], trial_simplices.shape[-2]) comp_indices = np.unravel_index( index_array, comp_shape)[len(indep_conds) + len(pot_conds)] fractions = np.linalg.solve( np.swapaxes(trial_matrix[index_array], -2, -1), comp_values[comp_indices]) # A simplex only contains a point if its barycentric coordinates # (phase fractions) are positive. bounding_indices = np.all(fractions >= 0, axis=-1) index_array = np.atleast_1d(index_array[bounding_indices]) raveled_simplices = trial_simplices.reshape((-1, ) + trial_simplices.shape[-1:]) candidate_simplices = raveled_simplices[index_array, :] #print('candidate_simplices', candidate_simplices) # We need to convert the flat index arrays into multi-index tuples. # These tuples will tell us which state variable combinations are relevant # for the calculation. We can drop the last dimension, 'trial'. #print('trial_simplices.shape[:-1]', trial_simplices.shape[:-1]) statevar_indices = np.unravel_index( index_array, trial_simplices.shape[:-1])[:len(indep_conds) + len(pot_conds)] aligned_energies = global_grid.GM.values[statevar_indices + (candidate_simplices.T, )].T statevar_indices = tuple(x[..., np.newaxis] for x in statevar_indices) #print('statevar_indices', statevar_indices) aligned_compositions = global_grid.X.values[np.index_exp[ statevar_indices + (candidate_simplices, )]] #print('aligned_compositions', aligned_compositions) #print('aligned_energies', aligned_energies) candidate_potentials = np.linalg.solve( aligned_compositions.astype(np.float, copy=False), aligned_energies.astype(np.float, copy=False)) #print('candidate_potentials', candidate_potentials) logger.debug('candidate_simplices: %s', candidate_simplices) comp_indices = np.unravel_index( index_array, comp_shape)[len(indep_conds) + len(pot_conds)] #print('comp_values[comp_indices]', comp_values[comp_indices]) candidate_energies = np.multiply( candidate_potentials, comp_values[comp_indices]).sum(axis=-1) #print('candidate_energies', candidate_energies) # Generate a matrix of energies comparing our calculations for this iteration # to each other. # 'conditions' axis followed by a 'trial' axis # Empty values are filled in with infinity comparison_matrix = np.empty( [trial_matrix.shape[0] / trial_shape[0], trial_shape[0]]) assert comparison_matrix.shape[0] == aligned_compositions.shape[0] comparison_matrix.fill(np.inf) comparison_matrix[ np.divide(index_array, trial_shape[0]).astype(np.int), np.mod(index_array, trial_shape[0])] = candidate_energies #print('comparison_matrix', comparison_matrix) # If a condition point is all infinities, it means we did not calculate it # We should filter those out from any comparisons calculated_indices = ~np.all(comparison_matrix == np.inf, axis=-1) # Extract indices for trials with the lowest energy for each target point lowest_energy_indices = np.argmin( comparison_matrix[calculated_indices], axis=-1) # Filter conditions down to only those calculated this iteration calculated_conditions_indices = np.arange( comparison_matrix.shape[0])[calculated_indices] #print('comparison_matrix[calculated_conditions_indices,lowest_energy_indices]',comparison_matrix[calculated_conditions_indices, # lowest_energy_indices]) is_lower_energy = comparison_matrix[calculated_conditions_indices, lowest_energy_indices] < \ result_array['GM'].values.flat[calculated_conditions_indices] #print('is_lower_energy', is_lower_energy) # These are the conditions we will update this iteration final_indices = calculated_conditions_indices[is_lower_energy] #print('final_indices', final_indices) # Convert to multi-index form so we can index the result array final_multi_indices = np.unravel_index(final_indices, result_array['GM'].values.shape) updated_potentials = candidate_potentials[is_lower_energy] result_array['points'].values[ final_multi_indices] = candidate_simplices[is_lower_energy] result_array['GM'].values[final_multi_indices] = candidate_energies[ is_lower_energy] result_array['MU'].values[final_multi_indices] = updated_potentials result_array['NP'].values[final_multi_indices] = \ fractions[bounding_indices][is_lower_energy] #print('result_array.GM.values', result_array.GM.values) # By profiling, it's faster to recompute all driving forces in-place # versus doing fancy indexing to only update "changed" driving forces # This avoids the creation of expensive temporaries np.einsum('...i,...i', result_array.MU.values[..., np.newaxis, :], global_grid.X.values[np.index_exp[...] + ((np.newaxis, ) * len(comp_conds)) + np.index_exp[:, :]], out=driving_forces) np.subtract(driving_forces, global_grid.GM.values[np.index_exp[...] + ((np.newaxis, ) * len(comp_conds)) + np.index_exp[:]], out=driving_forces) # Update trial points to choose points with largest remaining driving force trial_points = np.argmax(driving_forces, axis=-1) #print('trial_points', trial_points) logger.debug('trial_points: %s', trial_points) # If all driving force (within some tolerance) is consumed, we found equilibrium if np.all(driving_forces <= DRIVING_FORCE_TOLERANCE): return #raise ValueError print('Iterations exceeded. Remaining driving force: ', driving_forces.max()) logger.error('Iterations exceeded')
def lower_convex_hull(global_grid, result_array): """ Find the simplices on the lower convex hull satisfying the specified conditions in the result array. Parameters ---------- global_grid : Dataset A sample of the energy surface of the system. result_array : Dataset This object will be modified! Coordinates correspond to conditions axes. Returns ------- None. Results are written to result_array. Notes ----- This routine will not check if any simplex is degenerate. Degenerate simplices will manifest with duplicate or NaN indices. Examples -------- None yet. """ conditions = [x for x in result_array.coords.keys() if x not in ['vertex', 'component']] indep_conds = sorted([x for x in sorted(result_array.coords.keys()) if x in ['T', 'P']]) indep_shape = tuple(len(result_array.coords[x]) for x in indep_conds) comp_conds = sorted([x for x in sorted(result_array.coords.keys()) if x.startswith('X_')]) comp_shape = tuple(len(result_array.coords[x]) for x in comp_conds) pot_conds = sorted([x for x in sorted(result_array.coords.keys()) if x.startswith('MU_')]) # force conditions to have particular ordering conditions = indep_conds + pot_conds + comp_conds trial_shape = (len(result_array.coords['component']),) trial_points = None _initialize_array(global_grid, result_array) # Enforce ordering of shape result_array['points'] = result_array['points'].transpose(*(conditions + ['vertex'])) result_array['GM'] = result_array['GM'].transpose(*(conditions)) result_array['NP'] = result_array['NP'].transpose(*(conditions + ['vertex'])) # Determine starting combinations of chemical potentials and compositions # TODO: Check Gibbs phase rule compliance if len(pot_conds) > 0: raise NotImplementedError('Chemical potential conditions are not yet supported') # FIRST CASE: Only composition conditions specified # We only need to compute the dependent composition value directly # Initialize trial points as lowest energy point in the system if (len(comp_conds) > 0) and (len(pot_conds) == 0): trial_points = np.empty(result_array['GM'].T.shape) trial_points.fill(np.inf) trial_points[...] = global_grid['GM'].argmin(dim='points').values.T trial_points = trial_points.T comp_values = cartesian([result_array.coords[cond] for cond in comp_conds]) # Insert dependent composition value # TODO: Handle W(comp) as well as X(comp) here specified_components = set([x[2:] for x in comp_conds]) dependent_component = set(result_array.coords['component'].values) - specified_components dependent_component = list(dependent_component) if len(dependent_component) != 1: raise ValueError('Number of dependent components is different from one') insert_idx = sorted(result_array.coords['component'].values).index(dependent_component[0]) comp_values = np.concatenate((comp_values[..., :insert_idx], 1 - np.sum(comp_values, keepdims=True, axis=-1), comp_values[..., insert_idx:]), axis=-1) # SECOND CASE: Only chemical potential conditions specified # TODO: Implementation of chemical potential # THIRD CASE: Mixture of composition and chemical potential conditions # TODO: Implementation of mixed conditions if trial_points is None: raise ValueError('Invalid conditions') driving_forces = np.zeros(result_array.GM.values.shape + (len(global_grid.points),), dtype=np.float) max_iterations = 50 iterations = 0 while iterations < max_iterations: iterations += 1 trial_simplices = np.empty(result_array['points'].values.shape + \ (result_array['points'].values.shape[-1],), dtype=np.int) # Initialize trial simplices with values from best guess simplices trial_simplices[..., :, :] = result_array['points'].values[..., np.newaxis, :] # Trial simplices will be the current simplex with each vertex # replaced by the trial point # Exactly one of those simplices will contain a given test point, # excepting edge cases trial_simplices.T[np.diag_indices(trial_shape[0])] = trial_points.T #print('trial_simplices.shape', trial_simplices.shape) #print('global_grid.X.values.shape', global_grid.X.values.shape) flat_statevar_indices = np.unravel_index(np.arange(np.multiply.reduce(result_array.MU.values.shape)), result_array.MU.values.shape)[:len(indep_conds)] #print('flat_statevar_indices', flat_statevar_indices) trial_matrix = global_grid.X.values[np.index_exp[flat_statevar_indices + (trial_simplices.reshape(-1, trial_simplices.shape[-1]).T,)]] trial_matrix = np.rollaxis(trial_matrix, 0, -1) #print('trial_matrix', trial_matrix) # Partially ravel the array to make indexing operations easier trial_matrix.shape = (-1,) + trial_matrix.shape[-2:] # We have to filter out degenerate simplices before # phase fraction computation # This is because even one degenerate simplex causes the entire tensor # to be singular nondegenerate_indices = np.all(np.linalg.svd(trial_matrix, compute_uv=False) > 1e-12, axis=-1, keepdims=True) # Determine how many trial simplices remain for each target point. # In principle this would always be one simplex per point, but once # some target values reach equilibrium, trial_points starts # to contain points already on our best guess simplex. # This causes trial_simplices to create degenerate simplices. # We can safely filter them out since those target values are # already at equilibrium. sum_array = np.sum(nondegenerate_indices, axis=-1, dtype=np.int) index_array = np.repeat(np.arange(trial_matrix.shape[0], dtype=np.int), sum_array) comp_shape = trial_simplices.shape[:len(indep_conds)+len(pot_conds)] + \ (comp_values.shape[0], trial_simplices.shape[-2]) comp_indices = np.unravel_index(index_array, comp_shape)[len(indep_conds)+len(pot_conds)] fractions = np.linalg.solve(np.swapaxes(trial_matrix[index_array], -2, -1), comp_values[comp_indices]) # A simplex only contains a point if its barycentric coordinates # (phase fractions) are positive. bounding_indices = np.all(fractions >= 0, axis=-1) index_array = np.atleast_1d(index_array[bounding_indices]) raveled_simplices = trial_simplices.reshape((-1,) + trial_simplices.shape[-1:]) candidate_simplices = raveled_simplices[index_array, :] #print('candidate_simplices', candidate_simplices) # We need to convert the flat index arrays into multi-index tuples. # These tuples will tell us which state variable combinations are relevant # for the calculation. We can drop the last dimension, 'trial'. #print('trial_simplices.shape[:-1]', trial_simplices.shape[:-1]) statevar_indices = np.unravel_index(index_array, trial_simplices.shape[:-1] )[:len(indep_conds)+len(pot_conds)] aligned_energies = global_grid.GM.values[statevar_indices + (candidate_simplices.T,)].T statevar_indices = tuple(x[..., np.newaxis] for x in statevar_indices) #print('statevar_indices', statevar_indices) aligned_compositions = global_grid.X.values[np.index_exp[statevar_indices + (candidate_simplices,)]] #print('aligned_compositions', aligned_compositions) #print('aligned_energies', aligned_energies) candidate_potentials = np.linalg.solve(aligned_compositions.astype(np.float, copy=False), aligned_energies.astype(np.float, copy=False)) #print('candidate_potentials', candidate_potentials) logger.debug('candidate_simplices: %s', candidate_simplices) comp_indices = np.unravel_index(index_array, comp_shape)[len(indep_conds)+len(pot_conds)] #print('comp_values[comp_indices]', comp_values[comp_indices]) candidate_energies = np.multiply(candidate_potentials, comp_values[comp_indices]).sum(axis=-1) #print('candidate_energies', candidate_energies) # Generate a matrix of energies comparing our calculations for this iteration # to each other. # 'conditions' axis followed by a 'trial' axis # Empty values are filled in with infinity comparison_matrix = np.empty([trial_matrix.shape[0] / trial_shape[0], trial_shape[0]]) assert comparison_matrix.shape[0] == aligned_compositions.shape[0] comparison_matrix.fill(np.inf) comparison_matrix[np.divide(index_array, trial_shape[0]).astype(np.int), np.mod(index_array, trial_shape[0])] = candidate_energies #print('comparison_matrix', comparison_matrix) # If a condition point is all infinities, it means we did not calculate it # We should filter those out from any comparisons calculated_indices = ~np.all(comparison_matrix == np.inf, axis=-1) # Extract indices for trials with the lowest energy for each target point lowest_energy_indices = np.argmin(comparison_matrix[calculated_indices], axis=-1) # Filter conditions down to only those calculated this iteration calculated_conditions_indices = np.arange(comparison_matrix.shape[0])[calculated_indices] #print('comparison_matrix[calculated_conditions_indices,lowest_energy_indices]',comparison_matrix[calculated_conditions_indices, # lowest_energy_indices]) is_lower_energy = comparison_matrix[calculated_conditions_indices, lowest_energy_indices] < \ result_array['GM'].values.flat[calculated_conditions_indices] #print('is_lower_energy', is_lower_energy) # These are the conditions we will update this iteration final_indices = calculated_conditions_indices[is_lower_energy] #print('final_indices', final_indices) # Convert to multi-index form so we can index the result array final_multi_indices = np.unravel_index(final_indices, result_array['GM'].values.shape) updated_potentials = candidate_potentials[is_lower_energy] result_array['points'].values[final_multi_indices] = candidate_simplices[is_lower_energy] result_array['GM'].values[final_multi_indices] = candidate_energies[is_lower_energy] result_array['MU'].values[final_multi_indices] = updated_potentials result_array['NP'].values[final_multi_indices] = \ fractions[bounding_indices][is_lower_energy] #print('result_array.GM.values', result_array.GM.values) # By profiling, it's faster to recompute all driving forces in-place # versus doing fancy indexing to only update "changed" driving forces # This avoids the creation of expensive temporaries np.einsum('...i,...i', result_array.MU.values[..., np.newaxis, :], global_grid.X.values[np.index_exp[...] + ((np.newaxis,) * len(comp_conds)) + np.index_exp[:, :]], out=driving_forces) np.subtract(driving_forces, global_grid.GM.values[np.index_exp[...] + ((np.newaxis,) * len(comp_conds)) + np.index_exp[:]], out=driving_forces) # Update trial points to choose points with largest remaining driving force trial_points = np.argmax(driving_forces, axis=-1) #print('trial_points', trial_points) logger.debug('trial_points: %s', trial_points) # If all driving force (within some tolerance) is consumed, we found equilibrium if np.all(driving_forces <= DRIVING_FORCE_TOLERANCE): return #raise ValueError print('Iterations exceeded. Remaining driving force: ', driving_forces.max()) logger.error('Iterations exceeded')
def minimize(self, simplex, phase_fractions=None): """ Accept a list of simplex vertices and return the values of the variables that minimize the energy under the constraints. """ # Generate phase fraction variables # Track the multiplicity of phases with a Counter object composition_sets = Counter() all_variables = [] # starting point x_0 = [] # scaling factor -- set to minimum energy of starting simplex # Scaling the objective to be of order '10' seems to result in # sufficient precision (at least 5 significant figures). scaling_factor = abs(simplex['GM'].min()) / 10.0 # a list of tuples for where each phase's variable indices # start and end index_ranges = [] #print(list(enumerate(simplex.iterrows()))) #print((simplex.iterrows())) #print('END') for m_idx, vertex in enumerate(simplex.iterrows()): vertex = vertex[1] # increase multiplicity by one composition_sets[vertex['Phase']] += 1 # create new phase fraction variable all_variables.append( v.PhaseFraction(vertex['Phase'], composition_sets[vertex['Phase']]) ) start = len(x_0) # default position is centroid of the simplex if phase_fractions is None: x_0.append(1.0/len(list(simplex.iterrows()))) else: # use the provided guess for the phase fraction x_0.append(phase_fractions[m_idx]) # add site fraction variables all_variables.extend(self._variables[vertex['Phase']]) # add starting point for variable for varname in self._variables[vertex['Phase']]: x_0.append(vertex[str(varname)]) index_ranges.append([start, len(x_0)]) # Create master objective function def obj(input_x): "Objective function. Takes x vector as input. Returns scalar." objective = 0.0 for idx, vertex in enumerate(simplex.iterrows()): vertex = vertex[1] cur_x = input_x[index_ranges[idx][0]+1:index_ranges[idx][1]] #print('Phase: '+vertex['Phase']+' '+str(cur_x)) # phase fraction times value of objective for that phase objective += input_x[index_ranges[idx][0]] * \ self._phase_callables[vertex['Phase']]( *list(cur_x)) return objective / scaling_factor # Create master gradient function def gradient(input_x): "Accepts input vector and returns gradient vector." gradient = np.zeros(len(input_x)) for idx, vertex in enumerate(simplex.iterrows()): vertex = vertex[1] cur_x = input_x[index_ranges[idx][0]+1:index_ranges[idx][1]] #print('grad cur_x: '+str(cur_x)) # phase fraction derivative is just the phase energy gradient[index_ranges[idx][0]] = \ self._phase_callables[vertex['Phase']]( *list(cur_x)) # gradient for particular phase's variables # NOTE: We assume all phase d.o.f are independent here, # and we handle any coupling through the constraints for g_idx, grad in \ enumerate(self._gradient_callables[vertex['Phase']]): gradient[index_ranges[idx][0]+1+g_idx] = \ input_x[index_ranges[idx][0]] * \ grad(*list(cur_x)) #print('grad: '+str(gradient / scaling_factor)) return gradient / scaling_factor # Generate constraint sequence constraints = [] # phase fraction constraint def phasefrac_cons(input_x): "Accepts input vector and returns phase fraction constraint." output = 1.0 - sum([input_x[i[0]] for i in index_ranges])#** 2 return output def phasefrac_jac(input_x): "Accepts input vector and returns Jacobian of constraint." output_x = np.zeros(len(input_x)) for idx_range in index_ranges: output_x[idx_range[0]] = -1.0 #\ # -2.0*sum([input_x[i[0]] for i in index_ranges]) return output_x phasefrac_dict = dict() phasefrac_dict['type'] = 'eq' phasefrac_dict['fun'] = phasefrac_cons phasefrac_dict['jac'] = phasefrac_jac constraints.append(phasefrac_dict) # bounds constraint def nonneg_cons(input_x, idx): "Accepts input vector and returns non-negativity constraint." output = input_x[idx] #print('nonneg_cons: '+str(output)) return output def nonneg_jac(input_x, idx): "Accepts input vector and returns Jacobian of constraint." output_x = np.zeros(len(input_x)) output_x[idx] = 1.0 return output_x for idx in range(len(all_variables)): nonneg_dict = dict() nonneg_dict['type'] = 'ineq' nonneg_dict['fun'] = nonneg_cons nonneg_dict['jac'] = nonneg_jac nonneg_dict['args'] = [idx] constraints.append(nonneg_dict) # Generate all site fraction constraints for idx_range in index_ranges: # need to generate constraint for each sublattice dofs = self._sublattice_dof[all_variables[idx_range[0]].phase_name] cur_idx = idx_range[0]+1 for dof in dofs: sitefrac_dict = dict() sitefrac_dict['type'] = 'eq' sitefrac_dict['fun'] = sitefrac_cons sitefrac_dict['jac'] = sitefrac_jac sitefrac_dict['args'] = [[cur_idx, cur_idx+dof]] cur_idx += dof if dof > 0: constraints.append(sitefrac_dict) # All other constraints, e.g., mass balance def molefrac_cons(input_x, species, fix_val, all_variables, phases): """ Accept input vector, species and fixed value. Returns constraint. """ output = -fix_val for idx, vertex in enumerate(simplex.iterrows()): vertex = vertex[1] cur_x = input_x[index_ranges[idx][0]+1:index_ranges[idx][1]] res = self._molefrac_callables[vertex['Phase']][species](*cur_x) output += input_x[index_ranges[idx][0]] * res #print('molefrac_cons: '+str(output)) return output def molefrac_jac(input_x, species, fix_val, all_variables, phases): "Accepts input vector and returns Jacobian vector." output_x = np.zeros(len(input_x)) for idx, vertex in enumerate(simplex.iterrows()): vertex = vertex[1] cur_x = input_x[index_ranges[idx][0]+1:index_ranges[idx][1]] output_x[index_ranges[idx][0]] = \ self._molefrac_callables[vertex['Phase']][species](*cur_x) for g_idx, grad in \ enumerate(self._molefrac_jac_callables[vertex['Phase']][species]): output_x[index_ranges[idx][0]+1+g_idx] = \ input_x[index_ranges[idx][0]] * \ grad(*list(cur_x)) #print('molefrac_jac '+str(output_x)) return output_x eqs = len([x for x in constraints if x['type'] == 'eq']) if eqs < len(x_0): for condition, value in self.conditions.items(): if isinstance(condition, v.Composition): # mass balance constraint for mole fraction molefrac_dict = dict() molefrac_dict['type'] = 'eq' molefrac_dict['fun'] = molefrac_cons molefrac_dict['jac'] = molefrac_jac molefrac_dict['args'] = \ [condition.species, value, all_variables, self._phases] constraints.append(molefrac_dict) else: logger.warning("""Dropping mass balance constraints due to zero internal degrees of freedom""") # Run optimization res = scipy.optimize.minimize(obj, x_0, method='slsqp', jac=gradient,\ constraints=constraints, options={'maxiter': 1000}) # rescale final values back to original res['raw_fun'] = copy.deepcopy(res['fun']) res['raw_jac'] = copy.deepcopy(res['jac']) res['raw_x'] = copy.deepcopy(res['x']) res['fun'] *= scaling_factor res['jac'] *= scaling_factor # force tiny numerical values to be positive res['x'] = np.maximum(res['x'], np.zeros(1, dtype=np.float64)) logger.debug(res) if not res['success']: logger.error('Energy minimization failed') return None # Build result object eq_res = EquilibriumResult(self._phases, self.components, self.statevars, res['fun'], zip(all_variables, res['x'])) return eq_res