From 7cff49fd18037200de8b502b3928a409a33dbac5 Mon Sep 17 00:00:00 2001 From: Brandon Bocklund Date: Tue, 18 Aug 2026 12:12:24 -0700 Subject: [PATCH 1/2] Update fitting steps for per-formula-unit pycalphad Model AST pycalphad Model contributions (Model.ast and Model.models) are now built per mole of formula units, with GM = ast / _site_ratio_normalization. Remove the mole-atoms to mole-formula conversions that would now double count the normalization, subtract per-formula model attributes (e.g. V0) after converting data to per-formula units, and normalize V0 where molar values are needed. Convert the hard-coded per-mole-atoms GM fixture in the response vector test at hot-patch time. --- espei/parameter_selection/fitting_steps.py | 22 +++++++++++++++------- tests/test_parameter_generation_utils.py | 4 +++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/espei/parameter_selection/fitting_steps.py b/espei/parameter_selection/fitting_steps.py index 84d7210e..6559b359 100644 --- a/espei/parameter_selection/fitting_steps.py +++ b/espei/parameter_selection/fitting_steps.py @@ -124,7 +124,11 @@ def shift_reference_state(cls, desired_data: [Dataset], fixed_model: Model) -> A # state (i.e. where the property of interest is zero for all # the endmembers). To shift our VM_MIX data into VM, we need # to add the VM computed from the endmember reference model. - values[..., config_idx] += getattr(fixed_model.endmember_reference_model, cls.data_types_read) + em_prop = getattr(fixed_model.endmember_reference_model, cls.data_types_read) + if cls.data_types_read == "V0": + # Model.V0 is per mole-formula, but datasets are per mole-atoms + em_prop = em_prop / fixed_model.endmember_reference_model._site_ratio_normalization + values[..., config_idx] += em_prop else: pass total_response.append(values.flatten()) @@ -141,10 +145,11 @@ def get_response_vector(cls, fixed_model: Model, fixed_portions: [symengine.Basi # do anything too special here. # subtract off lower order contributions. for i in range(rhs.shape[0]): - rhs[i] -= getattr(fixed_model, cls.parameter_name) if cls.normalize_parameter_per_mole_formula: # Convert the quantity per-mole-atoms to per-mole-formula rhs[i] *= mole_atoms_per_mole_formula_unit + # model parameter attributes (e.g. V0) are already per-mole-formula + rhs[i] -= getattr(fixed_model, cls.parameter_name) # Previous steps may have introduced some symbolic terms. # Now we remove all the symbols: @@ -229,11 +234,12 @@ def shift_reference_state(cls, desired_data: [Dataset], fixed_model: Model, mole if occupancy is None: raise ValueError('Cannot have a _MIX property without sublattice occupancies.') else: - values[..., config_idx] += cls.transform_feature(fixed_model.models['ref'])*mole_atoms_per_mole_formula_unit + # Model contributions are already per mole-formula + values[..., config_idx] += cls.transform_feature(fixed_model.models['ref']) else: raise ValueError(f'Unknown property to shift: {dataset["output"]}') for excluded_contrib in unique_excluded_contributions: - values[..., config_idx] += cls.transform_feature(fixed_model.models[excluded_contrib])*mole_atoms_per_mole_formula_unit + values[..., config_idx] += cls.transform_feature(fixed_model.models[excluded_contrib]) total_response.append(values.flatten()) return total_response @@ -253,8 +259,8 @@ def get_response_vector(cls, fixed_model: Model, fixed_portions: [symengine.Basi site_fractions = list(itertools.chain(*site_fractions)) data_qtys = np.concatenate(cls.shift_reference_state(data, fixed_model, mole_atoms_per_mole_formula_unit), axis=-1) - # Remove existing partial model contributions from the data, convert to per mole-formula units - data_qtys = data_qtys - cls.transform_feature(fixed_model.ast)*mole_atoms_per_mole_formula_unit + # Remove existing partial model contributions from the data (Model.ast is already per mole-formula) + data_qtys = data_qtys - cls.transform_feature(fixed_model.ast) # Subtract out high-order (in T) parameters we've already fit, already in per mole-formula units data_qtys = data_qtys - cls.transform_feature(sum(fixed_portions)) # If any site fractions show up in our rhs that aren't in these @@ -313,8 +319,10 @@ def transform_data(d: ArrayLike, model: Model) -> ArrayLike: # np.object_ # \[ V_A = \log(VM / V_0) \] # cast to object_ because the real type may become a symengine.Expr d = np.asarray(d, dtype=np.object_) + # Model.V0 is per mole-formula; VM data are per mole-atoms + V0_molar = model.V0 / model._site_ratio_normalization for i in range(d.shape[0]): - d[i] = symengine.log(d[i] / model.V0) + d[i] = symengine.log(d[i] / V0_molar) return d @classmethod diff --git a/tests/test_parameter_generation_utils.py b/tests/test_parameter_generation_utils.py index 8e1cbeef..dac58f58 100644 --- a/tests/test_parameter_generation_utils.py +++ b/tests/test_parameter_generation_utils.py @@ -97,7 +97,9 @@ def test_get_response_vector_AL_NI_VA_interaction(): """) mod = Model(dbf, ['AL', 'NI', 'VA'], 'BCC_B2') dd = {ky: 0.0 for ky in mod.models.keys()} - dd['GM'] = NEW_GM + # NEW_GM is a per-mole-atoms energy printed from an older model, but Model + # contributions are per-mole-formula, so convert it when hot patching + dd['GM'] = NEW_GM * mod._site_ratio_normalization mod.models = dd print(mod.HM) config_tup = (('AL',), ('NI', 'VA'), ('VA',)) From 70392768e2adb44b83211af2f861f57798c93400 Mon Sep 17 00:00:00 2001 From: Brandon Bocklund Date: Sat, 19 Sep 2026 15:34:02 -0700 Subject: [PATCH 2/2] Cleanup irrelevant volume changes --- espei/parameter_selection/fitting_steps.py | 16 ++++------------ tests/test_parameter_generation_utils.py | 4 +--- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/espei/parameter_selection/fitting_steps.py b/espei/parameter_selection/fitting_steps.py index 6559b359..696cf8ae 100644 --- a/espei/parameter_selection/fitting_steps.py +++ b/espei/parameter_selection/fitting_steps.py @@ -124,11 +124,7 @@ def shift_reference_state(cls, desired_data: [Dataset], fixed_model: Model) -> A # state (i.e. where the property of interest is zero for all # the endmembers). To shift our VM_MIX data into VM, we need # to add the VM computed from the endmember reference model. - em_prop = getattr(fixed_model.endmember_reference_model, cls.data_types_read) - if cls.data_types_read == "V0": - # Model.V0 is per mole-formula, but datasets are per mole-atoms - em_prop = em_prop / fixed_model.endmember_reference_model._site_ratio_normalization - values[..., config_idx] += em_prop + values[..., config_idx] += getattr(fixed_model.endmember_reference_model, cls.data_types_read) else: pass total_response.append(values.flatten()) @@ -145,11 +141,10 @@ def get_response_vector(cls, fixed_model: Model, fixed_portions: [symengine.Basi # do anything too special here. # subtract off lower order contributions. for i in range(rhs.shape[0]): + rhs[i] -= getattr(fixed_model, cls.parameter_name) if cls.normalize_parameter_per_mole_formula: # Convert the quantity per-mole-atoms to per-mole-formula rhs[i] *= mole_atoms_per_mole_formula_unit - # model parameter attributes (e.g. V0) are already per-mole-formula - rhs[i] -= getattr(fixed_model, cls.parameter_name) # Previous steps may have introduced some symbolic terms. # Now we remove all the symbols: @@ -234,7 +229,6 @@ def shift_reference_state(cls, desired_data: [Dataset], fixed_model: Model, mole if occupancy is None: raise ValueError('Cannot have a _MIX property without sublattice occupancies.') else: - # Model contributions are already per mole-formula values[..., config_idx] += cls.transform_feature(fixed_model.models['ref']) else: raise ValueError(f'Unknown property to shift: {dataset["output"]}') @@ -259,7 +253,7 @@ def get_response_vector(cls, fixed_model: Model, fixed_portions: [symengine.Basi site_fractions = list(itertools.chain(*site_fractions)) data_qtys = np.concatenate(cls.shift_reference_state(data, fixed_model, mole_atoms_per_mole_formula_unit), axis=-1) - # Remove existing partial model contributions from the data (Model.ast is already per mole-formula) + # Remove existing partial model contributions from the data data_qtys = data_qtys - cls.transform_feature(fixed_model.ast) # Subtract out high-order (in T) parameters we've already fit, already in per mole-formula units data_qtys = data_qtys - cls.transform_feature(sum(fixed_portions)) @@ -319,10 +313,8 @@ def transform_data(d: ArrayLike, model: Model) -> ArrayLike: # np.object_ # \[ V_A = \log(VM / V_0) \] # cast to object_ because the real type may become a symengine.Expr d = np.asarray(d, dtype=np.object_) - # Model.V0 is per mole-formula; VM data are per mole-atoms - V0_molar = model.V0 / model._site_ratio_normalization for i in range(d.shape[0]): - d[i] = symengine.log(d[i] / V0_molar) + d[i] = symengine.log(d[i] / model.V0) return d @classmethod diff --git a/tests/test_parameter_generation_utils.py b/tests/test_parameter_generation_utils.py index dac58f58..fe798742 100644 --- a/tests/test_parameter_generation_utils.py +++ b/tests/test_parameter_generation_utils.py @@ -97,9 +97,7 @@ def test_get_response_vector_AL_NI_VA_interaction(): """) mod = Model(dbf, ['AL', 'NI', 'VA'], 'BCC_B2') dd = {ky: 0.0 for ky in mod.models.keys()} - # NEW_GM is a per-mole-atoms energy printed from an older model, but Model - # contributions are per-mole-formula, so convert it when hot patching - dd['GM'] = NEW_GM * mod._site_ratio_normalization + dd['G'] = NEW_GM * mod._site_ratio_normalization mod.models = dd print(mod.HM) config_tup = (('AL',), ('NI', 'VA'), ('VA',))