From ab6565a99f70268f39b8955d4c23f273c0fd983d Mon Sep 17 00:00:00 2001 From: Brandon Bocklund Date: Mon, 7 Sep 2026 14:40:01 -0700 Subject: [PATCH 1/4] FIX: _update_points appended state variables instead of site fractions Since the Workspace integration (0.3.0), _update_points sliced compset.dof[:sum(dof)], but CompositionSet.dof is laid out as [state variables..., site fractions...], so the leading N/P/T values were taken as the equilibrium point. After per-sublattice normalization in local_sample the adaptive points were nowhere near the actual equilibrium constitution. Skip the state variables when extracting the equilibrium site fractions and add regression tests for _update_points. --- CHANGES.rst | 4 ++++ scheil/simulate.py | 4 +++- tests/test_update_points.py | 45 +++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/test_update_points.py diff --git a/CHANGES.rst b/CHANGES.rst index 60dee6c..5efd7fe 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,10 @@ Changelog ========= +Unreleased +========== +* Bug fix for adaptive sampling: ``_update_points`` was appending the state variables (N, P, T) instead of the equilibrium site fractions since the Workspace integration in 0.3.0, so locally sampled points were not centered on the equilibrium constitution. + 0.3.0 (2025-11-03) ================== * Better support for the PyCalphad Workspace API, which allows for requesting custom Workspace property outputs for both Scheil and equilibrium solidification. Note that these may require post-processing to use meaningfully. (:issue:`39`) diff --git a/scheil/simulate.py b/scheil/simulate.py index e3919aa..4292fa2 100644 --- a/scheil/simulate.py +++ b/scheil/simulate.py @@ -51,7 +51,9 @@ def _update_points(wks, points_dict, dof_dict, local_pdens=0, verbose=False): if verbose: print(f'Adding points to {phase_name}. ', end='') dof = dof_dict[phase_name] - eq_pts = np.asarray(compset.dof[:sum(dof)]).reshape(1, -1) + # compset.dof is laid out as [state variables..., site fractions...] + num_statevars = compset.phase_record.num_statevars + eq_pts = np.asarray(compset.dof[num_statevars:num_statevars + sum(dof)]).reshape(1, -1) if local_pdens > 0: points_dict[phase_name] = np.concatenate([pts, local_sample(eq_pts, dof, pdens=local_pdens)], axis=0) else: diff --git a/tests/test_update_points.py b/tests/test_update_points.py new file mode 100644 index 0000000..4583230 --- /dev/null +++ b/tests/test_update_points.py @@ -0,0 +1,45 @@ +import numpy as np +from pycalphad import Workspace, variables as v +from scheil.simulate import _update_points +from fixtures import select_database, load_database + + +def _setup(dbf): + """Return a point-equilibrium Workspace and matching empty points/dof dicts.""" + wks = Workspace(dbf, ['AL', 'ZN', 'VA'], ['LIQUID', 'FCC_A1', 'HCP_A3'], + {v.T: 700, v.P: 101325, v.N: 1, v.X('ZN'): 0.3}) + dof_dict = {ph: list(map(len, mod.constituents)) for ph, mod in wks.models.items()} + points_dict = {ph: np.empty((0, sum(dof))) for ph, dof in dof_dict.items()} + return wks, points_dict, dof_dict + + +@select_database("alzn_mey.tdb") +def test_update_points_appends_equilibrium_site_fractions(load_database): + """The point appended by _update_points must be the equilibrium site fractions, not the state variables.""" + wks, points_dict, dof_dict = _setup(load_database()) + # Single-phase FCC_A1 region for this condition + assert [cs.phase_record.phase_name for cs in wks.get_composition_sets()] == ['FCC_A1'] + + _update_points(wks, points_dict, dof_dict, local_pdens=0) + + assert points_dict['FCC_A1'].shape == (1, 2) + expected = [wks.get(v.Y('FCC_A1', 0, 'AL')), wks.get(v.Y('FCC_A1', 0, 'ZN'))] + np.testing.assert_allclose(points_dict['FCC_A1'][0], expected, atol=1e-8) + # Other phases were not stable, so nothing should be appended + assert points_dict['LIQUID'].shape == (0, 2) + assert points_dict['HCP_A3'].shape == (0, 2) + + +@select_database("alzn_mey.tdb") +def test_update_points_local_samples_are_valid_site_fractions(load_database): + """Locally sampled points must be valid site fractions and include the equilibrium point itself.""" + wks, points_dict, dof_dict = _setup(load_database()) + + _update_points(wks, points_dict, dof_dict, local_pdens=20) + + pts = points_dict['FCC_A1'] + assert pts.shape == (21, 2) # 20 local samples + the equilibrium point + assert np.all(pts >= 0) and np.all(pts <= 1) + np.testing.assert_allclose(pts.sum(axis=1), 1.0) + expected = [wks.get(v.Y('FCC_A1', 0, 'AL')), wks.get(v.Y('FCC_A1', 0, 'ZN'))] + assert np.any(np.all(np.isclose(pts, expected, atol=1e-8), axis=1)) From f85925267e7634f89b578422d10e7e417289b8d7 Mon Sep 17 00:00:00 2001 From: Brandon Bocklund Date: Mon, 7 Sep 2026 14:55:53 -0700 Subject: [PATCH 2/4] CHANGELOG -> Release Notes --- CHANGES.rst | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5efd7fe..c304ce7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,10 +1,6 @@ -========= -Changelog -========= - -Unreleased -========== -* Bug fix for adaptive sampling: ``_update_points`` was appending the state variables (N, P, T) instead of the equilibrium site fractions since the Workspace integration in 0.3.0, so locally sampled points were not centered on the equilibrium constitution. +============= +Release Notes +============= 0.3.0 (2025-11-03) ================== From 5e3ce5156d994c58019f977847837615ff4e0b16 Mon Sep 17 00:00:00 2001 From: Brandon Bocklund Date: Mon, 7 Sep 2026 14:57:15 -0700 Subject: [PATCH 3/4] delete comment --- scheil/simulate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scheil/simulate.py b/scheil/simulate.py index 4292fa2..f143e1a 100644 --- a/scheil/simulate.py +++ b/scheil/simulate.py @@ -51,7 +51,6 @@ def _update_points(wks, points_dict, dof_dict, local_pdens=0, verbose=False): if verbose: print(f'Adding points to {phase_name}. ', end='') dof = dof_dict[phase_name] - # compset.dof is laid out as [state variables..., site fractions...] num_statevars = compset.phase_record.num_statevars eq_pts = np.asarray(compset.dof[num_statevars:num_statevars + sum(dof)]).reshape(1, -1) if local_pdens > 0: From 0bf18304865ae990b2307edf21186009318ea699 Mon Sep 17 00:00:00 2001 From: Brandon Bocklund Date: Mon, 7 Sep 2026 14:59:31 -0700 Subject: [PATCH 4/4] Fix test --- tests/test_update_points.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/tests/test_update_points.py b/tests/test_update_points.py index 4583230..93682cb 100644 --- a/tests/test_update_points.py +++ b/tests/test_update_points.py @@ -4,19 +4,14 @@ from fixtures import select_database, load_database -def _setup(dbf): - """Return a point-equilibrium Workspace and matching empty points/dof dicts.""" - wks = Workspace(dbf, ['AL', 'ZN', 'VA'], ['LIQUID', 'FCC_A1', 'HCP_A3'], - {v.T: 700, v.P: 101325, v.N: 1, v.X('ZN'): 0.3}) - dof_dict = {ph: list(map(len, mod.constituents)) for ph, mod in wks.models.items()} - points_dict = {ph: np.empty((0, sum(dof))) for ph, dof in dof_dict.items()} - return wks, points_dict, dof_dict - - @select_database("alzn_mey.tdb") def test_update_points_appends_equilibrium_site_fractions(load_database): """The point appended by _update_points must be the equilibrium site fractions, not the state variables.""" - wks, points_dict, dof_dict = _setup(load_database()) + dbf = load_database() + wks = Workspace(dbf, ['AL', 'ZN', 'VA'], ['LIQUID', 'FCC_A1', 'HCP_A3'], {v.T: 700, v.P: 101325, v.N: 1, v.X('ZN'): 0.3}) + dof_dict = {ph: list(map(len, mod.constituents)) for ph, mod in wks.models.items()} + points_dict = {ph: np.empty((0, sum(dof))) for ph, dof in dof_dict.items()} + # Single-phase FCC_A1 region for this condition assert [cs.phase_record.phase_name for cs in wks.get_composition_sets()] == ['FCC_A1'] @@ -33,7 +28,11 @@ def test_update_points_appends_equilibrium_site_fractions(load_database): @select_database("alzn_mey.tdb") def test_update_points_local_samples_are_valid_site_fractions(load_database): """Locally sampled points must be valid site fractions and include the equilibrium point itself.""" - wks, points_dict, dof_dict = _setup(load_database()) + dbf = load_database() + wks = Workspace(dbf, ['AL', 'ZN', 'VA'], ['LIQUID', 'FCC_A1', 'HCP_A3'], {v.T: 700, v.P: 101325, v.N: 1, v.X('ZN'): 0.3}) + dof_dict = {ph: list(map(len, mod.constituents)) for ph, mod in wks.models.items()} + points_dict = {ph: np.empty((0, sum(dof))) for ph, dof in dof_dict.items()} + _update_points(wks, points_dict, dof_dict, local_pdens=20)