From ae741ef365481b80be565f2a0c48ec1b90a866a2 Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Fri, 30 Jan 2026 09:37:51 +0000 Subject: [PATCH 01/12] add iron benchmarks --- .../analyse_iron_properties.py | 369 +++++++ .../physicality/iron_properties/metrics.yml | 101 ++ .../iron_properties/app_iron_properties.py | 105 ++ .../iron_properties/calc_iron_properties.py | 865 +++++++++++++++ ml_peg/calcs/utils/iron_utils.py | 982 ++++++++++++++++++ 5 files changed, 2422 insertions(+) create mode 100644 ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py create mode 100644 ml_peg/analysis/physicality/iron_properties/metrics.yml create mode 100644 ml_peg/app/physicality/iron_properties/app_iron_properties.py create mode 100644 ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py create mode 100644 ml_peg/calcs/utils/iron_utils.py diff --git a/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py new file mode 100644 index 000000000..87326a793 --- /dev/null +++ b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py @@ -0,0 +1,369 @@ +"""Analyse BCC iron properties benchmark. + +This analysis combines EOS, elastic, Bain path, defect, surface, stacking fault, +dislocation, and fracture properties. + +Reference +--------- +Zhang, L., Csányi, G., van der Giessen, E., & Maresca, F. (2023). +Efficiency, Accuracy, and Transferability of Machine Learning Potentials: +Application to Dislocations and Cracks in Iron. +arXiv:2307.10072. https://arxiv.org/abs/2307.10072 +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +import pytest + +from ml_peg.analysis.utils.decorators import build_table +from ml_peg.analysis.utils.utils import load_metrics_config +from ml_peg.app import APP_ROOT +from ml_peg.calcs import CALCS_ROOT +from ml_peg.models.get_models import get_model_names +from ml_peg.models.models import current_models + +MODELS = get_model_names(current_models) +CALC_PATH = CALCS_ROOT / "physicality" / "iron_properties" / "outputs" +OUT_PATH = APP_ROOT / "data" / "physicality" / "iron_properties" + +METRICS_CONFIG_PATH = Path(__file__).with_name("metrics.yml") +DEFAULT_THRESHOLDS, DEFAULT_TOOLTIPS, _ = load_metrics_config(METRICS_CONFIG_PATH) + +# DFT reference values +DFT_REFERENCE = { + # EOS properties + 'a0': 2.831, # Lattice parameter (Å) + 'B0': 178.0, # Bulk modulus (GPa) + 'E_bcc_fcc': 83.5, # BCC-FCC energy difference (meV/atom) + # Defect properties + 'E_vac': 2.02, # Vacancy formation energy (eV) + 'gamma_100': 2.41, # Surface energy (J/m²) + 'gamma_110': 2.37, + 'gamma_111': 2.58, + 'gamma_112': 2.48, + 'gamma_us_110': 0.75, # Unstable SFE (J/m²) + 'gamma_us_112': 1.12, + # Dislocation properties (approximate) + 'core_energy_screw_111': 1.8, # eV + 'core_energy_edge_111_110': 2.2, # eV + # Crack K_Griffith (MPa*sqrt(m)) + 'K_Griffith_1': 1.05, + 'K_Griffith_2': 1.02, + 'K_Griffith_3': 0.98, + 'K_Griffith_4': 0.95, +} + +# Dislocation type mapping +DISLOCATION_NAMES = { + 'edge_100_010': 'Edge a0[100](010)', + 'edge_100_011': 'Edge a0[100](011)', + 'edge_111_110': 'Edge a0/2[111](110)', + 'mixed_111': 'Mixed 70.5° a0/2[111](110)', + 'screw_111': 'Screw a0/2[111](112)', +} + + +def load_model_results(model_name: str) -> dict[str, Any] | None: + """Load iron properties results for a model.""" + json_path = CALC_PATH / model_name / "results.json" + if not json_path.exists(): + return None + return json.loads(json_path.read_text()) + + +def load_eos_curve(model_name: str) -> pd.DataFrame: + """Load EOS curve data for a model.""" + csv_path = CALC_PATH / model_name / "eos_curve.csv" + if not csv_path.exists(): + return pd.DataFrame() + return pd.read_csv(csv_path) + + +def load_bain_curve(model_name: str) -> pd.DataFrame: + """Load Bain path curve data for a model.""" + csv_path = CALC_PATH / model_name / "bain_path.csv" + if not csv_path.exists(): + return pd.DataFrame() + return pd.read_csv(csv_path) + + +def load_sfe_110_curve(model_name: str) -> pd.DataFrame: + """Load SFE 110 curve data for a model.""" + csv_path = CALC_PATH / model_name / "sfe_110_curve.csv" + if not csv_path.exists(): + return pd.DataFrame() + return pd.read_csv(csv_path) + + +def load_sfe_112_curve(model_name: str) -> pd.DataFrame: + """Load SFE 112 curve data for a model.""" + csv_path = CALC_PATH / model_name / "sfe_112_curve.csv" + if not csv_path.exists(): + return pd.DataFrame() + return pd.read_csv(csv_path) + + +def load_crack_ke_curve(model_name: str, crack_system: int) -> pd.DataFrame: + """Load crack K-E curve data for a model.""" + csv_path = CALC_PATH / model_name / f"crack_{crack_system}_KE.csv" + if not csv_path.exists(): + return pd.DataFrame() + return pd.read_csv(csv_path) + + +def compute_metrics(results: dict[str, Any]) -> dict[str, float]: + """Compute metrics from model results.""" + metrics: dict[str, float] = {} + + # ========================================================================== + # EOS metrics + # ========================================================================== + eos = results.get('eos', {}) + if 'a0' in eos: + a0_mlip = eos['a0'] + a0_error = abs(a0_mlip - DFT_REFERENCE['a0']) / DFT_REFERENCE['a0'] * 100 + metrics['a0 error (%)'] = a0_error + + if 'B0' in eos: + B0_mlip = eos['B0'] + B0_error = abs(B0_mlip - DFT_REFERENCE['B0']) / DFT_REFERENCE['B0'] * 100 + metrics['B0 error (%)'] = B0_error + + # ========================================================================== + # Bain path metrics + # ========================================================================== + bain = results.get('bain_path', {}) + if 'delta_E_meV' in bain: + E_bcc_fcc_mlip = bain['delta_E_meV'] + E_bcc_fcc_error = abs(E_bcc_fcc_mlip - DFT_REFERENCE['E_bcc_fcc']) + metrics['BCC-FCC ΔE error (meV)'] = E_bcc_fcc_error + + # ========================================================================== + # Elastic constants metrics + # ========================================================================== + elastic = results.get('elastic', {}) + if 'C11' in elastic: + metrics['C11 (GPa)'] = elastic['C11'] + if 'C12' in elastic: + metrics['C12 (GPa)'] = elastic['C12'] + if 'C44' in elastic: + metrics['C44 (GPa)'] = elastic['C44'] + + # ========================================================================== + # Vacancy metrics + # ========================================================================== + vacancy = results.get('vacancy', {}) + if 'E_vac' in vacancy: + E_vac_mlip = vacancy['E_vac'] + E_vac_error = abs(E_vac_mlip - DFT_REFERENCE['E_vac']) / DFT_REFERENCE['E_vac'] * 100 + metrics['E_vac error (%)'] = E_vac_error + + # ========================================================================== + # Surface energy metrics + # ========================================================================== + surfaces = results.get('surfaces', {}) + surface_errors = [] + + for surface in ['100', '110', '111', '112']: + key_mlip = f'gamma_{surface}' + if key_mlip in surfaces: + gamma_mlip = surfaces[key_mlip] + gamma_dft = DFT_REFERENCE[key_mlip] + error = abs(gamma_mlip - gamma_dft) + surface_errors.append(error) + + if surface_errors: + metrics['Surface MAE (J/m²)'] = np.mean(surface_errors) + + # ========================================================================== + # Stacking fault metrics + # ========================================================================== + sfe_110 = results.get('sfe_110', {}) + if 'max_sfe' in sfe_110: + max_sfe_110_mlip = sfe_110['max_sfe'] + max_sfe_110_error = abs(max_sfe_110_mlip - DFT_REFERENCE['gamma_us_110']) / DFT_REFERENCE['gamma_us_110'] * 100 + metrics['Max SFE 110 error (%)'] = max_sfe_110_error + + sfe_112 = results.get('sfe_112', {}) + if 'max_sfe' in sfe_112: + max_sfe_112_mlip = sfe_112['max_sfe'] + max_sfe_112_error = abs(max_sfe_112_mlip - DFT_REFERENCE['gamma_us_112']) / DFT_REFERENCE['gamma_us_112'] * 100 + metrics['Max SFE 112 error (%)'] = max_sfe_112_error + + # ========================================================================== + # Dislocation core energy metrics + # ========================================================================== + dislocations = results.get('dislocations', {}) + core_energies = [] + + for disl_type, disl_data in dislocations.items(): + if isinstance(disl_data, dict) and 'core_energy' in disl_data: + core_energies.append(disl_data['core_energy']) + metrics[f'Core E {DISLOCATION_NAMES.get(disl_type, disl_type)} (eV)'] = disl_data['core_energy'] + + if core_energies: + metrics['Mean core energy (eV)'] = np.mean(core_energies) + + # ========================================================================== + # Crack K-test metrics + # ========================================================================== + cracks = results.get('cracks', {}) + K_Griffith_values = [] + + for crack_sys, crack_data in cracks.items(): + if isinstance(crack_data, dict) and 'K_Griffith' in crack_data: + K_G = crack_data['K_Griffith'] + K_Griffith_values.append(K_G) + metrics[f'K_Griffith {crack_data.get("name", f"System {crack_sys}")} (MPa√m)'] = K_G + + if K_Griffith_values: + metrics['Mean K_Griffith (MPa√m)'] = np.mean(K_Griffith_values) + + return metrics + + +def _load_all_results() -> dict[str, dict[str, Any]]: + """Load results for all models.""" + all_results: dict[str, dict[str, Any]] = {} + for model_name in MODELS: + results = load_model_results(model_name) + if results is not None: + all_results[model_name] = results + return all_results + + +@pytest.fixture +def iron_eos_curves() -> dict[str, pd.DataFrame]: + """Load EOS curves for all models.""" + curves: dict[str, pd.DataFrame] = {} + for model_name in MODELS: + curve = load_eos_curve(model_name) + if not curve.empty: + curves[model_name] = curve + return curves + + +@pytest.fixture +def iron_bain_curves() -> dict[str, pd.DataFrame]: + """Load Bain path curves for all models.""" + curves: dict[str, pd.DataFrame] = {} + for model_name in MODELS: + curve = load_bain_curve(model_name) + if not curve.empty: + curves[model_name] = curve + return curves + + +@pytest.fixture +def iron_sfe_110_curves() -> dict[str, pd.DataFrame]: + """Load SFE 110 curves for all models.""" + curves: dict[str, pd.DataFrame] = {} + for model_name in MODELS: + curve = load_sfe_110_curve(model_name) + if not curve.empty: + curves[model_name] = curve + return curves + + +@pytest.fixture +def iron_sfe_112_curves() -> dict[str, pd.DataFrame]: + """Load SFE 112 curves for all models.""" + curves: dict[str, pd.DataFrame] = {} + for model_name in MODELS: + curve = load_sfe_112_curve(model_name) + if not curve.empty: + curves[model_name] = curve + return curves + + +@pytest.fixture +def iron_crack_curves() -> dict[str, dict[int, pd.DataFrame]]: + """Load crack K-E curves for all models.""" + curves: dict[str, dict[int, pd.DataFrame]] = {} + for model_name in MODELS: + model_curves: dict[int, pd.DataFrame] = {} + for crack_sys in [1, 2, 3, 4]: + curve = load_crack_ke_curve(model_name, crack_sys) + if not curve.empty: + model_curves[crack_sys] = curve + if model_curves: + curves[model_name] = model_curves + return curves + + +def collect_metrics() -> pd.DataFrame: + """Gather metrics for all models.""" + metrics_rows: list[dict[str, float | str]] = [] + + OUT_PATH.mkdir(parents=True, exist_ok=True) + + all_results = _load_all_results() + + for model_name, results in all_results.items(): + model_metrics = compute_metrics(results) + row = {"Model": model_name} | model_metrics + metrics_rows.append(row) + + columns = ["Model"] + list(DEFAULT_THRESHOLDS.keys()) + + return pd.DataFrame(metrics_rows).reindex(columns=columns) + + +@pytest.fixture +def iron_properties_collection() -> pd.DataFrame: + """Collect iron properties metrics across all models.""" + return collect_metrics() + + +@pytest.fixture +def iron_properties_metrics_dataframe( + iron_properties_collection: pd.DataFrame, +) -> pd.DataFrame: + """Provide the aggregated iron properties metrics dataframe.""" + return iron_properties_collection + + +@pytest.fixture +@build_table( + filename=OUT_PATH / "iron_properties_metrics_table.json", + metric_tooltips=DEFAULT_TOOLTIPS, + thresholds=DEFAULT_THRESHOLDS, + weights=None, +) +def metrics( + iron_properties_metrics_dataframe: pd.DataFrame, +) -> dict[str, dict]: + """ + Compute iron properties metrics for all models. + + Parameters + ---------- + iron_properties_metrics_dataframe + Aggregated per-model metrics. + + Returns + ------- + dict[str, dict] + Mapping of metric names to per-model results. + """ + metrics_df = iron_properties_metrics_dataframe + metrics_dict: dict[str, dict[str, float | None]] = {} + for column in metrics_df.columns: + if column == "Model": + continue + values = [ + value if pd.notna(value) else None for value in metrics_df[column].tolist() + ] + metrics_dict[column] = dict(zip(metrics_df["Model"], values, strict=False)) + return metrics_dict + + +def test_iron_properties(metrics: dict[str, dict]) -> None: + """Run iron properties analysis.""" + return diff --git a/ml_peg/analysis/physicality/iron_properties/metrics.yml b/ml_peg/analysis/physicality/iron_properties/metrics.yml new file mode 100644 index 000000000..cd8064340 --- /dev/null +++ b/ml_peg/analysis/physicality/iron_properties/metrics.yml @@ -0,0 +1,101 @@ +metrics: + # EOS properties + a0 error (%): + good: 0.0 + bad: 2.0 + unit: "%" + tooltip: "Lattice parameter error relative to DFT" + level_of_theory: PBE + B0 error (%): + good: 0.0 + bad: 20.0 + unit: "%" + tooltip: "Bulk modulus error relative to DFT" + level_of_theory: PBE + BCC-FCC ΔE error (meV): + good: 0.0 + bad: 50.0 + unit: "meV" + tooltip: "Error in BCC-FCC energy difference along Bain path" + level_of_theory: PBE + C11 (GPa): + good: 243.0 + bad: 150.0 + unit: "GPa" + tooltip: "Elastic constant C11" + level_of_theory: PBE + C12 (GPa): + good: 145.0 + bad: 100.0 + unit: "GPa" + tooltip: "Elastic constant C12" + level_of_theory: PBE + C44 (GPa): + good: 116.0 + bad: 80.0 + unit: "GPa" + tooltip: "Elastic constant C44 (shear modulus)" + level_of_theory: PBE + # Defect properties + E_vac error (%): + good: 0.0 + bad: 20.0 + unit: "%" + tooltip: "Vacancy formation energy error relative to DFT" + level_of_theory: PBE + Surface MAE (J/m²): + good: 0.0 + bad: 0.5 + unit: "J/m²" + tooltip: "Mean absolute error for surface energies (100, 110, 111, 112)" + level_of_theory: PBE + Max SFE 110 error (%): + good: 0.0 + bad: 30.0 + unit: "%" + tooltip: "Error in maximum stacking fault energy for {110}<111> slip system" + level_of_theory: PBE + Max SFE 112 error (%): + good: 0.0 + bad: 30.0 + unit: "%" + tooltip: "Error in maximum stacking fault energy for {112}<111> slip system" + level_of_theory: PBE + # Dislocation properties + Mean core energy (eV): + good: 0.0 + bad: 5.0 + unit: "eV" + tooltip: "Mean dislocation core energy across all types" + level_of_theory: DFT + Core E Screw a0/2[111](112) (eV): + good: 0.0 + bad: 3.0 + unit: "eV" + tooltip: "Core energy of screw a0/2[111](112) dislocation" + level_of_theory: DFT + Core E Edge a0/2[111](110) (eV): + good: 0.0 + bad: 3.0 + unit: "eV" + tooltip: "Core energy of edge a0/2[111](110) dislocation" + level_of_theory: DFT + # Fracture properties + Mean K_Griffith (MPa√m): + good: 1.0 + bad: 2.0 + unit: "MPa√m" + tooltip: "Mean Griffith stress intensity factor across crack systems" + level_of_theory: DFT + K_Griffith (100)[010] (MPa√m): + good: 1.0 + bad: 2.0 + unit: "MPa√m" + tooltip: "Griffith K for (100)[010] crack system" + level_of_theory: DFT + K_Griffith (110)[001] (MPa√m): + good: 1.0 + bad: 2.0 + unit: "MPa√m" + tooltip: "Griffith K for (110)[001] crack system" + level_of_theory: DFT diff --git a/ml_peg/app/physicality/iron_properties/app_iron_properties.py b/ml_peg/app/physicality/iron_properties/app_iron_properties.py new file mode 100644 index 000000000..e4656580c --- /dev/null +++ b/ml_peg/app/physicality/iron_properties/app_iron_properties.py @@ -0,0 +1,105 @@ +"""Run iron properties app.""" + +from __future__ import annotations + +from dash import Dash, dcc +from dash.dcc import Loading +from dash.html import Div, Label + +from ml_peg.app import APP_ROOT +from ml_peg.app.base_app import BaseApp +from ml_peg.models.get_models import get_model_names +from ml_peg.models.models import current_models + +# Get all models +MODELS = get_model_names(current_models) +BENCHMARK_NAME = "Iron Properties" +DATA_PATH = APP_ROOT / "data" / "physicality" / "iron_properties" +DOCS_URL = ( + "https://ddmms.github.io/ml-peg/user_guide/benchmarks/physicality.html#iron-properties" +) + + +class IronPropertiesApp(BaseApp): + """Iron properties benchmark app layout and callbacks.""" + + def register_callbacks(self) -> None: + """Register callbacks for curve visualization.""" + pass # Curve visualization to be added via plot_from_table_column + + +def get_app() -> IronPropertiesApp: + """ + Get iron properties benchmark app layout and callback registration. + + Returns + ------- + IronPropertiesApp + Benchmark layout and callback registration. + """ + model_options = [{"label": model, "value": model} for model in MODELS] + default_model = model_options[0]["value"] if model_options else None + + extra_components = [ + Div( + [ + Label("Select model for curve visualization:"), + dcc.Dropdown( + id=f"{BENCHMARK_NAME}-model-dropdown", + options=model_options, + value=default_model, + clearable=False, + style={"width": "300px", "marginBottom": "20px"}, + ), + dcc.Dropdown( + id=f"{BENCHMARK_NAME}-curve-dropdown", + options=[ + {"label": "EOS Curve", "value": "eos"}, + {"label": "Bain Path", "value": "bain"}, + {"label": "SFE {110}<111>", "value": "sfe_110"}, + {"label": "SFE {112}<111>", "value": "sfe_112"}, + {"label": "(100)[010] K-E Curve", "value": "crack_1"}, + {"label": "(100)[001] K-E Curve", "value": "crack_2"}, + {"label": "(110)[001] K-E Curve", "value": "crack_3"}, + {"label": "(110)[1-10] K-E Curve", "value": "crack_4"}, + ], + value="eos", + clearable=False, + style={"width": "300px"}, + ), + ], + style={"marginBottom": "20px"}, + ), + Loading( + dcc.Graph( + id=f"{BENCHMARK_NAME}-figure", + style={"height": "500px", "width": "100%", "marginTop": "20px"}, + ), + type="circle", + ), + ] + + return IronPropertiesApp( + name=BENCHMARK_NAME, + description=( + "Comprehensive BCC iron properties benchmark. " + "Includes equation of state (lattice parameter, bulk modulus), " + "elastic constants (C11, C12, C44), Bain path (BCC-FCC transformation), " + "vacancy formation energy, surface energies (100, 110, 111, 112), " + "generalized stacking fault energy curves for {110}<111> and {112}<111> slip systems, " + "dislocation core energies for 5 dislocation types (edge, mixed, screw), " + "and crack K-tests for 4 crack systems. " + "This benchmark is computationally expensive and marked with @pytest.mark.slow." + ), + docs_url=DOCS_URL, + table_path=DATA_PATH / "iron_properties_metrics_table.json", + extra_components=extra_components, + ) + + +if __name__ == "__main__": + dash_app = Dash(__name__, assets_folder=DATA_PATH.parent.parent) + iron_properties_app = get_app() + dash_app.layout = iron_properties_app.layout + iron_properties_app.register_callbacks() + dash_app.run(port=8060, debug=True) diff --git a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py new file mode 100644 index 000000000..4b8e8b9fc --- /dev/null +++ b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py @@ -0,0 +1,865 @@ +"""Run calculations for BCC iron properties benchmark. + +This benchmark computes fundamental properties of BCC iron including: +- Equation of state (lattice parameter, bulk modulus) +- Elastic constants (C11, C12, C44) +- Bain path energy curve +- Vacancy formation energy +- Surface energies (100, 110, 111, 112) +- Generalized stacking fault energy curves (110, 112) +- Dislocation core energies (5 types) +- Crack K-tests (4 systems) + +This benchmark is computationally expensive and marked with @pytest.mark.slow. + +Reference +--------- +Zhang, L., Csányi, G., van der Giessen, E., & Maresca, F. (2023). +Efficiency, Accuracy, and Transferability of Machine Learning Potentials: +Application to Dislocations and Cracks in Iron. +arXiv:2307.10072. https://arxiv.org/abs/2307.10072 +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +import pytest +from ase import Atoms +from ase.build import bulk +from ase.constraints import FixAtoms, FixedLine +from ase.filters import ExpCellFilter +from ase.optimize import BFGS, FIRE + +from ml_peg.calcs.utils.iron_utils import ( + # Constants + EV_TO_J, + ANGSTROM_TO_M, + EV_PER_A2_TO_J_PER_M2, + EV_PER_A3_TO_GPA, + # Configurations + DISLOCATION_CONFIGS, + DISLOCATION_TYPES, + CRACK_SYSTEMS_CONFIG, + # EOS fitting + fit_eos, + # Structure creation + create_bcc_supercell, + create_bain_cell, + create_surface_100, + create_surface_110, + create_surface_111, + create_surface_112, + create_sfe_110_structure, + create_sfe_112_structure, + # Elastic utilities + apply_strain, + get_voigt_strain, + calculate_surface_energy, + # Dislocation utilities + create_dislocation_cell, + get_dislocation_info, + apply_screw_displacement, + apply_edge_displacement, + apply_mixed_displacement, + # LEFM utilities + compute_lefm_coefficients, + apply_crack_displacement, + compute_incremental_displacement, + create_crack_cell, +) +from ml_peg.models.get_models import load_models +from ml_peg.models.models import current_models + +MODELS = load_models(current_models) + +# Local directory for calculator outputs +OUT_PATH = Path(__file__).parent / "outputs" + +# ============================================================================= +# Test Parameters +# ============================================================================= + +# EOS calculation parameters +EOS_NUM_POINTS = 30 + +# Elastic constants parameters +ELASTIC_STRAIN = 1.0e-5 +ELASTIC_SUPERCELL_SIZE = (4, 4, 4) +ELASTIC_FMAX = 1e-10 +ELASTIC_MAX_ITER = 100 + +# Bain path parameters +BAIN_NUM_POINTS = 65 + +# Vacancy calculation parameters +VACANCY_SUPERCELL_SIZE = (4, 4, 4) +VACANCY_FMAX = 1e-5 + +# Surface calculation parameters +SURFACE_VACUUM = 10.0 # Angstroms +SURFACE_FMAX = 1e-5 + +# Stacking fault calculation parameters +SFE_110_STEPS = 63 +SFE_112_STEPS = 100 +SFE_STEP_SIZE = 0.04 # Angstroms +SFE_FMAX = 1e-5 + +# Dislocation test parameters +DISLOCATION_FMAX = 1e-5 +DISLOCATION_STRESS_TOL = 100.0 # bar +DISLOCATION_MAX_ITERATIONS = 10 + +# Crack test parameters +CRACK_K_STEPS = 100 + + +# ============================================================================= +# EOS Calculation +# ============================================================================= + +def run_eos_calculation(calc: Any) -> dict[str, Any]: + """ + Run the energy-volume curve calculation. + + Parameters + ---------- + calc + ASE calculator object. + + Returns + ------- + dict + Dictionary with EOS results including a0, B0, V0, E0, volumes, energies. + """ + # Generate lattice parameters: 2.834 - 0.05 + (0.1/30)*i for i in 1..30 + lattice_params = np.array([2.834 - 0.05 + 0.1 / 30 * i for i in range(1, EOS_NUM_POINTS + 1)]) + + volumes = [] + energies = [] + + for lat in lattice_params: + atoms = bulk('Fe', 'bcc', a=lat, cubic=True) + atoms.calc = calc + + energy = atoms.get_potential_energy() + volume = atoms.get_volume() + + n_atoms = len(atoms) + volumes.append(volume / n_atoms) + energies.append(energy / n_atoms) + + volumes = np.array(volumes) + energies = np.array(energies) + + # Fit Birch-Murnaghan EOS + eos_results = fit_eos(volumes, energies) + + return { + 'volumes': volumes.tolist(), + 'energies': energies.tolist(), + 'lattice_params': lattice_params.tolist(), + 'a0': eos_results['a0'], + 'E0': eos_results['E0'], + 'B0': eos_results['B0'], + 'Bp': eos_results['Bp'], + 'V0': eos_results['V0'], + } + + +# ============================================================================= +# Elastic Constants Calculation +# ============================================================================= + +def run_elastic_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: + """ + Calculate elastic constants using the stress-strain method. + + Parameters + ---------- + calc + ASE calculator object. + lattice_parameter + Equilibrium lattice parameter from EOS fit. + + Returns + ------- + dict + Dictionary with elastic constants C11, C12, C44, bulk_modulus. + """ + # Create supercell + atoms_ref = create_bcc_supercell(lattice_parameter, ELASTIC_SUPERCELL_SIZE) + atoms_ref.calc = calc + + # Box relaxation + ecf = ExpCellFilter(atoms_ref) + opt = BFGS(ecf, logfile=None) + opt.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) + + # Elastic constant matrix + C = np.zeros((6, 6)) + + for i in range(6): + direction = i + 1 + + # Positive strain + strain_pos = get_voigt_strain(direction, ELASTIC_STRAIN) + atoms_pos = apply_strain(atoms_ref.copy(), strain_pos) + atoms_pos.calc = calc + + opt_pos = BFGS(atoms_pos, logfile=None) + opt_pos.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) + stress_pos = -atoms_pos.get_stress(voigt=True) + + # Negative strain + strain_neg = get_voigt_strain(direction, -ELASTIC_STRAIN) + atoms_neg = apply_strain(atoms_ref.copy(), strain_neg) + atoms_neg.calc = calc + + opt_neg = BFGS(atoms_neg, logfile=None) + opt_neg.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) + stress_neg = -atoms_neg.get_stress(voigt=True) + + # Compute elastic constants + delta_stress = stress_pos - stress_neg + delta_strain = 2 * ELASTIC_STRAIN + + for j in range(6): + C[j, i] = -delta_stress[j] / delta_strain * EV_PER_A3_TO_GPA + + # Symmetrize + C_sym = 0.5 * (C + C.T) + + # Extract cubic averages + C11 = (C_sym[0, 0] + C_sym[1, 1] + C_sym[2, 2]) / 3 + C12 = (C_sym[0, 1] + C_sym[0, 2] + C_sym[1, 2]) / 3 + C44 = (C_sym[3, 3] + C_sym[4, 4] + C_sym[5, 5]) / 3 + + bulk_modulus = (C11 + 2 * C12) / 3 + + return { + 'C11': C11, + 'C12': C12, + 'C44': C44, + 'bulk_modulus': bulk_modulus, + 'C_matrix': C_sym.tolist(), + } + + +# ============================================================================= +# Bain Path Calculation +# ============================================================================= + +def run_bain_path_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: + """ + Calculate the Bain path energy curve. + + Parameters + ---------- + calc + ASE calculator object. + lattice_parameter + Equilibrium BCC lattice parameter. + + Returns + ------- + dict + Dictionary with ca_ratios, energies, E_bcc, E_fcc, delta_E. + """ + # Generate c/a ratios: 0.7 + 0.02*i for i in 1..65 + ca_ratios_target = np.array([0.7 + 0.02 * i for i in range(1, BAIN_NUM_POINTS + 1)]) + + ca_ratios = [] + energies = [] + + for ratio in ca_ratios_target: + atoms = create_bain_cell(lattice_parameter, ratio) + atoms.calc = calc + + # Box relaxation + ecf = ExpCellFilter(atoms, scalar_pressure=0.0) + opt = BFGS(ecf, logfile=None) + + try: + opt.run(fmax=1e-5, steps=10000) + except Exception: + pass + + # Additional atomic relaxation + opt2 = BFGS(atoms, logfile=None) + try: + opt2.run(fmax=1e-5, steps=10000) + except Exception: + pass + + energy = atoms.get_potential_energy() + cell = atoms.get_cell() + ca_actual = cell[2, 2] / cell[1, 1] + + n_atoms = len(atoms) + ca_ratios.append(ca_actual) + energies.append(energy / n_atoms) + + ca_ratios = np.array(ca_ratios) + energies = np.array(energies) + + # Normalize energies (subtract minimum, convert to meV) + E_min = np.min(energies) + energies_norm = (energies - E_min) * 1000 # meV/atom + + # Find BCC point (c/a ≈ 1.0) and FCC point (c/a ≈ 1.414) + idx_bcc = np.argmin(np.abs(ca_ratios - 1.0)) + idx_fcc = np.argmin(np.abs(ca_ratios - np.sqrt(2))) + + E_bcc = energies_norm[idx_bcc] + E_fcc = energies_norm[idx_fcc] + + return { + 'ca_ratios': ca_ratios.tolist(), + 'energies': energies.tolist(), + 'energies_meV': energies_norm.tolist(), + 'E_bcc_meV': E_bcc, + 'E_fcc_meV': E_fcc, + 'delta_E_meV': E_fcc - E_bcc, + } + + +# ============================================================================= +# Vacancy Calculation +# ============================================================================= + +def run_vacancy_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: + """Calculate the vacancy formation energy.""" + atoms_perfect = create_bcc_supercell(lattice_parameter, VACANCY_SUPERCELL_SIZE) + atoms_perfect.calc = calc + + n_atoms = len(atoms_perfect) + E_perfect = atoms_perfect.get_potential_energy() + E_coh = E_perfect / n_atoms + + atoms_defect = atoms_perfect.copy() + del atoms_defect[0] + atoms_defect.calc = calc + + opt = BFGS(atoms_defect, logfile=None) + opt.run(fmax=VACANCY_FMAX, steps=10000) + E_defect = atoms_defect.get_potential_energy() + E_vac = (E_defect - E_perfect) + E_coh + + return {'E_vac': E_vac, 'E_coh': E_coh, 'E_perfect': E_perfect, 'E_defect': E_defect} + + +# ============================================================================= +# Surface Calculations +# ============================================================================= + +def run_surface_calculations(calc: Any, lattice_parameter: float) -> dict[str, Any]: + """Calculate surface energies for (100), (110), (111), (112) surfaces.""" + surfaces = {} + + # (100) surface + atoms_bulk = create_surface_100(lattice_parameter, layers=10, vacuum=0.0) + atoms_bulk.calc = calc + E_bulk = atoms_bulk.get_potential_energy() + cell = atoms_bulk.get_cell() + area = np.linalg.norm(np.cross(cell[0], cell[1])) + + atoms_slab = create_surface_100(lattice_parameter, layers=10, vacuum=SURFACE_VACUUM) + atoms_slab.calc = calc + opt = BFGS(atoms_slab, logfile=None) + opt.run(fmax=SURFACE_FMAX, steps=10000) + E_slab = atoms_slab.get_potential_energy() + surfaces['100'] = calculate_surface_energy(E_slab, E_bulk, area) + + # (110) surface + atoms_bulk = create_surface_110(lattice_parameter, layers=10, vacuum=0.0) + atoms_bulk.calc = calc + E_bulk = atoms_bulk.get_potential_energy() + cell = atoms_bulk.get_cell() + area = np.linalg.norm(np.cross(cell[0], cell[1])) + + atoms_slab = create_surface_110(lattice_parameter, layers=10, vacuum=SURFACE_VACUUM) + atoms_slab.calc = calc + opt = BFGS(atoms_slab, logfile=None) + opt.run(fmax=SURFACE_FMAX, steps=10000) + E_slab = atoms_slab.get_potential_energy() + surfaces['110'] = calculate_surface_energy(E_slab, E_bulk, area) + + # (111) surface + atoms_bulk = create_surface_111(lattice_parameter, size=(3, 15, 3), vacuum=0.0) + atoms_bulk.calc = calc + E_bulk = atoms_bulk.get_potential_energy() + cell = atoms_bulk.get_cell() + area = np.linalg.norm(np.cross(cell[0], cell[2])) + + atoms_slab = create_surface_111(lattice_parameter, size=(3, 15, 3), vacuum=SURFACE_VACUUM) + atoms_slab.calc = calc + opt = BFGS(atoms_slab, logfile=None) + opt.run(fmax=SURFACE_FMAX, steps=10000) + E_slab = atoms_slab.get_potential_energy() + surfaces['111'] = calculate_surface_energy(E_slab, E_bulk, area) + + # (112) surface + atoms_bulk = create_surface_112(lattice_parameter, layers=15, vacuum=0.0) + atoms_bulk.calc = calc + E_bulk = atoms_bulk.get_potential_energy() + cell = atoms_bulk.get_cell() + area = np.linalg.norm(np.cross(cell[0], cell[1])) + + atoms_slab = create_surface_112(lattice_parameter, layers=15, vacuum=5.0) + atoms_slab.calc = calc + opt = BFGS(atoms_slab, logfile=None) + opt.run(fmax=SURFACE_FMAX, steps=10000) + E_slab = atoms_slab.get_potential_energy() + surfaces['112'] = calculate_surface_energy(E_slab, E_bulk, area) + + return { + 'gamma_100': surfaces['100'], + 'gamma_110': surfaces['110'], + 'gamma_111': surfaces['111'], + 'gamma_112': surfaces['112'], + } + + +# ============================================================================= +# Stacking Fault Energy Calculations +# ============================================================================= + +def run_sfe_110_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: + """Calculate GSFE curve for {110}<111> slip system.""" + atoms = create_sfe_110_structure(lattice_parameter) + atoms.calc = calc + + cell = atoms.get_cell() + ly = cell[1, 1] + lz = cell[2, 2] + area = ly * lz + + opt = BFGS(atoms, logfile=None) + opt.run(fmax=SFE_FMAX, steps=10000) + E0 = atoms.get_potential_energy() + + positions = atoms.get_positions() + x_mid = (positions[:, 0].min() + positions[:, 0].max()) / 2 + 0.1 + upper_mask = positions[:, 0] < x_mid + upper_indices = np.where(upper_mask)[0] + + displacements = [0.0] + sfe_J_per_m2 = [0.0] + + + constraints = [FixedLine(idx, direction=[1, 0, 0]) for idx in range(len(atoms))] + for step in range(1, SFE_110_STEPS + 1): + positions = atoms.get_positions() + positions[upper_indices, 1] += SFE_STEP_SIZE + atoms.set_positions(positions) + + atoms.set_constraint(constraints) + + opt = BFGS(atoms, logfile=None) + try: + opt.run(fmax=SFE_FMAX, steps=10000) + except Exception: + pass + + atoms.set_constraint() + E = atoms.get_potential_energy() + sfe = (E - E0) / (2 * area) * EV_PER_A2_TO_J_PER_M2 + + displacements.append(step * SFE_STEP_SIZE) + sfe_J_per_m2.append(sfe) + + return {'displacements': displacements, 'sfe_J_per_m2': sfe_J_per_m2, 'max_sfe': max(sfe_J_per_m2)} + + +def run_sfe_112_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: + """Calculate GSFE curve for {112}<111> slip system.""" + atoms = create_sfe_112_structure(lattice_parameter) + atoms.calc = calc + + cell = atoms.get_cell() + ly = cell[1, 1] + lz = cell[2, 2] + area = ly * lz + + opt = BFGS(atoms, logfile=None) + opt.run(fmax=SFE_FMAX, steps=10000) + E0 = atoms.get_potential_energy() + + positions = atoms.get_positions() + x_mid = (positions[:, 0].min() + positions[:, 0].max()) / 2 + 0.1 + upper_mask = positions[:, 0] < x_mid + upper_indices = np.where(upper_mask)[0] + + displacements = [0.0] + sfe_J_per_m2 = [0.0] + + constraints = [FixedLine(idx, direction=[1, 0, 0]) for idx in range(len(atoms))] + for step in range(1, SFE_112_STEPS + 1): + positions = atoms.get_positions() + positions[upper_indices, 2] += SFE_STEP_SIZE + atoms.set_positions(positions) + + atoms.set_constraint(constraints) + + opt = BFGS(atoms, logfile=None) + try: + opt.run(fmax=SFE_FMAX, steps=10000) + except Exception: + pass + + atoms.set_constraint() + E = atoms.get_potential_energy() + sfe = (E - E0) / (2 * area) * EV_PER_A2_TO_J_PER_M2 + + displacements.append(step * SFE_STEP_SIZE) + sfe_J_per_m2.append(sfe) + + return {'displacements': displacements, 'sfe_J_per_m2': sfe_J_per_m2, 'max_sfe': max(sfe_J_per_m2)} + + +# ============================================================================= +# Dislocation Tests +# ============================================================================= + +def run_dislocation_test(calc: Any, a0: float, E_coh: float, dislocation_type: str) -> dict[str, Any]: + """Run dislocation test for a specific type.""" + config = get_dislocation_info(dislocation_type) + atoms = create_dislocation_cell(a0, dislocation_type) + atoms.calc = calc + + atoms_perfect = atoms.copy() + atoms_perfect.calc = calc + + # Box relaxation for perfect crystal with stress convergence loop + mask = [True, True, False, False, False, True] + for iteration in range(DISLOCATION_MAX_ITERATIONS): + ecf = ExpCellFilter(atoms_perfect, mask=mask, scalar_pressure=0.0) + opt = BFGS(ecf, logfile=None) + opt.run(fmax=DISLOCATION_FMAX, steps=2000) + + # Check stress convergence (convert eV/ų to bar) + stress = atoms_perfect.get_stress() * 160.2176621 * 10000 # bar + if abs(stress[0]) < DISLOCATION_STRESS_TOL and abs(stress[1]) < DISLOCATION_STRESS_TOL: + break + + # Final relaxation with FIRE + opt = FIRE(atoms_perfect, logfile=None) + opt.run(fmax=DISLOCATION_FMAX, steps=5000) + + E_perfect = atoms_perfect.get_potential_energy() + n_atoms_perfect = len(atoms_perfect) + E_coh_local = E_perfect / n_atoms_perfect + + atoms = atoms_perfect.copy() + atoms.calc = calc + + burgers_vec = config['burgers'] + b_mag = a0 * np.linalg.norm(burgers_vec) + disl_type = config['type'] + + if disl_type == 'screw': + apply_screw_displacement(atoms, b_mag) + elif disl_type == 'edge': + atoms = apply_edge_displacement(atoms, b_mag, a0, delete_half_plane=True) + atoms.calc = calc + elif disl_type == 'mixed': + atoms = apply_mixed_displacement(atoms, b_mag, a0, screw_fraction=0.7) + atoms.calc = calc + + # Multi-stage relaxation of dislocation structure with stress convergence loop + mask = [True, True, False, False, False, True] + for iteration in range(DISLOCATION_MAX_ITERATIONS): + ecf = ExpCellFilter(atoms, mask=mask, scalar_pressure=0.0) + opt = BFGS(ecf, logfile=None) + opt.run(fmax=DISLOCATION_FMAX, steps=2000) + + stress = atoms.get_stress() * 160.2176621 * 10000 # bar + if abs(stress[0]) < DISLOCATION_STRESS_TOL and abs(stress[1]) < DISLOCATION_STRESS_TOL: + break + + # Final relaxation with FIRE + opt = FIRE(atoms, logfile=None) + opt.run(fmax=DISLOCATION_FMAX, steps=5000) + + E_disl = atoms.get_potential_energy() + n_atoms_disl = len(atoms) + core_energy = E_disl - n_atoms_disl * E_coh_local + + return { + 'dislocation_type': dislocation_type, + 'name': config['name'], + 'core_energy': core_energy, + 'n_atoms': n_atoms_disl, + } + + +# ============================================================================= +# Crack Tests +# ============================================================================= + +def run_crack_test( + calc: Any, + a0: float, + elastic_constants: dict[str, float], + surface_energies: dict[str, float], + crack_system: int, + K_steps: int = CRACK_K_STEPS, +) -> dict[str, Any]: + """Run crack K-test for a specific system.""" + config = CRACK_SYSTEMS_CONFIG[crack_system] + surface = config['surface'] + surf_energy = surface_energies.get(surface, 2.0) + + coeffs = compute_lefm_coefficients( + elastic_constants['C11'], + elastic_constants['C12'], + elastic_constants['C44'], + surf_energy, + crack_system, + ) + K_Griffith = coeffs['K_I'] + + atoms, crack_tip, radius = create_crack_cell(a0, crack_system) + atoms.calc = calc + + positions = atoms.get_positions() + xtip, ytip = crack_tip + r = np.sqrt((positions[:, 0] - xtip)**2 + (positions[:, 1] - ytip)**2) + boundary_mask = r > (radius - 10.0) + boundary_indices = np.where(boundary_mask)[0] + + opt = BFGS(atoms, logfile=None) + opt.run(fmax=1e-3, steps=10000) + + ref_positions = atoms.get_positions().copy() + + K_start = max(0.5, K_Griffith * 100 - 10) + K_stop = K_start + K_steps + + new_positions = apply_crack_displacement(atoms.get_positions(), K_start, coeffs, crack_tip, ref_positions) + atoms.set_positions(new_positions) + + dx, dy = compute_incremental_displacement(ref_positions, 1.0, coeffs, crack_tip) + + K_values = [] + energies = [] + + atoms.set_constraint(FixAtoms(indices=boundary_indices)) + dK = (K_stop - K_start) / K_steps if K_steps > 0 else 1.0 + + for i in range(K_steps + 1): + K = K_start + i * dK + + if i > 0: + positions = atoms.get_positions() + positions[:, 0] += dx * dK + positions[:, 1] += dy * dK + atoms.set_positions(positions) + + opt = BFGS(atoms, logfile=None) + try: + opt.run(fmax=1e-3, steps=5000) + except Exception: + pass + + E = atoms.get_potential_energy() + K_values.append(K) + energies.append(E) + + return { + 'crack_system': crack_system, + 'name': config['name'], + 'K_Griffith': K_Griffith, + 'K_values': K_values, + 'energies': energies, + } + + +# ============================================================================= +# Main Benchmark Function +# ============================================================================= + +def run_iron_properties(model_name: str, model: Any) -> None: + """ + Run the full iron properties benchmark for a single model. + + This benchmark includes: + - Equation of state (lattice parameter, bulk modulus) + - Elastic constants (C11, C12, C44) + - Bain path energy curve + - Vacancy formation energy + - Surface energies (100, 110, 111, 112) + - Stacking fault energy curves (110, 112) + - Dislocation core energies (5 types) + - Crack K-tests (4 systems) + + Parameters + ---------- + model_name + Name of the model being evaluated. + model + Model wrapper providing ``get_calculator``. + """ + calc = model.get_calculator() + write_dir = OUT_PATH / model_name + write_dir.mkdir(parents=True, exist_ok=True) + + results: dict[str, Any] = {} + + # EOS calculation + print(f"[{model_name}] Running EOS calculation...") + eos_results = run_eos_calculation(calc) + results['eos'] = eos_results + a0 = eos_results['a0'] + E_coh = eos_results['E0'] + print(f"[{model_name}] Lattice parameter: {a0:.4f} Å, Bulk modulus: {eos_results['B0']:.1f} GPa") + + # Save EOS curve data + eos_df = pd.DataFrame({ + 'volume': eos_results['volumes'], + 'energy': eos_results['energies'], + }) + eos_df.to_csv(write_dir / "eos_curve.csv", index=False) + + # Elastic constants calculation + print(f"[{model_name}] Running elastic constants calculation...") + elastic_results = run_elastic_calculation(calc, a0) + results['elastic'] = elastic_results + print(f"[{model_name}] C11={elastic_results['C11']:.1f}, C12={elastic_results['C12']:.1f}, C44={elastic_results['C44']:.1f} GPa") + + # Bain path calculation + print(f"[{model_name}] Running Bain path calculation...") + bain_results = run_bain_path_calculation(calc, a0) + results['bain_path'] = bain_results + + # Save Bain path data + bain_df = pd.DataFrame({ + 'ca_ratio': bain_results['ca_ratios'], + 'energy': bain_results['energies'], + 'energy_meV': bain_results['energies_meV'], + }) + bain_df.to_csv(write_dir / "bain_path.csv", index=False) + + # Vacancy calculation + print(f"[{model_name}] Running vacancy calculation...") + vacancy_results = run_vacancy_calculation(calc, a0) + results['vacancy'] = vacancy_results + print(f"[{model_name}] E_vac = {vacancy_results['E_vac']:.3f} eV") + + # Surface calculations + print(f"[{model_name}] Running surface calculations...") + surface_results = run_surface_calculations(calc, a0) + results['surfaces'] = surface_results + + # SFE 110 calculation + print(f"[{model_name}] Running SFE 110 calculation...") + sfe_110_results = run_sfe_110_calculation(calc, a0) + results['sfe_110'] = {'max_sfe': sfe_110_results['max_sfe']} + sfe_110_df = pd.DataFrame({ + 'displacement': sfe_110_results['displacements'], + 'sfe_J_per_m2': sfe_110_results['sfe_J_per_m2'], + }) + sfe_110_df.to_csv(write_dir / "sfe_110_curve.csv", index=False) + + # SFE 112 calculation + print(f"[{model_name}] Running SFE 112 calculation...") + sfe_112_results = run_sfe_112_calculation(calc, a0) + results['sfe_112'] = {'max_sfe': sfe_112_results['max_sfe']} + sfe_112_df = pd.DataFrame({ + 'displacement': sfe_112_results['displacements'], + 'sfe_J_per_m2': sfe_112_results['sfe_J_per_m2'], + }) + sfe_112_df.to_csv(write_dir / "sfe_112_curve.csv", index=False) + + # Dislocation tests + print(f"[{model_name}] Running dislocation tests...") + dislocation_results = {} + for disl_type in DISLOCATION_TYPES: + print(f"[{model_name}] {disl_type}...") + try: + disl_result = run_dislocation_test(calc, a0, E_coh, disl_type) + dislocation_results[disl_type] = disl_result + except Exception as e: + print(f"[{model_name}] Error: {e}") + dislocation_results[disl_type] = {'error': str(e)} + results['dislocations'] = dislocation_results + + # Crack K-tests + print(f"[{model_name}] Running crack K-tests...") + crack_results = {} + for crack_sys in [1, 2, 3, 4]: + print(f"[{model_name}] System {crack_sys}...") + try: + crack_result = run_crack_test( + calc, a0, elastic_results, + {'100': surface_results['gamma_100'], '110': surface_results['gamma_110']}, + crack_sys, K_steps=CRACK_K_STEPS, + ) + crack_results[crack_sys] = { + 'name': crack_result['name'], + 'K_Griffith': crack_result['K_Griffith'], + } + ke_df = pd.DataFrame({ + 'K': crack_result['K_values'], + 'energy': crack_result['energies'], + }) + ke_df.to_csv(write_dir / f"crack_{crack_sys}_KE.csv", index=False) + except Exception as e: + print(f"[{model_name}] Error: {e}") + crack_results[crack_sys] = {'error': str(e)} + results['cracks'] = crack_results + + # Save all results as JSON + (write_dir / "results.json").write_text(json.dumps(results, indent=2, default=str)) + + # Save summary metrics + summary: dict[str, Any] = { + 'a0': a0, + 'B0': eos_results['B0'], + 'C11': elastic_results['C11'], + 'C12': elastic_results['C12'], + 'C44': elastic_results['C44'], + 'E_bcc_fcc_meV': bain_results['delta_E_meV'], + 'E_vac': vacancy_results['E_vac'], + 'gamma_100': surface_results['gamma_100'], + 'gamma_110': surface_results['gamma_110'], + 'gamma_111': surface_results['gamma_111'], + 'gamma_112': surface_results['gamma_112'], + 'max_sfe_110': sfe_110_results['max_sfe'], + 'max_sfe_112': sfe_112_results['max_sfe'], + } + + for disl_type, disl_data in dislocation_results.items(): + if 'core_energy' in disl_data: + summary[f'core_energy_{disl_type}'] = disl_data['core_energy'] + + for crack_sys, crack_data in crack_results.items(): + if 'K_Griffith' in crack_data: + summary[f'K_Griffith_{crack_sys}'] = crack_data['K_Griffith'] + + (write_dir / "summary.json").write_text(json.dumps(summary, indent=2)) + + print(f"[{model_name}] Done. Results saved to {write_dir}") + + +@pytest.mark.slow +@pytest.mark.parametrize("model_name", MODELS) +def test_iron_properties(model_name: str) -> None: + """ + Run iron properties benchmark for each registered model. + + This test is marked as slow and excluded from default test runs. + Run with ``pytest --run-slow`` to include. + + Parameters + ---------- + model_name + Name of the model to evaluate. + """ + run_iron_properties(model_name, MODELS[model_name]) diff --git a/ml_peg/calcs/utils/iron_utils.py b/ml_peg/calcs/utils/iron_utils.py new file mode 100644 index 000000000..4d891642f --- /dev/null +++ b/ml_peg/calcs/utils/iron_utils.py @@ -0,0 +1,982 @@ +"""Utility functions for BCC iron property calculations. + +This module provides structure creation, EOS fitting, dislocation utilities, +and LEFM functions for iron benchmarks. + +Reference +--------- +Zhang, L., Csányi, G., van der Giessen, E., & Maresca, F. (2023). +Efficiency, Accuracy, and Transferability of Machine Learning Potentials: +Application to Dislocations and Cracks in Iron. +arXiv:2307.10072. https://arxiv.org/abs/2307.10072 +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +from ase import Atoms +from ase.build import bulk +from ase.neighborlist import NeighborList +from scipy.optimize import leastsq + + +# ============================================================================= +# Unit Conversion Constants +# ============================================================================= + +EV_TO_J = 1.60218e-19 +ANGSTROM_TO_M = 1.0e-10 +EV_PER_A2_TO_J_PER_M2 = 16.0217733 +EV_PER_A3_TO_GPA = 160.21765 + + +# ============================================================================= +# Dislocation Configuration +# ============================================================================= + +DISLOCATION_CONFIGS = { + 'edge_100_010': { + 'name': 'Edge a0[100](010)', + 'orient_x': np.array([0, 0, 1]), + 'orient_y': np.array([1, 0, 0]), + 'orient_z': np.array([0, 1, 0]), + 'size': (1, 50, 20), + 'burgers': np.array([1, 0, 0]), + 'type': 'edge', + 'slip_direction': 1, + }, + 'edge_100_011': { + 'name': 'Edge a0[100](011)', + 'orient_x': np.array([0, -1, 1]), + 'orient_y': np.array([1, 0, 0]), + 'orient_z': np.array([0, 1, 1]), + 'size': (1, 80, 22), + 'burgers': np.array([1, 0, 0]), + 'type': 'edge', + 'slip_direction': 1, + }, + 'edge_111_110': { + 'name': 'Edge a0/2[111](110)', + 'orient_x': np.array([1, 2, -1]), + 'orient_y': np.array([-1, 1, 1]), + 'orient_z': np.array([1, 0, 1]), + 'size': (1, 40, 20), + 'burgers': np.array([0.5, 0.5, 0.5]), + 'type': 'edge', + 'slip_direction': 1, + }, + 'mixed_111': { + 'name': 'Mixed 70.5 deg a0/2[111](110)', + 'orient_x': np.array([1, 2, -1]), + 'orient_y': np.array([-1, 1, 1]), + 'orient_z': np.array([1, 0, 1]), + 'size': (40, 2, 19), + 'burgers': np.array([0.5, 0.5, 0.5]), + 'type': 'mixed', + 'slip_direction': 1, + }, + 'screw_111': { + 'name': 'Screw a0/2[111](112)', + 'orient_x': np.array([1, 2, -1]), + 'orient_y': np.array([-1, 1, 1]), + 'orient_z': np.array([1, 0, 1]), + 'size': (60, 2, 19), + 'burgers': np.array([0.5, 0.5, 0.5]), + 'type': 'screw', + 'slip_direction': 1, + }, +} + +DISLOCATION_TYPES = list(DISLOCATION_CONFIGS.keys()) + + +# ============================================================================= +# Crack System Configuration +# ============================================================================= + +CRACK_SYSTEMS_CONFIG = { + 1: { + 'name': '(100)[010]', + 'orient_x': np.array([0, 0, 1]), + 'orient_y': np.array([1, 0, 0]), + 'orient_z': np.array([0, 1, 0]), + 'surface': '100', + 'box_size': (50, 50), + 'tip_factors': (1.0, 1.0), + }, + 2: { + 'name': '(100)[001]', + 'orient_x': np.array([0, -1, 1]), + 'orient_y': np.array([1, 0, 0]), + 'orient_z': np.array([0, 1, 1]), + 'surface': '100', + 'box_size': (38, 54), + 'tip_factors': (np.sqrt(2), 1.0), + }, + 3: { + 'name': '(110)[001]', + 'orient_x': np.array([1, -1, 0]), + 'orient_y': np.array([1, 1, 0]), + 'orient_z': np.array([0, 0, 1]), + 'surface': '110', + 'box_size': (38, 38), + 'tip_factors': (np.sqrt(2), np.sqrt(2)), + }, + 4: { + 'name': '(110)[1-10]', + 'orient_x': np.array([0, 0, -1]), + 'orient_y': np.array([1, 1, 0]), + 'orient_z': np.array([1, -1, 0]), + 'surface': '110', + 'box_size': (55, 38), + 'tip_factors': (1.0, np.sqrt(2)), + }, +} + + +# ============================================================================= +# EOS Fitting Functions +# ============================================================================= + +def eos_birch_murnaghan( + params: tuple[float, float, float, float], + vol: np.ndarray +) -> np.ndarray: + """ + Birch-Murnaghan equation of state (3rd order). + + Parameters + ---------- + params + (E0, B0, Bp, V0). + vol + Volume array. + + Returns + ------- + np.ndarray + Energy array. + """ + E0, B0, Bp, V0 = params + eta = (vol / V0) ** (1.0 / 3.0) + E = E0 + 9.0 * B0 * V0 / 16.0 * (eta**2 - 1.0)**2 * (6.0 + Bp * (eta**2 - 1.0) - 4.0 * eta**2) + return E + + +def get_eos_initial_guess( + vol: np.ndarray, + ene: np.ndarray +) -> tuple[float, float, float, float]: + """ + Get initial guess for EOS parameters using quadratic fit. + + Parameters + ---------- + vol + Volume array. + ene + Energy array. + + Returns + ------- + tuple + (E0, B0, Bp, V0) initial guess. + """ + a, b, c = np.polyfit(vol, ene, 2) + V0 = -b / (2 * a) + E0 = a * V0**2 + b * V0 + c + B0 = 2 * a * V0 + Bp = 4.0 + return E0, B0, Bp, V0 + + +def fit_eos( + vol: np.ndarray, + ene: np.ndarray, +) -> dict[str, Any]: + """ + Fit Birch-Murnaghan equation of state to energy-volume data. + + Parameters + ---------- + vol + Volume per atom array (Angstrom^3). + ene + Energy per atom array (eV). + + Returns + ------- + dict + Fitted parameters: + - E0: Equilibrium energy (eV) + - B0: Bulk modulus (GPa) + - Bp: Pressure derivative (dimensionless) + - V0: Equilibrium volume per atom (Angstrom^3) + - a0: Equilibrium lattice parameter (Angstrom) - for BCC + """ + x0 = get_eos_initial_guess(vol, ene) + + def residual(params, y, x): + return y - eos_birch_murnaghan(params, x) + + params, _ = leastsq(residual, x0, args=(ene, vol)) + E0, B0, Bp, V0 = params + + # Convert bulk modulus to GPa (from eV/Angstrom^3) + B0_GPa = B0 * EV_PER_A3_TO_GPA + + # Calculate lattice parameter for BCC (2 atoms per unit cell) + a0 = (V0 * 2) ** (1.0 / 3.0) + + return {'E0': E0, 'B0': B0_GPa, 'Bp': Bp, 'V0': V0, 'a0': a0} + + +# ============================================================================= +# Structure Creation Functions +# ============================================================================= + +def create_bcc_supercell( + lattice_parameter: float, + size: tuple = (4, 4, 4), + symbol: str = 'Fe' +) -> Atoms: + """ + Create a BCC supercell. + + Parameters + ---------- + lattice_parameter + Lattice parameter in Angstroms. + size + Supercell size as (nx, ny, nz). + symbol + Chemical symbol (default: 'Fe'). + + Returns + ------- + Atoms + ASE Atoms object. + """ + unit_cell = bulk(symbol, 'bcc', a=lattice_parameter, cubic=True) + return unit_cell * size + + +def create_bain_cell(lattice_parameter: float, ca_ratio: float) -> Atoms: + """ + Create a tetragonally distorted BCC cell for Bain path calculation. + + Parameters + ---------- + lattice_parameter + BCC lattice parameter. + ca_ratio + Target c/a ratio. + + Returns + ------- + Atoms + Tetragonally distorted cell. + """ + beta = (1.0 / ca_ratio) ** (1.0 / 3.0) + al = lattice_parameter * beta + alz = al * ca_ratio + + cell = np.array([[al, 0, 0], [0, al, 0], [0, 0, alz]]) + positions = np.array([[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]]) @ cell + + return Atoms(symbols=['Fe', 'Fe'], positions=positions, cell=cell, pbc=True) + + +def create_surface_100( + lattice_parameter: float, + layers: int = 10, + vacuum: float = 0.0, + symbol: str = 'Fe' +) -> Atoms: + """Create a (100) surface slab for BCC iron.""" + a = lattice_parameter + cell = np.array([[a, 0, 0], [0, a, 0], [0, 0, a * layers]]) + + positions = [] + for k in range(layers): + positions.append([0, 0, k * a]) + positions.append([0.5 * a, 0.5 * a, (k + 0.5) * a]) + + atoms = Atoms(symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True) + if vacuum > 0: + atoms.center(vacuum=vacuum, axis=2) + return atoms + + +def create_surface_110( + lattice_parameter: float, + layers: int = 10, + vacuum: float = 0.0, + symbol: str = 'Fe' +) -> Atoms: + """Create a (110) surface slab for BCC iron.""" + a = lattice_parameter + lx = a + ly = a * np.sqrt(2) + lz = a * np.sqrt(2) * layers + + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) + positions = [] + d110 = a * np.sqrt(2) / 2 + + for k in range(layers * 2): + z = k * d110 + if k % 2 == 0: + positions.append([0, 0, z]) + else: + positions.append([0.5 * a, 0.5 * ly, z]) + + atoms = Atoms(symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True) + if vacuum > 0: + atoms.center(vacuum=vacuum, axis=2) + return atoms + + +def create_surface_111( + lattice_parameter: float, + size: tuple = (3, 15, 3), + vacuum: float = 0.0, + symbol: str = 'Fe' +) -> Atoms: + """Create a (111) surface slab for BCC iron.""" + a = lattice_parameter + lx = a * np.sqrt(2) * size[0] + ly = a * np.sqrt(3) * size[1] + lz = a * np.sqrt(6) * size[2] + + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) + positions = [] + max_range = int(max(size) * 3 + 5) + + ex = np.array([-1, 1, 0]) / np.sqrt(2) + ey = np.array([1, 1, 1]) / np.sqrt(3) + ez = np.array([1, 1, -2]) / np.sqrt(6) + R = np.array([ex, ey, ez]) + + for i in range(-max_range, max_range + 1): + for j in range(-max_range, max_range + 1): + for k in range(-max_range, max_range + 1): + for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: + pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) + pos_oriented = R @ pos_cubic + frac_x = pos_oriented[0] / lx + frac_y = pos_oriented[1] / ly + frac_z = pos_oriented[2] / lz + eps = 1e-8 + if (0 - eps <= frac_x < 1 - eps and + 0 - eps <= frac_y < 1 - eps and + 0 - eps <= frac_z < 1 - eps): + positions.append(pos_oriented) + + if len(positions) == 0: + raise ValueError("No atoms found for (111) surface") + + positions = np.array(positions) + _, unique_idx = np.unique(np.round(positions, decimals=6), axis=0, return_index=True) + positions = positions[unique_idx] + + atoms = Atoms(symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True) + atoms.wrap() + if vacuum > 0: + atoms.center(vacuum=vacuum, axis=1) + return atoms + + +def create_surface_112( + lattice_parameter: float, + layers: int = 15, + vacuum: float = 0.0, + symbol: str = 'Fe' +) -> Atoms: + """Create a (112) surface slab for BCC iron.""" + a = lattice_parameter + lx = a * np.sqrt(2) + ly = a * np.sqrt(3) + lz = a * np.sqrt(6) * layers + + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) + positions = [] + max_range = int(layers * 3 + 5) + + ex = np.array([-1, 1, 0]) / np.sqrt(2) + ey = np.array([1, 1, 1]) / np.sqrt(3) + ez = np.array([1, 1, -2]) / np.sqrt(6) + R = np.array([ex, ey, ez]) + + for i in range(-max_range, max_range + 1): + for j in range(-max_range, max_range + 1): + for k in range(-max_range, max_range + 1): + for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: + pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) + pos_oriented = R @ pos_cubic + frac_x = pos_oriented[0] / lx + frac_y = pos_oriented[1] / ly + frac_z = pos_oriented[2] / lz + eps = 1e-8 + if (0 - eps <= frac_x < 1 - eps and + 0 - eps <= frac_y < 1 - eps and + 0 - eps <= frac_z < 1 - eps): + positions.append(pos_oriented) + + if len(positions) == 0: + raise ValueError("No atoms found for (112) surface") + + positions = np.array(positions) + _, unique_idx = np.unique(np.round(positions, decimals=6), axis=0, return_index=True) + positions = positions[unique_idx] + + atoms = Atoms(symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True) + atoms.wrap() + if vacuum > 0: + atoms.center(vacuum=vacuum, axis=2) + return atoms + + +def create_sfe_110_structure(lattice_parameter: float) -> Atoms: + """Create structure for {110}<111> stacking fault calculation.""" + a = lattice_parameter + size = (20, 1, 3) + + ex = np.array([-1, 1, 0]) / np.sqrt(2) + ey = np.array([1, 1, 1]) / np.sqrt(3) + ez = np.array([1, 1, -2]) / np.sqrt(6) + R = np.array([ex, ey, ez]) + + lx = a * np.sqrt(2) * size[0] + ly = a * np.sqrt(3) * size[1] + lz = a * np.sqrt(6) * size[2] + + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) + positions = [] + max_range = int(max(size) * 3 + 5) + + for i in range(-max_range, max_range + 1): + for j in range(-max_range, max_range + 1): + for k in range(-max_range, max_range + 1): + for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: + pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) + pos_oriented = R @ pos_cubic + frac_x = pos_oriented[0] / lx + frac_y = pos_oriented[1] / ly + frac_z = pos_oriented[2] / lz + eps = 1e-8 + if (0 - eps <= frac_x < 1 - eps and + 0 - eps <= frac_y < 1 - eps and + 0 - eps <= frac_z < 1 - eps): + positions.append(pos_oriented) + + positions = np.array(positions) + _, unique_idx = np.unique(np.round(positions, decimals=6), axis=0, return_index=True) + positions = positions[unique_idx] + + atoms = Atoms(symbols=['Fe'] * len(positions), positions=positions, cell=cell, pbc=True) + atoms.wrap() + return atoms + + +def create_sfe_112_structure(lattice_parameter: float) -> Atoms: + """Create structure for {112}<111> stacking fault calculation.""" + a = lattice_parameter + size = (15, 1, 1) + + ex = np.array([1, 1, -2]) / np.sqrt(6) + ey = np.array([-1, 1, 0]) / np.sqrt(2) + ez = np.array([1, 1, 1]) / np.sqrt(3) + R = np.array([ex, ey, ez]) + + lx = a * np.sqrt(6) * size[0] + ly = a * np.sqrt(2) * size[1] + lz = a * np.sqrt(3) * size[2] + + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) + positions = [] + max_range = int(max(size) * 3 + 5) + + for i in range(-max_range, max_range + 1): + for j in range(-max_range, max_range + 1): + for k in range(-max_range, max_range + 1): + for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: + pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) + pos_oriented = R @ pos_cubic + frac_x = pos_oriented[0] / lx + frac_y = pos_oriented[1] / ly + frac_z = pos_oriented[2] / lz + eps = 1e-8 + if (0 - eps <= frac_x < 1 - eps and + 0 - eps <= frac_y < 1 - eps and + 0 - eps <= frac_z < 1 - eps): + positions.append(pos_oriented) + + positions = np.array(positions) + _, unique_idx = np.unique(np.round(positions, decimals=6), axis=0, return_index=True) + positions = positions[unique_idx] + + atoms = Atoms(symbols=['Fe'] * len(positions), positions=positions, cell=cell, pbc=True) + atoms.wrap() + return atoms + + +# ============================================================================= +# Dislocation Utilities +# ============================================================================= + +def create_oriented_bcc_cell( + lattice_parameter: float, + orient_x: np.ndarray, + orient_y: np.ndarray, + orient_z: np.ndarray, + size: tuple[int, int, int], + symbol: str = 'Fe', + center_cell: bool = True +) -> Atoms: + """Create an oriented BCC supercell.""" + a = lattice_parameter + ox = orient_x / np.linalg.norm(orient_x) + oy = orient_y / np.linalg.norm(orient_y) + oz = orient_z / np.linalg.norm(orient_z) + R = np.array([ox, oy, oz]) + + len_x = np.linalg.norm(orient_x) + len_y = np.linalg.norm(orient_y) + len_z = np.linalg.norm(orient_z) + + half_lx = a * len_x * size[0] + half_ly = a * len_y * size[1] / 2 + half_lz = a * len_z * size[2] / 2 + + lx = 2 * half_lx + ly = 2 * half_ly + lz = 2 * half_lz + + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) + positions = [] + max_range = int(max(size) * max(len_x, len_y, len_z) + 10) + + for i in range(-max_range, max_range + 1): + for j in range(-max_range, max_range + 1): + for k in range(-max_range, max_range + 1): + for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: + pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) + pos_oriented = R @ pos_cubic + eps = 1e-8 + if (-half_lx - eps <= pos_oriented[0] < half_lx - eps and + -half_ly - eps <= pos_oriented[1] < half_ly - eps and + -half_lz - eps <= pos_oriented[2] < half_lz - eps): + positions.append(pos_oriented) + + if len(positions) == 0: + raise ValueError("No atoms found in the oriented cell") + + positions = np.array(positions) + _, unique_idx = np.unique(np.round(positions, decimals=6), axis=0, return_index=True) + positions = positions[unique_idx] + + if not center_cell: + positions[:, 0] += half_lx + positions[:, 1] += half_ly + positions[:, 2] += half_lz + + atoms = Atoms(symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=[True, True, False]) + atoms.info['cell_center'] = np.array([0, 0, 0]) if center_cell else np.array([half_lx, half_ly, half_lz]) + atoms.info['half_dims'] = np.array([half_lx, half_ly, half_lz]) + + return atoms + + +def create_dislocation_cell( + lattice_parameter: float, + dislocation_type: str, + symbol: str = 'Fe' +) -> Atoms: + """Create a cell for dislocation simulation.""" + if dislocation_type not in DISLOCATION_CONFIGS: + raise ValueError(f"Unknown dislocation type: {dislocation_type}") + config = DISLOCATION_CONFIGS[dislocation_type] + return create_oriented_bcc_cell( + lattice_parameter, config['orient_x'], config['orient_y'], config['orient_z'], + config['size'], symbol, center_cell=True + ) + + +def get_dislocation_info(dislocation_type: str) -> dict[str, Any]: + """Get information about a dislocation type.""" + if dislocation_type not in DISLOCATION_CONFIGS: + raise ValueError(f"Unknown dislocation type: {dislocation_type}") + return DISLOCATION_CONFIGS[dislocation_type].copy() + + +def apply_screw_displacement(atoms: Atoms, burgers_magnitude: float) -> None: + """Apply screw dislocation displacement field.""" + positions = atoms.get_positions() + cell = atoms.get_cell() + + if 'half_dims' in atoms.info: + half_lx = atoms.info['half_dims'][0] + x_min, x_max = -half_lx, half_lx + z_mid = 0 + else: + x_min, x_max = 0, cell[0, 0] + z_mid = cell[2, 2] / 2 + + upper_mask = positions[:, 2] > z_mid + x = positions[upper_mask, 0] + fraction = (x - x_min) / (x_max - x_min) + displacement = -burgers_magnitude + fraction * burgers_magnitude + positions[upper_mask, 1] += displacement + atoms.set_positions(positions) + + +def delete_overlapping_atoms(atoms: Atoms, cutoff: float = 0.5) -> Atoms: + """Delete atoms that are too close to each other.""" + if len(atoms) == 0: + return atoms + cutoffs = [cutoff / 2] * len(atoms) + nl = NeighborList(cutoffs, self_interaction=False, bothways=False) + nl.update(atoms) + to_delete = set() + for i in range(len(atoms)): + indices, _ = nl.get_neighbors(i) + for j in indices: + if j > i: + to_delete.add(j) + keep_mask = np.ones(len(atoms), dtype=bool) + keep_mask[list(to_delete)] = False + return atoms[keep_mask] + + +def apply_edge_displacement( + atoms: Atoms, + burgers_magnitude: float, + lattice_parameter: float, + delete_half_plane: bool = True +) -> Atoms: + """Apply edge dislocation displacement.""" + positions = atoms.get_positions() + cell = atoms.get_cell() + + if 'half_dims' in atoms.info: + half_ly = atoms.info['half_dims'][1] + y_min, y_max = -half_ly, half_ly + z_mid = 0 + else: + y_min, y_max = 0, cell[1, 1] + z_mid = cell[2, 2] / 2 + + a = lattice_parameter + + if delete_half_plane: + ymindip = -0.6 * a + ymaxdip = 0.1 * a + keep_mask = ~((positions[:, 1] >= ymindip) & (positions[:, 1] <= ymaxdip) & (positions[:, 2] < z_mid)) + atoms = atoms[keep_mask] + positions = atoms.get_positions() + + quarter_b = 0.5 * a + mask_ll = (positions[:, 1] < 0) & (positions[:, 2] < z_mid) + if np.any(mask_ll): + y = positions[mask_ll, 1] + fraction = (y - y_min) / (0 - y_min) + positions[mask_ll, 1] += fraction * quarter_b + + mask_lr = (positions[:, 1] >= 0) & (positions[:, 2] < z_mid) + if np.any(mask_lr): + y = positions[mask_lr, 1] + fraction = (y - 0) / (y_max - 0) + positions[mask_lr, 1] += (1 - fraction) * (-quarter_b) + + atoms.set_positions(positions) + atoms = delete_overlapping_atoms(atoms, cutoff=0.5) + return atoms + + +def apply_mixed_displacement( + atoms: Atoms, + burgers_magnitude: float, + lattice_parameter: float, + screw_fraction: float = 0.7 +) -> Atoms: + """Apply mixed dislocation displacement.""" + edge_fraction = 1 - screw_fraction + if edge_fraction > 0: + edge_magnitude = burgers_magnitude * edge_fraction + atoms = apply_edge_displacement(atoms, edge_magnitude, lattice_parameter, delete_half_plane=True) + if screw_fraction > 0: + screw_magnitude = burgers_magnitude * screw_fraction + apply_screw_displacement(atoms, screw_magnitude) + return atoms + + +# ============================================================================= +# LEFM / Crack Utilities +# ============================================================================= + +def get_crack_orientation(crack_system: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Get crystallographic orientation vectors for a crack system.""" + if crack_system == 1: + return np.array([0, 0, 1]), np.array([1, 0, 0]), np.array([0, 1, 0]) + elif crack_system == 2: + return np.array([0, -1, 1]), np.array([1, 0, 0]), np.array([0, 1, 1]) + elif crack_system == 3: + return np.array([1, -1, 0]), np.array([1, 1, 0]), np.array([0, 0, 1]) + elif crack_system == 4: + return np.array([0, 0, -1]), np.array([1, 1, 0]), np.array([1, -1, 0]) + else: + raise ValueError(f"Invalid crack system: {crack_system}") + + +def aniso_disp_solution( + C: np.ndarray, + a1: np.ndarray, + a2: np.ndarray, + a3: np.ndarray, + surfE: float +) -> tuple: + """Solve the anisotropic LEFM displacement field coefficients.""" + S = np.linalg.inv(C) + a1 = a1 / np.linalg.norm(a1) + a2 = a2 / np.linalg.norm(a2) + a3 = a3 / np.linalg.norm(a3) + Q = np.array([a1, a2, a3]) + + K1 = np.array([ + [Q[0, 0]**2, Q[0, 1]**2, Q[0, 2]**2], + [Q[1, 0]**2, Q[1, 1]**2, Q[1, 2]**2], + [Q[2, 0]**2, Q[2, 1]**2, Q[2, 2]**2] + ]) + K2 = np.array([ + [Q[0, 1]*Q[0, 2], Q[0, 2]*Q[0, 0], Q[0, 0]*Q[0, 1]], + [Q[1, 1]*Q[1, 2], Q[1, 2]*Q[1, 0], Q[1, 0]*Q[1, 1]], + [Q[2, 1]*Q[2, 2], Q[2, 2]*Q[2, 0], Q[2, 0]*Q[2, 1]] + ]) + K3 = np.array([ + [Q[1, 0]*Q[2, 0], Q[1, 1]*Q[2, 1], Q[1, 2]*Q[2, 2]], + [Q[2, 0]*Q[0, 0], Q[2, 1]*Q[0, 1], Q[2, 2]*Q[0, 2]], + [Q[0, 0]*Q[1, 0], Q[0, 1]*Q[1, 1], Q[0, 2]*Q[1, 2]] + ]) + K4 = np.array([ + [Q[1, 1]*Q[2, 2] + Q[1, 2]*Q[2, 1], Q[1, 2]*Q[2, 0] + Q[1, 0]*Q[2, 2], Q[1, 0]*Q[2, 1] + Q[1, 1]*Q[2, 0]], + [Q[2, 1]*Q[0, 2] + Q[2, 2]*Q[0, 1], Q[2, 2]*Q[0, 0] + Q[2, 0]*Q[0, 2], Q[2, 0]*Q[0, 1] + Q[2, 1]*Q[0, 0]], + [Q[0, 1]*Q[1, 2] + Q[0, 2]*Q[1, 1], Q[0, 2]*Q[1, 0] + Q[0, 0]*Q[1, 2], Q[0, 0]*Q[1, 1] + Q[0, 1]*Q[1, 0]] + ]) + + K_mat = np.vstack((np.hstack((K1, 2*K2)), np.hstack((K3, K4)))) + S_star = np.linalg.inv(K_mat).T @ S @ np.linalg.inv(K_mat) + + b_11 = (S_star[0, 0] * S_star[2, 2] - S_star[0, 2]**2) / S_star[2, 2] + b_22 = (S_star[1, 1] * S_star[2, 2] - S_star[1, 2]**2) / S_star[2, 2] + b_66 = (S_star[5, 5] * S_star[2, 2] - S_star[2, 5]**2) / S_star[2, 2] + b_12 = (S_star[0, 1] * S_star[2, 2] - S_star[0, 2] * S_star[1, 2]) / S_star[2, 2] + b_16 = (S_star[0, 5] * S_star[2, 2] - S_star[0, 2] * S_star[2, 5]) / S_star[2, 2] + b_26 = (S_star[1, 5] * S_star[2, 2] - S_star[1, 2] * S_star[2, 5]) / S_star[2, 2] + + B = np.sqrt((b_11 * b_22 / 2) * (np.sqrt(b_22 / b_11) + ((2 * b_12 + b_66) / (2 * b_11)))) + K_I = np.sqrt(2 * surfE * (1 / (B * 1000))) + G_I = 2 * surfE + + coefvct = [b_11, -2 * b_16, 2 * b_12 + b_66, -2 * b_26, b_22] + rt = np.roots(coefvct) + s = rt[np.imag(rt) >= 0] + if np.real(s[0]) < np.real(s[1]): + s[0], s[1] = s[1], s[0] + + p = np.array([b_11 * s[0]**2 + b_12 - b_16 * s[0], b_11 * s[1]**2 + b_12 - b_16 * s[1]]) + q = np.array([b_12 * s[0] + b_22 / s[0] - b_26, b_12 * s[1] + b_22 / s[1] - b_26]) + + return s, p, q, K_I, G_I + + +def compute_lefm_coefficients( + C11: float, + C12: float, + C44: float, + surface_energy: float, + crack_system: int +) -> dict[str, Any]: + """Compute LEFM coefficients for anisotropic crack analysis.""" + C = np.array([ + [C11, C12, C12, 0, 0, 0], + [C12, C11, C12, 0, 0, 0], + [C12, C12, C11, 0, 0, 0], + [0, 0, 0, C44, 0, 0], + [0, 0, 0, 0, C44, 0], + [0, 0, 0, 0, 0, C44] + ]) + a1, a2, a3 = get_crack_orientation(crack_system) + s, p, q, K_I, G_I = aniso_disp_solution(C, a1, a2, a3, surface_energy) + return {'s1': s[0], 's2': s[1], 'p1': p[0], 'p2': p[1], 'q1': q[0], 'q2': q[1], 'K_I': K_I, 'G_I': G_I} + + +def apply_crack_displacement( + positions: np.ndarray, + K: float, + coeffs: dict[str, Any], + crack_tip: tuple[float, float], + reference_positions: np.ndarray | None = None +) -> np.ndarray: + """Apply anisotropic LEFM crack displacement field to atomic positions.""" + s1, s2 = coeffs['s1'], coeffs['s2'] + p1, p2 = coeffs['p1'], coeffs['p2'] + q1, q2 = coeffs['q1'], coeffs['q2'] + xtip, ytip = crack_tip + + if reference_positions is None: + reference_positions = positions.copy() + + new_positions = positions.copy() + x = reference_positions[:, 0] - xtip + y = reference_positions[:, 1] - ytip + r = np.maximum(np.sqrt(x**2 + y**2), 1e-10) + theta = np.arctan2(y, x) + + coef = K * np.sqrt(2.0 * r / np.pi) + z1 = np.cos(theta) + s1 * np.sin(theta) + z2 = np.cos(theta) + s2 * np.sin(theta) + + sqrt_coef_1 = np.sqrt(z2.astype(complex)) + sqrt_coef_2 = np.sqrt(z1.astype(complex)) + denom = s1 - s2 + + coef_x = (s1 * p2 * sqrt_coef_1 - s2 * p1 * sqrt_coef_2) / denom + coef_y = (s1 * q2 * sqrt_coef_1 - s2 * q1 * sqrt_coef_2) / denom + + new_positions[:, 0] = positions[:, 0] + coef * np.real(coef_x) + new_positions[:, 1] = positions[:, 1] + coef * np.real(coef_y) + return new_positions + + +def compute_incremental_displacement( + positions: np.ndarray, + dK: float, + coeffs: dict[str, Any], + crack_tip: tuple[float, float] +) -> tuple[np.ndarray, np.ndarray]: + """Compute the incremental displacement for a K increment.""" + s1, s2 = coeffs['s1'], coeffs['s2'] + p1, p2 = coeffs['p1'], coeffs['p2'] + q1, q2 = coeffs['q1'], coeffs['q2'] + xtip, ytip = crack_tip + + x = positions[:, 0] - xtip + y = positions[:, 1] - ytip + r = np.maximum(np.sqrt(x**2 + y**2), 1e-10) + theta = np.arctan2(y, x) + + coef = dK * np.sqrt(2.0 * r / np.pi) + z1 = np.cos(theta) + s1 * np.sin(theta) + z2 = np.cos(theta) + s2 * np.sin(theta) + + sqrt_coef_1 = np.sqrt(z2.astype(complex)) + sqrt_coef_2 = np.sqrt(z1.astype(complex)) + denom = s1 - s2 + + coef_x = (s1 * p2 * sqrt_coef_1 - s2 * p1 * sqrt_coef_2) / denom + coef_y = (s1 * q2 * sqrt_coef_1 - s2 * q1 * sqrt_coef_2) / denom + + return coef * np.real(coef_x), coef * np.real(coef_y) + + +def create_crack_cell( + lattice_parameter: float, + crack_system: int +) -> tuple[Atoms, tuple[float, float], float]: + """Create a circular domain for crack simulation.""" + config = CRACK_SYSTEMS_CONFIG[crack_system] + a0 = lattice_parameter + + ox = config['orient_x'] / np.linalg.norm(config['orient_x']) + oy = config['orient_y'] / np.linalg.norm(config['orient_y']) + oz = config['orient_z'] / np.linalg.norm(config['orient_z']) + R = np.array([ox, oy, oz]) + + len_x = np.linalg.norm(config['orient_x']) + len_y = np.linalg.norm(config['orient_y']) + len_z = np.linalg.norm(config['orient_z']) + + box_x, box_y = config['box_size'] + lx = 2 * a0 * len_x * box_x + ly = 2 * a0 * len_y * box_y + lz = a0 * len_z + + positions = [] + max_range = int(max(box_x, box_y) * max(len_x, len_y) + 10) + + for i in range(-max_range, max_range + 1): + for j in range(-max_range, max_range + 1): + for k in range(2): + for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: + pos_cubic = a0 * np.array([i + basis[0], j + basis[1], k + basis[2]]) + pos_oriented = R @ pos_cubic + if (-lx/2 <= pos_oriented[0] < lx/2 and + -ly/2 <= pos_oriented[1] < ly/2 and + 0 <= pos_oriented[2] < lz): + positions.append(pos_oriented) + + positions = np.array(positions) + _, unique_idx = np.unique(np.round(positions, decimals=5), axis=0, return_index=True) + positions = positions[unique_idx] + + tip_x = a0 * config['tip_factors'][0] * 0.25 + tip_y = a0 * config['tip_factors'][1] * 0.25 + + radius = min(lx/2, ly/2) - 1.0 + r = np.sqrt((positions[:, 0] - tip_x)**2 + (positions[:, 1] - tip_y)**2) + keep_mask = r < radius + positions = positions[keep_mask] + + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) + atoms = Atoms(symbols=['Fe'] * len(positions), positions=positions, cell=cell, pbc=[False, False, True]) + atoms.center() + + center = np.array([lx/2, ly/2, lz/2]) + crack_tip = (tip_x + center[0] - lx/2, tip_y + center[1] - ly/2) + + return atoms, crack_tip, radius + + +# ============================================================================= +# Elastic Calculation Utilities +# ============================================================================= + +def apply_strain(atoms: Atoms, strain_matrix: np.ndarray) -> Atoms: + """Apply a strain to the atoms object.""" + atoms_strained = atoms.copy() + F = np.eye(3) + strain_matrix + new_cell = atoms_strained.cell @ F.T + atoms_strained.set_cell(new_cell, scale_atoms=True) + return atoms_strained + + +def get_voigt_strain(direction: int, magnitude: float) -> np.ndarray: + """Get the strain tensor for a given Voigt direction (1-6).""" + strain = np.zeros((3, 3)) + + if direction == 1: + strain[0, 0] = magnitude + elif direction == 2: + strain[1, 1] = magnitude + elif direction == 3: + strain[2, 2] = magnitude + elif direction == 4: + strain[1, 2] = magnitude / 2 + strain[2, 1] = magnitude / 2 + elif direction == 5: + strain[0, 2] = magnitude / 2 + strain[2, 0] = magnitude / 2 + elif direction == 6: + strain[0, 1] = magnitude / 2 + strain[1, 0] = magnitude / 2 + + return strain + + +def calculate_surface_energy(E_slab: float, E_bulk: float, area: float) -> float: + """Calculate surface energy in J/m^2.""" + delta_E = E_slab - E_bulk + return delta_E * EV_TO_J / (2 * area * ANGSTROM_TO_M**2) From 0605560b4bf7a57cf684736d08fdf724736dacc2 Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Fri, 30 Jan 2026 11:03:18 +0000 Subject: [PATCH 02/12] fixes for cracks and discloations cell sizes --- .../iron_properties/app_iron_properties.py | 234 ++- .../iron_properties/calc_iron_properties.py | 821 ++++++---- ml_peg/calcs/utils/iron_utils.py | 1318 ++++++++++++----- 3 files changed, 1653 insertions(+), 720 deletions(-) diff --git a/ml_peg/app/physicality/iron_properties/app_iron_properties.py b/ml_peg/app/physicality/iron_properties/app_iron_properties.py index e4656580c..35e2da98c 100644 --- a/ml_peg/app/physicality/iron_properties/app_iron_properties.py +++ b/ml_peg/app/physicality/iron_properties/app_iron_properties.py @@ -2,12 +2,16 @@ from __future__ import annotations -from dash import Dash, dcc +from dash import Dash, Input, Output, callback, dcc from dash.dcc import Loading +from dash.exceptions import PreventUpdate from dash.html import Div, Label +import pandas as pd +import plotly.graph_objects as go from ml_peg.app import APP_ROOT from ml_peg.app.base_app import BaseApp +from ml_peg.calcs import CALCS_ROOT from ml_peg.models.get_models import get_model_names from ml_peg.models.models import current_models @@ -15,9 +19,176 @@ MODELS = get_model_names(current_models) BENCHMARK_NAME = "Iron Properties" DATA_PATH = APP_ROOT / "data" / "physicality" / "iron_properties" -DOCS_URL = ( - "https://ddmms.github.io/ml-peg/user_guide/benchmarks/physicality.html#iron-properties" -) +CALC_PATH = CALCS_ROOT / "physicality" / "iron_properties" / "outputs" +DOCS_URL = "https://ddmms.github.io/ml-peg/user_guide/benchmarks/physicality.html#iron-properties" + + +def _load_curve_data(model_name: str, curve_type: str) -> pd.DataFrame | None: + """ + Load curve data for a model. + + Parameters + ---------- + model_name : str + Name of the model. + curve_type : str + Type of curve to load (e.g., 'eos', 'bain', 'sfe_110'). + + Returns + ------- + pd.DataFrame or None + DataFrame with curve data, or None if not found. + """ + model_dir = CALC_PATH / model_name + if not model_dir.exists(): + return None + + file_map = { + "eos": "eos_curve.csv", + "bain": "bain_path.csv", + "sfe_110": "sfe_110_curve.csv", + "sfe_112": "sfe_112_curve.csv", + "crack_1": "crack_1_KE.csv", + "crack_2": "crack_2_KE.csv", + "crack_3": "crack_3_KE.csv", + "crack_4": "crack_4_KE.csv", + } + + filename = file_map.get(curve_type) + if not filename: + return None + + csv_path = model_dir / filename + if not csv_path.exists(): + return None + + return pd.read_csv(csv_path) + + +def _create_figure(df: pd.DataFrame, curve_type: str, model_name: str) -> go.Figure: + """ + Create plotly figure for the given curve type. + + Parameters + ---------- + df : pd.DataFrame + DataFrame containing the curve data. + curve_type : str + Type of curve to plot (e.g., 'eos', 'bain', 'sfe_110'). + model_name : str + Name of the model for the title. + + Returns + ------- + go.Figure + Plotly figure object. + """ + fig = go.Figure() + + if curve_type == "eos": + fig.add_trace( + go.Scatter( + x=df["volume"], + y=df["energy"], + mode="lines+markers", + name=model_name, + line={"width": 2}, + marker={"size": 6}, + ) + ) + fig.update_layout( + title=f"Equation of State - {model_name}", + xaxis_title="Volume (ų/atom)", + yaxis_title="Energy (eV/atom)", + ) + + elif curve_type == "bain": + fig.add_trace( + go.Scatter( + x=df["ca_ratio"], + y=df["energy_meV"], + mode="lines+markers", + name=model_name, + line={"width": 2}, + marker={"size": 6}, + ) + ) + # Add vertical lines for BCC and FCC + fig.add_vline(x=1.0, line_dash="dash", line_color="gray", annotation_text="BCC") + fig.add_vline( + x=1.414, line_dash="dash", line_color="gray", annotation_text="FCC" + ) + fig.update_layout( + title=f"Bain Path - {model_name}", + xaxis_title="c/a ratio", + yaxis_title="Energy (meV/atom)", + ) + + elif curve_type == "sfe_110": + fig.add_trace( + go.Scatter( + x=df["displacement"], + y=df["sfe_J_per_m2"], + mode="lines+markers", + name=model_name, + line={"width": 2}, + marker={"size": 6}, + ) + ) + fig.update_layout( + title=f"Stacking Fault Energy {{110}}<111> - {model_name}", + xaxis_title="Displacement (Å)", + yaxis_title="SFE (J/m²)", + ) + + elif curve_type == "sfe_112": + fig.add_trace( + go.Scatter( + x=df["displacement"], + y=df["sfe_J_per_m2"], + mode="lines+markers", + name=model_name, + line={"width": 2}, + marker={"size": 6}, + ) + ) + fig.update_layout( + title=f"Stacking Fault Energy {{112}}<111> - {model_name}", + xaxis_title="Displacement (Å)", + yaxis_title="SFE (J/m²)", + ) + + elif curve_type.startswith("crack_"): + crack_names = { + "crack_1": "(100)[010]", + "crack_2": "(100)[001]", + "crack_3": "(110)[001]", + "crack_4": "(110)[1-10]", + } + crack_name = crack_names.get(curve_type, curve_type) + fig.add_trace( + go.Scatter( + x=df["K"], + y=df["energy"], + mode="lines+markers", + name=model_name, + line={"width": 2}, + marker={"size": 6}, + ) + ) + fig.update_layout( + title=f"Crack K-E Curve {crack_name} - {model_name}", + xaxis_title="K (MPa√m)", + yaxis_title="Energy (eV)", + ) + + fig.update_layout( + template="plotly_white", + showlegend=True, + height=500, + ) + + return fig class IronPropertiesApp(BaseApp): @@ -25,7 +196,54 @@ class IronPropertiesApp(BaseApp): def register_callbacks(self) -> None: """Register callbacks for curve visualization.""" - pass # Curve visualization to be added via plot_from_table_column + model_dropdown_id = f"{BENCHMARK_NAME}-model-dropdown" + curve_dropdown_id = f"{BENCHMARK_NAME}-curve-dropdown" + figure_id = f"{BENCHMARK_NAME}-figure" + + @callback( + Output(figure_id, "figure"), + Input(model_dropdown_id, "value"), + Input(curve_dropdown_id, "value"), + ) + def update_figure(model_name: str, curve_type: str) -> go.Figure: + """ + Update figure based on model and curve selection. + + Parameters + ---------- + model_name : str + Name of the selected model. + curve_type : str + Type of curve to display. + + Returns + ------- + go.Figure + Updated plotly figure. + """ + if not model_name or not curve_type: + raise PreventUpdate + + df = _load_curve_data(model_name, curve_type) + if df is None or df.empty: + # Return empty figure with message + fig = go.Figure() + fig.add_annotation( + text=f"No data available for {model_name} - {curve_type}", + xref="paper", + yref="paper", + x=0.5, + y=0.5, + showarrow=False, + font={"size": 16}, + ) + fig.update_layout( + template="plotly_white", + height=500, + ) + return fig + + return _create_figure(df, curve_type, model_name) def get_app() -> IronPropertiesApp: @@ -86,10 +304,12 @@ def get_app() -> IronPropertiesApp: "Includes equation of state (lattice parameter, bulk modulus), " "elastic constants (C11, C12, C44), Bain path (BCC-FCC transformation), " "vacancy formation energy, surface energies (100, 110, 111, 112), " - "generalized stacking fault energy curves for {110}<111> and {112}<111> slip systems, " + "generalized stacking fault energy curves for {110}<111> and " + "{112}<111> slip systems, " "dislocation core energies for 5 dislocation types (edge, mixed, screw), " "and crack K-tests for 4 crack systems. " - "This benchmark is computationally expensive and marked with @pytest.mark.slow." + "This benchmark is computationally expensive and marked with " + "@pytest.mark.slow." ), docs_url=DOCS_URL, table_path=DATA_PATH / "iron_properties_metrics_table.json", diff --git a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py index 4b8e8b9fc..7ba0af1c2 100644 --- a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py +++ b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py @@ -1,4 +1,5 @@ -"""Run calculations for BCC iron properties benchmark. +""" +Run calculations for BCC iron properties benchmark. This benchmark computes fundamental properties of BCC iron including: - Equation of state (lattice parameter, bulk modulus) @@ -12,8 +13,8 @@ This benchmark is computationally expensive and marked with @pytest.mark.slow. -Reference ---------- +References +---------- Zhang, L., Csányi, G., van der Giessen, E., & Maresca, F. (2023). Efficiency, Accuracy, and Transferability of Machine Learning Potentials: Application to Dislocations and Cracks in Iron. @@ -26,51 +27,47 @@ from pathlib import Path from typing import Any -import numpy as np -import pandas as pd -import pytest -from ase import Atoms from ase.build import bulk from ase.constraints import FixAtoms, FixedLine from ase.filters import ExpCellFilter from ase.optimize import BFGS, FIRE +import numpy as np +import pandas as pd +import pytest from ml_peg.calcs.utils.iron_utils import ( - # Constants - EV_TO_J, - ANGSTROM_TO_M, - EV_PER_A2_TO_J_PER_M2, - EV_PER_A3_TO_GPA, + CRACK_SYSTEMS_CONFIG, # Configurations - DISLOCATION_CONFIGS, DISLOCATION_TYPES, - CRACK_SYSTEMS_CONFIG, - # EOS fitting - fit_eos, + EV_PER_A2_TO_J_PER_M2, + EV_PER_A3_TO_GPA, + # Constants + apply_crack_displacement, + apply_edge_displacement, + apply_mixed_displacement, + apply_screw_displacement, + # Elastic utilities + apply_strain, + calculate_surface_energy, + compute_incremental_displacement, + # LEFM utilities + compute_lefm_coefficients, + create_bain_cell, # Structure creation create_bcc_supercell, - create_bain_cell, + create_crack_cell, + # Dislocation utilities + create_dislocation_cell, + create_sfe_110_structure, + create_sfe_112_structure, create_surface_100, create_surface_110, create_surface_111, create_surface_112, - create_sfe_110_structure, - create_sfe_112_structure, - # Elastic utilities - apply_strain, - get_voigt_strain, - calculate_surface_energy, - # Dislocation utilities - create_dislocation_cell, + # EOS fitting + fit_eos, get_dislocation_info, - apply_screw_displacement, - apply_edge_displacement, - apply_mixed_displacement, - # LEFM utilities - compute_lefm_coefficients, - apply_crack_displacement, - compute_incremental_displacement, - create_crack_cell, + get_voigt_strain, ) from ml_peg.models.get_models import load_models from ml_peg.models.models import current_models @@ -123,52 +120,55 @@ # EOS Calculation # ============================================================================= + def run_eos_calculation(calc: Any) -> dict[str, Any]: """ Run the energy-volume curve calculation. - + Parameters ---------- calc ASE calculator object. - + Returns ------- dict Dictionary with EOS results including a0, B0, V0, E0, volumes, energies. """ # Generate lattice parameters: 2.834 - 0.05 + (0.1/30)*i for i in 1..30 - lattice_params = np.array([2.834 - 0.05 + 0.1 / 30 * i for i in range(1, EOS_NUM_POINTS + 1)]) - + lattice_params = np.array( + [2.834 - 0.05 + 0.1 / 30 * i for i in range(1, EOS_NUM_POINTS + 1)] + ) + volumes = [] energies = [] - + for lat in lattice_params: - atoms = bulk('Fe', 'bcc', a=lat, cubic=True) + atoms = bulk("Fe", "bcc", a=lat, cubic=True) atoms.calc = calc - + energy = atoms.get_potential_energy() volume = atoms.get_volume() - + n_atoms = len(atoms) volumes.append(volume / n_atoms) energies.append(energy / n_atoms) - + volumes = np.array(volumes) energies = np.array(energies) - + # Fit Birch-Murnaghan EOS eos_results = fit_eos(volumes, energies) - + return { - 'volumes': volumes.tolist(), - 'energies': energies.tolist(), - 'lattice_params': lattice_params.tolist(), - 'a0': eos_results['a0'], - 'E0': eos_results['E0'], - 'B0': eos_results['B0'], - 'Bp': eos_results['Bp'], - 'V0': eos_results['V0'], + "volumes": volumes.tolist(), + "energies": energies.tolist(), + "lattice_params": lattice_params.tolist(), + "a0": eos_results["a0"], + "E0": eos_results["E0"], + "B0": eos_results["B0"], + "Bp": eos_results["Bp"], + "V0": eos_results["V0"], } @@ -176,17 +176,18 @@ def run_eos_calculation(calc: Any) -> dict[str, Any]: # Elastic Constants Calculation # ============================================================================= + def run_elastic_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: """ Calculate elastic constants using the stress-strain method. - + Parameters ---------- calc ASE calculator object. lattice_parameter Equilibrium lattice parameter from EOS fit. - + Returns ------- dict @@ -195,59 +196,59 @@ def run_elastic_calculation(calc: Any, lattice_parameter: float) -> dict[str, An # Create supercell atoms_ref = create_bcc_supercell(lattice_parameter, ELASTIC_SUPERCELL_SIZE) atoms_ref.calc = calc - + # Box relaxation ecf = ExpCellFilter(atoms_ref) opt = BFGS(ecf, logfile=None) opt.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) - + # Elastic constant matrix - C = np.zeros((6, 6)) - + C = np.zeros((6, 6)) # noqa: N806 + for i in range(6): direction = i + 1 - + # Positive strain strain_pos = get_voigt_strain(direction, ELASTIC_STRAIN) atoms_pos = apply_strain(atoms_ref.copy(), strain_pos) atoms_pos.calc = calc - + opt_pos = BFGS(atoms_pos, logfile=None) opt_pos.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) stress_pos = -atoms_pos.get_stress(voigt=True) - + # Negative strain strain_neg = get_voigt_strain(direction, -ELASTIC_STRAIN) atoms_neg = apply_strain(atoms_ref.copy(), strain_neg) atoms_neg.calc = calc - + opt_neg = BFGS(atoms_neg, logfile=None) opt_neg.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) stress_neg = -atoms_neg.get_stress(voigt=True) - + # Compute elastic constants delta_stress = stress_pos - stress_neg delta_strain = 2 * ELASTIC_STRAIN - + for j in range(6): C[j, i] = -delta_stress[j] / delta_strain * EV_PER_A3_TO_GPA - + # Symmetrize - C_sym = 0.5 * (C + C.T) - + C_sym = 0.5 * (C + C.T) # noqa: N806 + # Extract cubic averages - C11 = (C_sym[0, 0] + C_sym[1, 1] + C_sym[2, 2]) / 3 - C12 = (C_sym[0, 1] + C_sym[0, 2] + C_sym[1, 2]) / 3 - C44 = (C_sym[3, 3] + C_sym[4, 4] + C_sym[5, 5]) / 3 - + C11 = (C_sym[0, 0] + C_sym[1, 1] + C_sym[2, 2]) / 3 # noqa: N806 + C12 = (C_sym[0, 1] + C_sym[0, 2] + C_sym[1, 2]) / 3 # noqa: N806 + C44 = (C_sym[3, 3] + C_sym[4, 4] + C_sym[5, 5]) / 3 # noqa: N806 + bulk_modulus = (C11 + 2 * C12) / 3 - + return { - 'C11': C11, - 'C12': C12, - 'C44': C44, - 'bulk_modulus': bulk_modulus, - 'C_matrix': C_sym.tolist(), + "C11": C11, + "C12": C12, + "C44": C44, + "bulk_modulus": bulk_modulus, + "C_matrix": C_sym.tolist(), } @@ -255,17 +256,18 @@ def run_elastic_calculation(calc: Any, lattice_parameter: float) -> dict[str, An # Bain Path Calculation # ============================================================================= + def run_bain_path_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: """ Calculate the Bain path energy curve. - + Parameters ---------- calc ASE calculator object. lattice_parameter Equilibrium BCC lattice parameter. - + Returns ------- dict @@ -273,59 +275,59 @@ def run_bain_path_calculation(calc: Any, lattice_parameter: float) -> dict[str, """ # Generate c/a ratios: 0.7 + 0.02*i for i in 1..65 ca_ratios_target = np.array([0.7 + 0.02 * i for i in range(1, BAIN_NUM_POINTS + 1)]) - + ca_ratios = [] energies = [] - + for ratio in ca_ratios_target: atoms = create_bain_cell(lattice_parameter, ratio) atoms.calc = calc - + # Box relaxation ecf = ExpCellFilter(atoms, scalar_pressure=0.0) opt = BFGS(ecf, logfile=None) - + try: opt.run(fmax=1e-5, steps=10000) except Exception: pass - + # Additional atomic relaxation opt2 = BFGS(atoms, logfile=None) try: opt2.run(fmax=1e-5, steps=10000) except Exception: pass - + energy = atoms.get_potential_energy() cell = atoms.get_cell() ca_actual = cell[2, 2] / cell[1, 1] - + n_atoms = len(atoms) ca_ratios.append(ca_actual) energies.append(energy / n_atoms) - + ca_ratios = np.array(ca_ratios) energies = np.array(energies) - + # Normalize energies (subtract minimum, convert to meV) - E_min = np.min(energies) + E_min = np.min(energies) # noqa: N806 energies_norm = (energies - E_min) * 1000 # meV/atom - + # Find BCC point (c/a ≈ 1.0) and FCC point (c/a ≈ 1.414) idx_bcc = np.argmin(np.abs(ca_ratios - 1.0)) idx_fcc = np.argmin(np.abs(ca_ratios - np.sqrt(2))) - - E_bcc = energies_norm[idx_bcc] - E_fcc = energies_norm[idx_fcc] - + + E_bcc = energies_norm[idx_bcc] # noqa: N806 + E_fcc = energies_norm[idx_fcc] # noqa: N806 + return { - 'ca_ratios': ca_ratios.tolist(), - 'energies': energies.tolist(), - 'energies_meV': energies_norm.tolist(), - 'E_bcc_meV': E_bcc, - 'E_fcc_meV': E_fcc, - 'delta_E_meV': E_fcc - E_bcc, + "ca_ratios": ca_ratios.tolist(), + "energies": energies.tolist(), + "energies_meV": energies_norm.tolist(), + "E_bcc_meV": E_bcc, + "E_fcc_meV": E_fcc, + "delta_E_meV": E_fcc - E_bcc, } @@ -333,96 +335,133 @@ def run_bain_path_calculation(calc: Any, lattice_parameter: float) -> dict[str, # Vacancy Calculation # ============================================================================= + def run_vacancy_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: - """Calculate the vacancy formation energy.""" + """ + Calculate the vacancy formation energy. + + Parameters + ---------- + calc : Any + ASE calculator object. + lattice_parameter : float + Equilibrium lattice parameter from EOS fit. + + Returns + ------- + dict + Dictionary with vacancy results including E_vac, E_coh, E_perfect, E_defect. + """ atoms_perfect = create_bcc_supercell(lattice_parameter, VACANCY_SUPERCELL_SIZE) atoms_perfect.calc = calc - + n_atoms = len(atoms_perfect) - E_perfect = atoms_perfect.get_potential_energy() - E_coh = E_perfect / n_atoms - + E_perfect = atoms_perfect.get_potential_energy() # noqa: N806 + E_coh = E_perfect / n_atoms # noqa: N806 + atoms_defect = atoms_perfect.copy() del atoms_defect[0] atoms_defect.calc = calc - + opt = BFGS(atoms_defect, logfile=None) opt.run(fmax=VACANCY_FMAX, steps=10000) - E_defect = atoms_defect.get_potential_energy() - E_vac = (E_defect - E_perfect) + E_coh - - return {'E_vac': E_vac, 'E_coh': E_coh, 'E_perfect': E_perfect, 'E_defect': E_defect} + E_defect = atoms_defect.get_potential_energy() # noqa: N806 + E_vac = (E_defect - E_perfect) + E_coh # noqa: N806 + + return { + "E_vac": E_vac, + "E_coh": E_coh, + "E_perfect": E_perfect, + "E_defect": E_defect, + } # ============================================================================= # Surface Calculations # ============================================================================= + def run_surface_calculations(calc: Any, lattice_parameter: float) -> dict[str, Any]: - """Calculate surface energies for (100), (110), (111), (112) surfaces.""" + """ + Calculate surface energies for (100), (110), (111), (112) surfaces. + + Parameters + ---------- + calc : Any + ASE calculator object. + lattice_parameter : float + Equilibrium lattice parameter from EOS fit. + + Returns + ------- + dict + Dictionary with surface energies gamma_100, gamma_110, gamma_111, gamma_112. + """ surfaces = {} - + # (100) surface atoms_bulk = create_surface_100(lattice_parameter, layers=10, vacuum=0.0) atoms_bulk.calc = calc - E_bulk = atoms_bulk.get_potential_energy() + E_bulk = atoms_bulk.get_potential_energy() # noqa: N806 cell = atoms_bulk.get_cell() area = np.linalg.norm(np.cross(cell[0], cell[1])) - + atoms_slab = create_surface_100(lattice_parameter, layers=10, vacuum=SURFACE_VACUUM) atoms_slab.calc = calc opt = BFGS(atoms_slab, logfile=None) opt.run(fmax=SURFACE_FMAX, steps=10000) - E_slab = atoms_slab.get_potential_energy() - surfaces['100'] = calculate_surface_energy(E_slab, E_bulk, area) - + E_slab = atoms_slab.get_potential_energy() # noqa: N806 + surfaces["100"] = calculate_surface_energy(E_slab, E_bulk, area) + # (110) surface atoms_bulk = create_surface_110(lattice_parameter, layers=10, vacuum=0.0) atoms_bulk.calc = calc - E_bulk = atoms_bulk.get_potential_energy() + E_bulk = atoms_bulk.get_potential_energy() # noqa: N806 cell = atoms_bulk.get_cell() area = np.linalg.norm(np.cross(cell[0], cell[1])) - + atoms_slab = create_surface_110(lattice_parameter, layers=10, vacuum=SURFACE_VACUUM) atoms_slab.calc = calc opt = BFGS(atoms_slab, logfile=None) opt.run(fmax=SURFACE_FMAX, steps=10000) - E_slab = atoms_slab.get_potential_energy() - surfaces['110'] = calculate_surface_energy(E_slab, E_bulk, area) - + E_slab = atoms_slab.get_potential_energy() # noqa: N806 + surfaces["110"] = calculate_surface_energy(E_slab, E_bulk, area) + # (111) surface atoms_bulk = create_surface_111(lattice_parameter, size=(3, 15, 3), vacuum=0.0) atoms_bulk.calc = calc - E_bulk = atoms_bulk.get_potential_energy() + E_bulk = atoms_bulk.get_potential_energy() # noqa: N806 cell = atoms_bulk.get_cell() area = np.linalg.norm(np.cross(cell[0], cell[2])) - - atoms_slab = create_surface_111(lattice_parameter, size=(3, 15, 3), vacuum=SURFACE_VACUUM) + + atoms_slab = create_surface_111( + lattice_parameter, size=(3, 15, 3), vacuum=SURFACE_VACUUM + ) atoms_slab.calc = calc opt = BFGS(atoms_slab, logfile=None) opt.run(fmax=SURFACE_FMAX, steps=10000) - E_slab = atoms_slab.get_potential_energy() - surfaces['111'] = calculate_surface_energy(E_slab, E_bulk, area) - + E_slab = atoms_slab.get_potential_energy() # noqa: N806 + surfaces["111"] = calculate_surface_energy(E_slab, E_bulk, area) + # (112) surface atoms_bulk = create_surface_112(lattice_parameter, layers=15, vacuum=0.0) atoms_bulk.calc = calc - E_bulk = atoms_bulk.get_potential_energy() + E_bulk = atoms_bulk.get_potential_energy() # noqa: N806 cell = atoms_bulk.get_cell() area = np.linalg.norm(np.cross(cell[0], cell[1])) - + atoms_slab = create_surface_112(lattice_parameter, layers=15, vacuum=5.0) atoms_slab.calc = calc opt = BFGS(atoms_slab, logfile=None) opt.run(fmax=SURFACE_FMAX, steps=10000) - E_slab = atoms_slab.get_potential_energy() - surfaces['112'] = calculate_surface_energy(E_slab, E_bulk, area) - + E_slab = atoms_slab.get_potential_energy() # noqa: N806 + surfaces["112"] = calculate_surface_energy(E_slab, E_bulk, area) + return { - 'gamma_100': surfaces['100'], - 'gamma_110': surfaces['110'], - 'gamma_111': surfaces['111'], - 'gamma_112': surfaces['112'], + "gamma_100": surfaces["100"], + "gamma_110": surfaces["110"], + "gamma_111": surfaces["111"], + "gamma_112": surfaces["112"], } @@ -430,28 +469,42 @@ def run_surface_calculations(calc: Any, lattice_parameter: float) -> dict[str, A # Stacking Fault Energy Calculations # ============================================================================= + def run_sfe_110_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: - """Calculate GSFE curve for {110}<111> slip system.""" + """ + Calculate GSFE curve for {110}<111> slip system. + + Parameters + ---------- + calc : Any + ASE calculator object. + lattice_parameter : float + Equilibrium lattice parameter from EOS fit. + + Returns + ------- + dict + Dictionary with displacements, sfe_J_per_m2, and max_sfe. + """ atoms = create_sfe_110_structure(lattice_parameter) atoms.calc = calc - + cell = atoms.get_cell() ly = cell[1, 1] lz = cell[2, 2] area = ly * lz - + opt = BFGS(atoms, logfile=None) opt.run(fmax=SFE_FMAX, steps=10000) - E0 = atoms.get_potential_energy() - + E0 = atoms.get_potential_energy() # noqa: N806 + positions = atoms.get_positions() x_mid = (positions[:, 0].min() + positions[:, 0].max()) / 2 + 0.1 upper_mask = positions[:, 0] < x_mid upper_indices = np.where(upper_mask)[0] - + displacements = [0.0] - sfe_J_per_m2 = [0.0] - + sfe_j_per_m2 = [0.0] constraints = [FixedLine(idx, direction=[1, 0, 0]) for idx in range(len(atoms))] for step in range(1, SFE_110_STEPS + 1): @@ -460,142 +513,218 @@ def run_sfe_110_calculation(calc: Any, lattice_parameter: float) -> dict[str, An atoms.set_positions(positions) atoms.set_constraint(constraints) - + opt = BFGS(atoms, logfile=None) try: opt.run(fmax=SFE_FMAX, steps=10000) except Exception: pass - + atoms.set_constraint() - E = atoms.get_potential_energy() + E = atoms.get_potential_energy() # noqa: N806 sfe = (E - E0) / (2 * area) * EV_PER_A2_TO_J_PER_M2 - + displacements.append(step * SFE_STEP_SIZE) - sfe_J_per_m2.append(sfe) - - return {'displacements': displacements, 'sfe_J_per_m2': sfe_J_per_m2, 'max_sfe': max(sfe_J_per_m2)} + sfe_j_per_m2.append(sfe) + + return { + "displacements": displacements, + "sfe_J_per_m2": sfe_j_per_m2, + "max_sfe": max(sfe_j_per_m2), + } def run_sfe_112_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: - """Calculate GSFE curve for {112}<111> slip system.""" + """ + Calculate GSFE curve for {112}<111> slip system. + + Parameters + ---------- + calc : Any + ASE calculator object. + lattice_parameter : float + Equilibrium lattice parameter from EOS fit. + + Returns + ------- + dict + Dictionary with displacements, sfe_J_per_m2, and max_sfe. + """ atoms = create_sfe_112_structure(lattice_parameter) atoms.calc = calc - + cell = atoms.get_cell() ly = cell[1, 1] lz = cell[2, 2] area = ly * lz - + opt = BFGS(atoms, logfile=None) opt.run(fmax=SFE_FMAX, steps=10000) - E0 = atoms.get_potential_energy() - + E0 = atoms.get_potential_energy() # noqa: N806 + positions = atoms.get_positions() x_mid = (positions[:, 0].min() + positions[:, 0].max()) / 2 + 0.1 upper_mask = positions[:, 0] < x_mid upper_indices = np.where(upper_mask)[0] - + displacements = [0.0] - sfe_J_per_m2 = [0.0] - + sfe_j_per_m2 = [0.0] + constraints = [FixedLine(idx, direction=[1, 0, 0]) for idx in range(len(atoms))] for step in range(1, SFE_112_STEPS + 1): positions = atoms.get_positions() positions[upper_indices, 2] += SFE_STEP_SIZE atoms.set_positions(positions) - + atoms.set_constraint(constraints) - + opt = BFGS(atoms, logfile=None) try: opt.run(fmax=SFE_FMAX, steps=10000) except Exception: pass - + atoms.set_constraint() - E = atoms.get_potential_energy() + E = atoms.get_potential_energy() # noqa: N806 sfe = (E - E0) / (2 * area) * EV_PER_A2_TO_J_PER_M2 - + displacements.append(step * SFE_STEP_SIZE) - sfe_J_per_m2.append(sfe) - - return {'displacements': displacements, 'sfe_J_per_m2': sfe_J_per_m2, 'max_sfe': max(sfe_J_per_m2)} + sfe_j_per_m2.append(sfe) + + return { + "displacements": displacements, + "sfe_J_per_m2": sfe_j_per_m2, + "max_sfe": max(sfe_j_per_m2), + } # ============================================================================= # Dislocation Tests # ============================================================================= -def run_dislocation_test(calc: Any, a0: float, E_coh: float, dislocation_type: str) -> dict[str, Any]: - """Run dislocation test for a specific type.""" + +def run_dislocation_test( + calc: Any, a0: float, e_coh: float, dislocation_type: str +) -> dict[str, Any]: + """ + Run dislocation test for a specific type. + + Parameters + ---------- + calc : Any + ASE calculator object. + a0 : float + Equilibrium lattice parameter. + e_coh : float + Cohesive energy per atom. + dislocation_type : str + Type of dislocation (e.g., 'edge_100_010', 'screw_111'). + + Returns + ------- + dict + Dictionary with dislocation_type, name, core_energy, and n_atoms. + """ config = get_dislocation_info(dislocation_type) atoms = create_dislocation_cell(a0, dislocation_type) atoms.calc = calc - + atoms_perfect = atoms.copy() atoms_perfect.calc = calc - + # Box relaxation for perfect crystal with stress convergence loop mask = [True, True, False, False, False, True] - for iteration in range(DISLOCATION_MAX_ITERATIONS): + for _iteration in range(DISLOCATION_MAX_ITERATIONS): ecf = ExpCellFilter(atoms_perfect, mask=mask, scalar_pressure=0.0) opt = BFGS(ecf, logfile=None) opt.run(fmax=DISLOCATION_FMAX, steps=2000) - + # Check stress convergence (convert eV/ų to bar) stress = atoms_perfect.get_stress() * 160.2176621 * 10000 # bar - if abs(stress[0]) < DISLOCATION_STRESS_TOL and abs(stress[1]) < DISLOCATION_STRESS_TOL: + if ( + abs(stress[0]) < DISLOCATION_STRESS_TOL + and abs(stress[1]) < DISLOCATION_STRESS_TOL + ): break - + # Final relaxation with FIRE opt = FIRE(atoms_perfect, logfile=None) opt.run(fmax=DISLOCATION_FMAX, steps=5000) - - E_perfect = atoms_perfect.get_potential_energy() + + E_perfect = atoms_perfect.get_potential_energy() # noqa: N806 n_atoms_perfect = len(atoms_perfect) - E_coh_local = E_perfect / n_atoms_perfect - + E_coh_local = E_perfect / n_atoms_perfect # noqa: N806 + atoms = atoms_perfect.copy() atoms.calc = calc - - burgers_vec = config['burgers'] + + burgers_vec = config["burgers"] b_mag = a0 * np.linalg.norm(burgers_vec) - disl_type = config['type'] - - if disl_type == 'screw': + disl_type = config["type"] + + if disl_type == "screw": apply_screw_displacement(atoms, b_mag) - elif disl_type == 'edge': - atoms = apply_edge_displacement(atoms, b_mag, a0, delete_half_plane=True) + elif disl_type == "edge": + # Use LAMMPS-matched deletion region parameters from config + delete_axis = config.get("delete_axis", 1) + delete_min = config.get("delete_min", -0.6) + delete_max = config.get("delete_max", 0.1) + atoms = apply_edge_displacement( + atoms, + b_mag, + a0, + delete_half_plane=True, + delete_axis=delete_axis, + delete_min=delete_min, + delete_max=delete_max, + ) atoms.calc = calc - elif disl_type == 'mixed': - atoms = apply_mixed_displacement(atoms, b_mag, a0, screw_fraction=0.7) + elif disl_type == "mixed": + # Use LAMMPS-matched parameters from config + screw_frac = config.get("screw_fraction", 0.325568) + edge_fac = config.get("edge_factor", 0.5) + delete_axis = config.get("delete_axis", 0) + delete_min = config.get("delete_min", -0.5) + delete_max = config.get("delete_max", 0.1) + atoms = apply_mixed_displacement( + atoms, + b_mag, + a0, + screw_fraction=screw_frac, + edge_factor=edge_fac, + delete_axis=delete_axis, + delete_min=delete_min, + delete_max=delete_max, + ) atoms.calc = calc - + # Multi-stage relaxation of dislocation structure with stress convergence loop mask = [True, True, False, False, False, True] - for iteration in range(DISLOCATION_MAX_ITERATIONS): + for _iteration in range(DISLOCATION_MAX_ITERATIONS): ecf = ExpCellFilter(atoms, mask=mask, scalar_pressure=0.0) opt = BFGS(ecf, logfile=None) opt.run(fmax=DISLOCATION_FMAX, steps=2000) - + stress = atoms.get_stress() * 160.2176621 * 10000 # bar - if abs(stress[0]) < DISLOCATION_STRESS_TOL and abs(stress[1]) < DISLOCATION_STRESS_TOL: + if ( + abs(stress[0]) < DISLOCATION_STRESS_TOL + and abs(stress[1]) < DISLOCATION_STRESS_TOL + ): break - + # Final relaxation with FIRE opt = FIRE(atoms, logfile=None) opt.run(fmax=DISLOCATION_FMAX, steps=5000) - - E_disl = atoms.get_potential_energy() + + E_disl = atoms.get_potential_energy() # noqa: N806 n_atoms_disl = len(atoms) core_energy = E_disl - n_atoms_disl * E_coh_local - + return { - 'dislocation_type': dislocation_type, - 'name': config['name'], - 'core_energy': core_energy, - 'n_atoms': n_atoms_disl, + "dislocation_type": dislocation_type, + "name": config["name"], + "core_energy": core_energy, + "n_atoms": n_atoms_disl, } @@ -603,81 +732,106 @@ def run_dislocation_test(calc: Any, a0: float, E_coh: float, dislocation_type: s # Crack Tests # ============================================================================= + def run_crack_test( calc: Any, a0: float, elastic_constants: dict[str, float], surface_energies: dict[str, float], crack_system: int, - K_steps: int = CRACK_K_STEPS, + k_steps: int = CRACK_K_STEPS, ) -> dict[str, Any]: - """Run crack K-test for a specific system.""" + """ + Run crack K-test for a specific system. + + Parameters + ---------- + calc : Any + ASE calculator object. + a0 : float + Equilibrium lattice parameter. + elastic_constants : dict[str, float] + Dictionary with elastic constants C11, C12, C44. + surface_energies : dict[str, float] + Dictionary with surface energies for relevant surfaces. + crack_system : int + Crack system index (1-4). + k_steps : int, optional + Number of K steps for the K-test (default: CRACK_K_STEPS). + + Returns + ------- + dict + Dictionary with crack_system, name, K_Griffith, K_values, and energies. + """ config = CRACK_SYSTEMS_CONFIG[crack_system] - surface = config['surface'] + surface = config["surface"] surf_energy = surface_energies.get(surface, 2.0) - + coeffs = compute_lefm_coefficients( - elastic_constants['C11'], - elastic_constants['C12'], - elastic_constants['C44'], + elastic_constants["C11"], + elastic_constants["C12"], + elastic_constants["C44"], surf_energy, crack_system, ) - K_Griffith = coeffs['K_I'] - + k_griffith = coeffs["K_I"] + atoms, crack_tip, radius = create_crack_cell(a0, crack_system) atoms.calc = calc - + positions = atoms.get_positions() xtip, ytip = crack_tip - r = np.sqrt((positions[:, 0] - xtip)**2 + (positions[:, 1] - ytip)**2) + r = np.sqrt((positions[:, 0] - xtip) ** 2 + (positions[:, 1] - ytip) ** 2) boundary_mask = r > (radius - 10.0) boundary_indices = np.where(boundary_mask)[0] - + opt = BFGS(atoms, logfile=None) opt.run(fmax=1e-3, steps=10000) - + ref_positions = atoms.get_positions().copy() - - K_start = max(0.5, K_Griffith * 100 - 10) - K_stop = K_start + K_steps - - new_positions = apply_crack_displacement(atoms.get_positions(), K_start, coeffs, crack_tip, ref_positions) + + k_start = max(0.5, k_griffith * 100 - 10) + k_stop = k_start + k_steps + + new_positions = apply_crack_displacement( + atoms.get_positions(), k_start, coeffs, crack_tip, ref_positions + ) atoms.set_positions(new_positions) - + dx, dy = compute_incremental_displacement(ref_positions, 1.0, coeffs, crack_tip) - - K_values = [] + + k_values = [] energies = [] - + atoms.set_constraint(FixAtoms(indices=boundary_indices)) - dK = (K_stop - K_start) / K_steps if K_steps > 0 else 1.0 - - for i in range(K_steps + 1): - K = K_start + i * dK - + dk = (k_stop - k_start) / k_steps if k_steps > 0 else 1.0 + + for i in range(k_steps + 1): + k = k_start + i * dk + if i > 0: positions = atoms.get_positions() - positions[:, 0] += dx * dK - positions[:, 1] += dy * dK + positions[:, 0] += dx * dk + positions[:, 1] += dy * dk atoms.set_positions(positions) - + opt = BFGS(atoms, logfile=None) try: opt.run(fmax=1e-3, steps=5000) except Exception: pass - - E = atoms.get_potential_energy() - K_values.append(K) - energies.append(E) - + + energy = atoms.get_potential_energy() + k_values.append(k) + energies.append(energy) + return { - 'crack_system': crack_system, - 'name': config['name'], - 'K_Griffith': K_Griffith, - 'K_values': K_values, - 'energies': energies, + "crack_system": crack_system, + "name": config["name"], + "K_Griffith": k_griffith, + "K_values": k_values, + "energies": energies, } @@ -685,10 +839,11 @@ def run_crack_test( # Main Benchmark Function # ============================================================================= + def run_iron_properties(model_name: str, model: Any) -> None: """ Run the full iron properties benchmark for a single model. - + This benchmark includes: - Equation of state (lattice parameter, bulk modulus) - Elastic constants (C11, C12, C44) @@ -698,7 +853,7 @@ def run_iron_properties(model_name: str, model: Any) -> None: - Stacking fault energy curves (110, 112) - Dislocation core energies (5 types) - Crack K-tests (4 systems) - + Parameters ---------- model_name @@ -709,87 +864,101 @@ def run_iron_properties(model_name: str, model: Any) -> None: calc = model.get_calculator() write_dir = OUT_PATH / model_name write_dir.mkdir(parents=True, exist_ok=True) - + results: dict[str, Any] = {} - + # EOS calculation print(f"[{model_name}] Running EOS calculation...") eos_results = run_eos_calculation(calc) - results['eos'] = eos_results - a0 = eos_results['a0'] - E_coh = eos_results['E0'] - print(f"[{model_name}] Lattice parameter: {a0:.4f} Å, Bulk modulus: {eos_results['B0']:.1f} GPa") - + results["eos"] = eos_results + a0 = eos_results["a0"] + e_coh = eos_results["E0"] + print( + f"[{model_name}] Lattice parameter: {a0:.4f} Å, " + f"Bulk modulus: {eos_results['B0']:.1f} GPa" + ) + # Save EOS curve data - eos_df = pd.DataFrame({ - 'volume': eos_results['volumes'], - 'energy': eos_results['energies'], - }) + eos_df = pd.DataFrame( + { + "volume": eos_results["volumes"], + "energy": eos_results["energies"], + } + ) eos_df.to_csv(write_dir / "eos_curve.csv", index=False) - + # Elastic constants calculation print(f"[{model_name}] Running elastic constants calculation...") elastic_results = run_elastic_calculation(calc, a0) - results['elastic'] = elastic_results - print(f"[{model_name}] C11={elastic_results['C11']:.1f}, C12={elastic_results['C12']:.1f}, C44={elastic_results['C44']:.1f} GPa") - + results["elastic"] = elastic_results + print( + f"[{model_name}] C11={elastic_results['C11']:.1f}, " + f"C12={elastic_results['C12']:.1f}, C44={elastic_results['C44']:.1f} GPa" + ) + # Bain path calculation print(f"[{model_name}] Running Bain path calculation...") bain_results = run_bain_path_calculation(calc, a0) - results['bain_path'] = bain_results - + results["bain_path"] = bain_results + # Save Bain path data - bain_df = pd.DataFrame({ - 'ca_ratio': bain_results['ca_ratios'], - 'energy': bain_results['energies'], - 'energy_meV': bain_results['energies_meV'], - }) + bain_df = pd.DataFrame( + { + "ca_ratio": bain_results["ca_ratios"], + "energy": bain_results["energies"], + "energy_meV": bain_results["energies_meV"], + } + ) bain_df.to_csv(write_dir / "bain_path.csv", index=False) - + # Vacancy calculation print(f"[{model_name}] Running vacancy calculation...") vacancy_results = run_vacancy_calculation(calc, a0) - results['vacancy'] = vacancy_results + results["vacancy"] = vacancy_results print(f"[{model_name}] E_vac = {vacancy_results['E_vac']:.3f} eV") - + # Surface calculations print(f"[{model_name}] Running surface calculations...") surface_results = run_surface_calculations(calc, a0) - results['surfaces'] = surface_results - + results["surfaces"] = surface_results + # SFE 110 calculation print(f"[{model_name}] Running SFE 110 calculation...") sfe_110_results = run_sfe_110_calculation(calc, a0) - results['sfe_110'] = {'max_sfe': sfe_110_results['max_sfe']} - sfe_110_df = pd.DataFrame({ - 'displacement': sfe_110_results['displacements'], - 'sfe_J_per_m2': sfe_110_results['sfe_J_per_m2'], - }) + results["sfe_110"] = {"max_sfe": sfe_110_results["max_sfe"]} + sfe_110_df = pd.DataFrame( + { + "displacement": sfe_110_results["displacements"], + "sfe_J_per_m2": sfe_110_results["sfe_J_per_m2"], + } + ) sfe_110_df.to_csv(write_dir / "sfe_110_curve.csv", index=False) - + # SFE 112 calculation print(f"[{model_name}] Running SFE 112 calculation...") sfe_112_results = run_sfe_112_calculation(calc, a0) - results['sfe_112'] = {'max_sfe': sfe_112_results['max_sfe']} - sfe_112_df = pd.DataFrame({ - 'displacement': sfe_112_results['displacements'], - 'sfe_J_per_m2': sfe_112_results['sfe_J_per_m2'], - }) + results["sfe_112"] = {"max_sfe": sfe_112_results["max_sfe"]} + sfe_112_df = pd.DataFrame( + { + "displacement": sfe_112_results["displacements"], + "sfe_J_per_m2": sfe_112_results["sfe_J_per_m2"], + } + ) sfe_112_df.to_csv(write_dir / "sfe_112_curve.csv", index=False) - + # Dislocation tests print(f"[{model_name}] Running dislocation tests...") dislocation_results = {} for disl_type in DISLOCATION_TYPES: print(f"[{model_name}] {disl_type}...") try: - disl_result = run_dislocation_test(calc, a0, E_coh, disl_type) + disl_result = run_dislocation_test(calc, a0, e_coh, disl_type) dislocation_results[disl_type] = disl_result except Exception as e: print(f"[{model_name}] Error: {e}") - dislocation_results[disl_type] = {'error': str(e)} - results['dislocations'] = dislocation_results - + dislocation_results[disl_type] = {"error": str(e)} + results["dislocations"] = dislocation_results + # Crack K-tests print(f"[{model_name}] Running crack K-tests...") crack_results = {} @@ -797,54 +966,62 @@ def run_iron_properties(model_name: str, model: Any) -> None: print(f"[{model_name}] System {crack_sys}...") try: crack_result = run_crack_test( - calc, a0, elastic_results, - {'100': surface_results['gamma_100'], '110': surface_results['gamma_110']}, - crack_sys, K_steps=CRACK_K_STEPS, + calc, + a0, + elastic_results, + { + "100": surface_results["gamma_100"], + "110": surface_results["gamma_110"], + }, + crack_sys, + K_steps=CRACK_K_STEPS, ) crack_results[crack_sys] = { - 'name': crack_result['name'], - 'K_Griffith': crack_result['K_Griffith'], + "name": crack_result["name"], + "K_Griffith": crack_result["K_Griffith"], } - ke_df = pd.DataFrame({ - 'K': crack_result['K_values'], - 'energy': crack_result['energies'], - }) + ke_df = pd.DataFrame( + { + "K": crack_result["K_values"], + "energy": crack_result["energies"], + } + ) ke_df.to_csv(write_dir / f"crack_{crack_sys}_KE.csv", index=False) except Exception as e: print(f"[{model_name}] Error: {e}") - crack_results[crack_sys] = {'error': str(e)} - results['cracks'] = crack_results - + crack_results[crack_sys] = {"error": str(e)} + results["cracks"] = crack_results + # Save all results as JSON (write_dir / "results.json").write_text(json.dumps(results, indent=2, default=str)) - + # Save summary metrics summary: dict[str, Any] = { - 'a0': a0, - 'B0': eos_results['B0'], - 'C11': elastic_results['C11'], - 'C12': elastic_results['C12'], - 'C44': elastic_results['C44'], - 'E_bcc_fcc_meV': bain_results['delta_E_meV'], - 'E_vac': vacancy_results['E_vac'], - 'gamma_100': surface_results['gamma_100'], - 'gamma_110': surface_results['gamma_110'], - 'gamma_111': surface_results['gamma_111'], - 'gamma_112': surface_results['gamma_112'], - 'max_sfe_110': sfe_110_results['max_sfe'], - 'max_sfe_112': sfe_112_results['max_sfe'], + "a0": a0, + "B0": eos_results["B0"], + "C11": elastic_results["C11"], + "C12": elastic_results["C12"], + "C44": elastic_results["C44"], + "E_bcc_fcc_meV": bain_results["delta_E_meV"], + "E_vac": vacancy_results["E_vac"], + "gamma_100": surface_results["gamma_100"], + "gamma_110": surface_results["gamma_110"], + "gamma_111": surface_results["gamma_111"], + "gamma_112": surface_results["gamma_112"], + "max_sfe_110": sfe_110_results["max_sfe"], + "max_sfe_112": sfe_112_results["max_sfe"], } - + for disl_type, disl_data in dislocation_results.items(): - if 'core_energy' in disl_data: - summary[f'core_energy_{disl_type}'] = disl_data['core_energy'] - + if "core_energy" in disl_data: + summary[f"core_energy_{disl_type}"] = disl_data["core_energy"] + for crack_sys, crack_data in crack_results.items(): - if 'K_Griffith' in crack_data: - summary[f'K_Griffith_{crack_sys}'] = crack_data['K_Griffith'] - + if "K_Griffith" in crack_data: + summary[f"K_Griffith_{crack_sys}"] = crack_data["K_Griffith"] + (write_dir / "summary.json").write_text(json.dumps(summary, indent=2)) - + print(f"[{model_name}] Done. Results saved to {write_dir}") @@ -853,10 +1030,10 @@ def run_iron_properties(model_name: str, model: Any) -> None: def test_iron_properties(model_name: str) -> None: """ Run iron properties benchmark for each registered model. - + This test is marked as slow and excluded from default test runs. Run with ``pytest --run-slow`` to include. - + Parameters ---------- model_name diff --git a/ml_peg/calcs/utils/iron_utils.py b/ml_peg/calcs/utils/iron_utils.py index 4d891642f..880d78b66 100644 --- a/ml_peg/calcs/utils/iron_utils.py +++ b/ml_peg/calcs/utils/iron_utils.py @@ -1,10 +1,11 @@ -"""Utility functions for BCC iron property calculations. +""" +Utility functions for BCC iron property calculations. This module provides structure creation, EOS fitting, dislocation utilities, and LEFM functions for iron benchmarks. -Reference ---------- +References +---------- Zhang, L., Csányi, G., van der Giessen, E., & Maresca, F. (2023). Efficiency, Accuracy, and Transferability of Machine Learning Potentials: Application to Dislocations and Cracks in Iron. @@ -15,13 +16,12 @@ from typing import Any -import numpy as np from ase import Atoms from ase.build import bulk from ase.neighborlist import NeighborList +import numpy as np from scipy.optimize import leastsq - # ============================================================================= # Unit Conversion Constants # ============================================================================= @@ -37,55 +37,82 @@ # ============================================================================= DISLOCATION_CONFIGS = { - 'edge_100_010': { - 'name': 'Edge a0[100](010)', - 'orient_x': np.array([0, 0, 1]), - 'orient_y': np.array([1, 0, 0]), - 'orient_z': np.array([0, 1, 0]), - 'size': (1, 50, 20), - 'burgers': np.array([1, 0, 0]), - 'type': 'edge', - 'slip_direction': 1, + "edge_100_010": { + "name": "Edge a0[100](010)", + "orient_x": np.array([0, 0, 1]), + "orient_y": np.array([1, 0, 0]), + "orient_z": np.array([0, 1, 0]), + "size": (1, 50, 20), + "dim_divisors": (1, 1, 1), # LAMMPS: sqrt(1)*N*a for all (no /2) + "burgers": np.array([1, 0, 0]), + "type": "edge", + "slip_direction": 1, + # Half-plane deletion region: LAMMPS uses + # ymindip=-0.6*sqrt(1)*a, ymaxdip=0.1*sqrt(1)*a + "delete_axis": 1, # y-axis + "delete_min": -0.6, # factor * a + "delete_max": 0.1, # factor * a }, - 'edge_100_011': { - 'name': 'Edge a0[100](011)', - 'orient_x': np.array([0, -1, 1]), - 'orient_y': np.array([1, 0, 0]), - 'orient_z': np.array([0, 1, 1]), - 'size': (1, 80, 22), - 'burgers': np.array([1, 0, 0]), - 'type': 'edge', - 'slip_direction': 1, + "edge_100_011": { + "name": "Edge a0[100](011)", + "orient_x": np.array([0, -1, 1]), + "orient_y": np.array([1, 0, 0]), + "orient_z": np.array([0, 1, 1]), + "size": (1, 80, 22), + "dim_divisors": (1, 2, 2), # LAMMPS: sqrt(2)*N, sqrt(1)/2*N, sqrt(2)/2*N + "burgers": np.array([1, 0, 0]), + "type": "edge", + "slip_direction": 1, + # Half-plane deletion region: LAMMPS uses + # ymindip=-0.6*sqrt(1)*a, ymaxdip=0.1*sqrt(1)*a + "delete_axis": 1, # y-axis + "delete_min": -0.6, # factor * a + "delete_max": 0.1, # factor * a }, - 'edge_111_110': { - 'name': 'Edge a0/2[111](110)', - 'orient_x': np.array([1, 2, -1]), - 'orient_y': np.array([-1, 1, 1]), - 'orient_z': np.array([1, 0, 1]), - 'size': (1, 40, 20), - 'burgers': np.array([0.5, 0.5, 0.5]), - 'type': 'edge', - 'slip_direction': 1, + "edge_111_110": { + "name": "Edge a0/2[111](110)", + "orient_x": np.array([1, 2, -1]), + "orient_y": np.array([-1, 1, 1]), + "orient_z": np.array([1, 0, 1]), + "size": (1, 40, 20), + "dim_divisors": (1, 2, 2), # LAMMPS: sqrt(6)*N, sqrt(3)/2*N, sqrt(2)/2*N + "burgers": np.array([0.5, 0.5, 0.5]), + "type": "edge", + "slip_direction": 1, + # Half-plane deletion region: LAMMPS uses + # ymindip=-0.3*sqrt(3)*a, ymaxdip=0.3*sqrt(3)*a + "delete_axis": 1, # y-axis + "delete_min": -0.3 * np.sqrt(3), # -0.52*a + "delete_max": 0.3 * np.sqrt(3), # 0.52*a }, - 'mixed_111': { - 'name': 'Mixed 70.5 deg a0/2[111](110)', - 'orient_x': np.array([1, 2, -1]), - 'orient_y': np.array([-1, 1, 1]), - 'orient_z': np.array([1, 0, 1]), - 'size': (40, 2, 19), - 'burgers': np.array([0.5, 0.5, 0.5]), - 'type': 'mixed', - 'slip_direction': 1, + "mixed_111": { + "name": "Mixed 70.5 deg a0/2[111](110)", + "orient_x": np.array([1, 2, -1]), + "orient_y": np.array([-1, 1, 1]), + "orient_z": np.array([1, 0, 1]), + "size": (40, 2, 19), + "dim_divisors": (2, 2, 2), # LAMMPS: sqrt(6)/2*N for all dimensions + "burgers": np.array([0.5, 0.5, 0.5]), + "type": "mixed", + "slip_direction": 1, + "screw_fraction": 0.325568, # cos(71°) from LAMMPS + "edge_factor": 0.5, # Additional factor from LAMMPS edge component + # Half-plane deletion region: LAMMPS uses + # xmindip=-0.5*sqrt(1)*a, xmaxdip=0.1*sqrt(1)*a + "delete_axis": 0, # x-axis (different from edge dislocations!) + "delete_min": -0.5, # factor * a + "delete_max": 0.1, # factor * a }, - 'screw_111': { - 'name': 'Screw a0/2[111](112)', - 'orient_x': np.array([1, 2, -1]), - 'orient_y': np.array([-1, 1, 1]), - 'orient_z': np.array([1, 0, 1]), - 'size': (60, 2, 19), - 'burgers': np.array([0.5, 0.5, 0.5]), - 'type': 'screw', - 'slip_direction': 1, + "screw_111": { + "name": "Screw a0/2[111](112)", + "orient_x": np.array([1, 2, -1]), + "orient_y": np.array([-1, 1, 1]), + "orient_z": np.array([1, 0, 1]), + "size": (60, 2, 19), + "dim_divisors": (2, 2, 2), # LAMMPS: sqrt(6)/2*N, sqrt(3)/2*N, sqrt(2)/2*N + "burgers": np.array([0.5, 0.5, 0.5]), + "type": "screw", + "slip_direction": 1, }, } @@ -98,40 +125,40 @@ CRACK_SYSTEMS_CONFIG = { 1: { - 'name': '(100)[010]', - 'orient_x': np.array([0, 0, 1]), - 'orient_y': np.array([1, 0, 0]), - 'orient_z': np.array([0, 1, 0]), - 'surface': '100', - 'box_size': (50, 50), - 'tip_factors': (1.0, 1.0), + "name": "(100)[010]", + "orient_x": np.array([0, 0, 1]), + "orient_y": np.array([1, 0, 0]), + "orient_z": np.array([0, 1, 0]), + "surface": "100", + "box_size": (50, 50), + "tip_factors": (1.0, 1.0), }, 2: { - 'name': '(100)[001]', - 'orient_x': np.array([0, -1, 1]), - 'orient_y': np.array([1, 0, 0]), - 'orient_z': np.array([0, 1, 1]), - 'surface': '100', - 'box_size': (38, 54), - 'tip_factors': (np.sqrt(2), 1.0), + "name": "(100)[001]", + "orient_x": np.array([0, -1, 1]), + "orient_y": np.array([1, 0, 0]), + "orient_z": np.array([0, 1, 1]), + "surface": "100", + "box_size": (38, 54), + "tip_factors": (np.sqrt(2), 1.0), }, 3: { - 'name': '(110)[001]', - 'orient_x': np.array([1, -1, 0]), - 'orient_y': np.array([1, 1, 0]), - 'orient_z': np.array([0, 0, 1]), - 'surface': '110', - 'box_size': (38, 38), - 'tip_factors': (np.sqrt(2), np.sqrt(2)), + "name": "(110)[001]", + "orient_x": np.array([1, -1, 0]), + "orient_y": np.array([1, 1, 0]), + "orient_z": np.array([0, 0, 1]), + "surface": "110", + "box_size": (38, 38), + "tip_factors": (np.sqrt(2), np.sqrt(2)), }, 4: { - 'name': '(110)[1-10]', - 'orient_x': np.array([0, 0, -1]), - 'orient_y': np.array([1, 1, 0]), - 'orient_z': np.array([1, -1, 0]), - 'surface': '110', - 'box_size': (55, 38), - 'tip_factors': (1.0, np.sqrt(2)), + "name": "(110)[1-10]", + "orient_x": np.array([0, 0, -1]), + "orient_y": np.array([1, 1, 0]), + "orient_z": np.array([1, -1, 0]), + "surface": "110", + "box_size": (55, 38), + "tip_factors": (1.0, np.sqrt(2)), }, } @@ -140,55 +167,55 @@ # EOS Fitting Functions # ============================================================================= + def eos_birch_murnaghan( - params: tuple[float, float, float, float], - vol: np.ndarray + params: tuple[float, float, float, float], vol: np.ndarray ) -> np.ndarray: """ Birch-Murnaghan equation of state (3rd order). - + Parameters ---------- params (E0, B0, Bp, V0). vol Volume array. - + Returns ------- np.ndarray Energy array. """ - E0, B0, Bp, V0 = params + E0, B0, Bp, V0 = params # noqa: N806 eta = (vol / V0) ** (1.0 / 3.0) - E = E0 + 9.0 * B0 * V0 / 16.0 * (eta**2 - 1.0)**2 * (6.0 + Bp * (eta**2 - 1.0) - 4.0 * eta**2) - return E + return E0 + 9.0 * B0 * V0 / 16.0 * (eta**2 - 1.0) ** 2 * ( + 6.0 + Bp * (eta**2 - 1.0) - 4.0 * eta**2 + ) def get_eos_initial_guess( - vol: np.ndarray, - ene: np.ndarray + vol: np.ndarray, ene: np.ndarray ) -> tuple[float, float, float, float]: """ Get initial guess for EOS parameters using quadratic fit. - + Parameters ---------- vol Volume array. ene Energy array. - + Returns ------- tuple (E0, B0, Bp, V0) initial guess. """ a, b, c = np.polyfit(vol, ene, 2) - V0 = -b / (2 * a) - E0 = a * V0**2 + b * V0 + c - B0 = 2 * a * V0 - Bp = 4.0 + V0 = -b / (2 * a) # noqa: N806 + E0 = a * V0**2 + b * V0 + c # noqa: N806 + B0 = 2 * a * V0 # noqa: N806 + Bp = 4.0 # noqa: N806 return E0, B0, Bp, V0 @@ -198,14 +225,14 @@ def fit_eos( ) -> dict[str, Any]: """ Fit Birch-Murnaghan equation of state to energy-volume data. - + Parameters ---------- vol Volume per atom array (Angstrom^3). ene Energy per atom array (eV). - + Returns ------- dict @@ -217,34 +244,50 @@ def fit_eos( - a0: Equilibrium lattice parameter (Angstrom) - for BCC """ x0 = get_eos_initial_guess(vol, ene) - + def residual(params, y, x): + """ + Compute residual for EOS fitting. + + Parameters + ---------- + params : tuple + EOS parameters (E0, B0, Bp, V0). + y : np.ndarray + Observed energies. + x : np.ndarray + Volumes. + + Returns + ------- + np.ndarray + Residual array (observed - predicted). + """ return y - eos_birch_murnaghan(params, x) - + params, _ = leastsq(residual, x0, args=(ene, vol)) - E0, B0, Bp, V0 = params - + E0, B0, Bp, V0 = params # noqa: N806 + # Convert bulk modulus to GPa (from eV/Angstrom^3) - B0_GPa = B0 * EV_PER_A3_TO_GPA - + B0_GPa = B0 * EV_PER_A3_TO_GPA # noqa: N806 + # Calculate lattice parameter for BCC (2 atoms per unit cell) a0 = (V0 * 2) ** (1.0 / 3.0) - - return {'E0': E0, 'B0': B0_GPa, 'Bp': Bp, 'V0': V0, 'a0': a0} + + return {"E0": E0, "B0": B0_GPa, "Bp": Bp, "V0": V0, "a0": a0} # ============================================================================= # Structure Creation Functions # ============================================================================= + def create_bcc_supercell( - lattice_parameter: float, - size: tuple = (4, 4, 4), - symbol: str = 'Fe' + lattice_parameter: float, size: tuple = (4, 4, 4), symbol: str = "Fe" ) -> Atoms: """ Create a BCC supercell. - + Parameters ---------- lattice_parameter @@ -253,27 +296,27 @@ def create_bcc_supercell( Supercell size as (nx, ny, nz). symbol Chemical symbol (default: 'Fe'). - + Returns ------- Atoms ASE Atoms object. """ - unit_cell = bulk(symbol, 'bcc', a=lattice_parameter, cubic=True) + unit_cell = bulk(symbol, "bcc", a=lattice_parameter, cubic=True) return unit_cell * size def create_bain_cell(lattice_parameter: float, ca_ratio: float) -> Atoms: """ Create a tetragonally distorted BCC cell for Bain path calculation. - + Parameters ---------- lattice_parameter BCC lattice parameter. ca_ratio Target c/a ratio. - + Returns ------- Atoms @@ -282,58 +325,92 @@ def create_bain_cell(lattice_parameter: float, ca_ratio: float) -> Atoms: beta = (1.0 / ca_ratio) ** (1.0 / 3.0) al = lattice_parameter * beta alz = al * ca_ratio - + cell = np.array([[al, 0, 0], [0, al, 0], [0, 0, alz]]) positions = np.array([[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]]) @ cell - - return Atoms(symbols=['Fe', 'Fe'], positions=positions, cell=cell, pbc=True) + + return Atoms(symbols=["Fe", "Fe"], positions=positions, cell=cell, pbc=True) def create_surface_100( - lattice_parameter: float, - layers: int = 10, - vacuum: float = 0.0, - symbol: str = 'Fe' + lattice_parameter: float, layers: int = 10, vacuum: float = 0.0, symbol: str = "Fe" ) -> Atoms: - """Create a (100) surface slab for BCC iron.""" + """ + Create a (100) surface slab for BCC iron. + + Parameters + ---------- + lattice_parameter : float + Lattice parameter in Angstroms. + layers : int, optional + Number of atomic layers (default: 10). + vacuum : float, optional + Vacuum thickness in Angstroms (default: 0.0). + symbol : str, optional + Chemical symbol (default: 'Fe'). + + Returns + ------- + Atoms + ASE Atoms object with the (100) surface slab. + """ a = lattice_parameter cell = np.array([[a, 0, 0], [0, a, 0], [0, 0, a * layers]]) - + positions = [] for k in range(layers): positions.append([0, 0, k * a]) positions.append([0.5 * a, 0.5 * a, (k + 0.5) * a]) - - atoms = Atoms(symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True) + + atoms = Atoms( + symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True + ) if vacuum > 0: atoms.center(vacuum=vacuum, axis=2) return atoms def create_surface_110( - lattice_parameter: float, - layers: int = 10, - vacuum: float = 0.0, - symbol: str = 'Fe' + lattice_parameter: float, layers: int = 10, vacuum: float = 0.0, symbol: str = "Fe" ) -> Atoms: - """Create a (110) surface slab for BCC iron.""" + """ + Create a (110) surface slab for BCC iron. + + Parameters + ---------- + lattice_parameter : float + Lattice parameter in Angstroms. + layers : int, optional + Number of atomic layers (default: 10). + vacuum : float, optional + Vacuum thickness in Angstroms (default: 0.0). + symbol : str, optional + Chemical symbol (default: 'Fe'). + + Returns + ------- + Atoms + ASE Atoms object with the (110) surface slab. + """ a = lattice_parameter lx = a ly = a * np.sqrt(2) lz = a * np.sqrt(2) * layers - + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) positions = [] d110 = a * np.sqrt(2) / 2 - + for k in range(layers * 2): z = k * d110 if k % 2 == 0: positions.append([0, 0, z]) else: positions.append([0.5 * a, 0.5 * ly, z]) - - atoms = Atoms(symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True) + + atoms = Atoms( + symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True + ) if vacuum > 0: atoms.center(vacuum=vacuum, axis=2) return atoms @@ -343,46 +420,70 @@ def create_surface_111( lattice_parameter: float, size: tuple = (3, 15, 3), vacuum: float = 0.0, - symbol: str = 'Fe' + symbol: str = "Fe", ) -> Atoms: - """Create a (111) surface slab for BCC iron.""" + """ + Create a (111) surface slab for BCC iron. + + Parameters + ---------- + lattice_parameter : float + Lattice parameter in Angstroms. + size : tuple, optional + Cell size as (nx, ny, nz) (default: (3, 15, 3)). + vacuum : float, optional + Vacuum thickness in Angstroms (default: 0.0). + symbol : str, optional + Chemical symbol (default: 'Fe'). + + Returns + ------- + Atoms + ASE Atoms object with the (111) surface slab. + """ a = lattice_parameter lx = a * np.sqrt(2) * size[0] ly = a * np.sqrt(3) * size[1] lz = a * np.sqrt(6) * size[2] - + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) positions = [] max_range = int(max(size) * 3 + 5) - + ex = np.array([-1, 1, 0]) / np.sqrt(2) ey = np.array([1, 1, 1]) / np.sqrt(3) ez = np.array([1, 1, -2]) / np.sqrt(6) - R = np.array([ex, ey, ez]) - + rot = np.array([ex, ey, ez]) + for i in range(-max_range, max_range + 1): for j in range(-max_range, max_range + 1): for k in range(-max_range, max_range + 1): for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = R @ pos_cubic + pos_oriented = rot @ pos_cubic frac_x = pos_oriented[0] / lx frac_y = pos_oriented[1] / ly frac_z = pos_oriented[2] / lz eps = 1e-8 - if (0 - eps <= frac_x < 1 - eps and - 0 - eps <= frac_y < 1 - eps and - 0 - eps <= frac_z < 1 - eps): + if ( + 0 - eps <= frac_x < 1 - eps + and 0 - eps <= frac_y < 1 - eps + and 0 - eps <= frac_z < 1 - eps + ): positions.append(pos_oriented) - + if len(positions) == 0: raise ValueError("No atoms found for (111) surface") - + positions = np.array(positions) - _, unique_idx = np.unique(np.round(positions, decimals=6), axis=0, return_index=True) + _, unique_idx = np.unique( + np.round(positions, decimals=6), axis=0, return_index=True + ) positions = positions[unique_idx] - - atoms = Atoms(symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True) + + atoms = Atoms( + symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True + ) atoms.wrap() if vacuum > 0: atoms.center(vacuum=vacuum, axis=1) @@ -390,49 +491,70 @@ def create_surface_111( def create_surface_112( - lattice_parameter: float, - layers: int = 15, - vacuum: float = 0.0, - symbol: str = 'Fe' + lattice_parameter: float, layers: int = 15, vacuum: float = 0.0, symbol: str = "Fe" ) -> Atoms: - """Create a (112) surface slab for BCC iron.""" + """ + Create a (112) surface slab for BCC iron. + + Parameters + ---------- + lattice_parameter : float + Lattice parameter in Angstroms. + layers : int, optional + Number of atomic layers (default: 15). + vacuum : float, optional + Vacuum thickness in Angstroms (default: 0.0). + symbol : str, optional + Chemical symbol (default: 'Fe'). + + Returns + ------- + Atoms + ASE Atoms object with the (112) surface slab. + """ a = lattice_parameter lx = a * np.sqrt(2) ly = a * np.sqrt(3) lz = a * np.sqrt(6) * layers - + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) positions = [] max_range = int(layers * 3 + 5) - + ex = np.array([-1, 1, 0]) / np.sqrt(2) ey = np.array([1, 1, 1]) / np.sqrt(3) ez = np.array([1, 1, -2]) / np.sqrt(6) - R = np.array([ex, ey, ez]) - + rot = np.array([ex, ey, ez]) + for i in range(-max_range, max_range + 1): for j in range(-max_range, max_range + 1): for k in range(-max_range, max_range + 1): for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = R @ pos_cubic + pos_oriented = rot @ pos_cubic frac_x = pos_oriented[0] / lx frac_y = pos_oriented[1] / ly frac_z = pos_oriented[2] / lz eps = 1e-8 - if (0 - eps <= frac_x < 1 - eps and - 0 - eps <= frac_y < 1 - eps and - 0 - eps <= frac_z < 1 - eps): + if ( + 0 - eps <= frac_x < 1 - eps + and 0 - eps <= frac_y < 1 - eps + and 0 - eps <= frac_z < 1 - eps + ): positions.append(pos_oriented) - + if len(positions) == 0: raise ValueError("No atoms found for (112) surface") - + positions = np.array(positions) - _, unique_idx = np.unique(np.round(positions, decimals=6), axis=0, return_index=True) + _, unique_idx = np.unique( + np.round(positions, decimals=6), axis=0, return_index=True + ) positions = positions[unique_idx] - - atoms = Atoms(symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True) + + atoms = Atoms( + symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True + ) atoms.wrap() if vacuum > 0: atoms.center(vacuum=vacuum, axis=2) @@ -440,85 +562,121 @@ def create_surface_112( def create_sfe_110_structure(lattice_parameter: float) -> Atoms: - """Create structure for {110}<111> stacking fault calculation.""" + """ + Create structure for {110}<111> stacking fault calculation. + + Parameters + ---------- + lattice_parameter : float + Lattice parameter in Angstroms. + + Returns + ------- + Atoms + ASE Atoms object for SFE calculation. + """ a = lattice_parameter size = (20, 1, 3) - + ex = np.array([-1, 1, 0]) / np.sqrt(2) ey = np.array([1, 1, 1]) / np.sqrt(3) ez = np.array([1, 1, -2]) / np.sqrt(6) - R = np.array([ex, ey, ez]) - + rot = np.array([ex, ey, ez]) + lx = a * np.sqrt(2) * size[0] ly = a * np.sqrt(3) * size[1] lz = a * np.sqrt(6) * size[2] - + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) positions = [] max_range = int(max(size) * 3 + 5) - + for i in range(-max_range, max_range + 1): for j in range(-max_range, max_range + 1): for k in range(-max_range, max_range + 1): for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = R @ pos_cubic + pos_oriented = rot @ pos_cubic frac_x = pos_oriented[0] / lx frac_y = pos_oriented[1] / ly frac_z = pos_oriented[2] / lz eps = 1e-8 - if (0 - eps <= frac_x < 1 - eps and - 0 - eps <= frac_y < 1 - eps and - 0 - eps <= frac_z < 1 - eps): + if ( + 0 - eps <= frac_x < 1 - eps + and 0 - eps <= frac_y < 1 - eps + and 0 - eps <= frac_z < 1 - eps + ): positions.append(pos_oriented) - + positions = np.array(positions) - _, unique_idx = np.unique(np.round(positions, decimals=6), axis=0, return_index=True) + _, unique_idx = np.unique( + np.round(positions, decimals=6), axis=0, return_index=True + ) positions = positions[unique_idx] - - atoms = Atoms(symbols=['Fe'] * len(positions), positions=positions, cell=cell, pbc=True) + + atoms = Atoms( + symbols=["Fe"] * len(positions), positions=positions, cell=cell, pbc=True + ) atoms.wrap() return atoms def create_sfe_112_structure(lattice_parameter: float) -> Atoms: - """Create structure for {112}<111> stacking fault calculation.""" + """ + Create structure for {112}<111> stacking fault calculation. + + Parameters + ---------- + lattice_parameter : float + Lattice parameter in Angstroms. + + Returns + ------- + Atoms + ASE Atoms object for SFE calculation. + """ a = lattice_parameter size = (15, 1, 1) - + ex = np.array([1, 1, -2]) / np.sqrt(6) ey = np.array([-1, 1, 0]) / np.sqrt(2) ez = np.array([1, 1, 1]) / np.sqrt(3) - R = np.array([ex, ey, ez]) - + rot = np.array([ex, ey, ez]) + lx = a * np.sqrt(6) * size[0] ly = a * np.sqrt(2) * size[1] lz = a * np.sqrt(3) * size[2] - + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) positions = [] max_range = int(max(size) * 3 + 5) - + for i in range(-max_range, max_range + 1): for j in range(-max_range, max_range + 1): for k in range(-max_range, max_range + 1): for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = R @ pos_cubic + pos_oriented = rot @ pos_cubic frac_x = pos_oriented[0] / lx frac_y = pos_oriented[1] / ly frac_z = pos_oriented[2] / lz eps = 1e-8 - if (0 - eps <= frac_x < 1 - eps and - 0 - eps <= frac_y < 1 - eps and - 0 - eps <= frac_z < 1 - eps): + if ( + 0 - eps <= frac_x < 1 - eps + and 0 - eps <= frac_y < 1 - eps + and 0 - eps <= frac_z < 1 - eps + ): positions.append(pos_oriented) - + positions = np.array(positions) - _, unique_idx = np.unique(np.round(positions, decimals=6), axis=0, return_index=True) + _, unique_idx = np.unique( + np.round(positions, decimals=6), axis=0, return_index=True + ) positions = positions[unique_idx] - - atoms = Atoms(symbols=['Fe'] * len(positions), positions=positions, cell=cell, pbc=True) + + atoms = Atoms( + symbols=["Fe"] * len(positions), positions=positions, cell=cell, pbc=True + ) atoms.wrap() return atoms @@ -527,104 +685,186 @@ def create_sfe_112_structure(lattice_parameter: float) -> Atoms: # Dislocation Utilities # ============================================================================= + def create_oriented_bcc_cell( lattice_parameter: float, orient_x: np.ndarray, orient_y: np.ndarray, orient_z: np.ndarray, size: tuple[int, int, int], - symbol: str = 'Fe', - center_cell: bool = True + dim_divisors: tuple[int, int, int] = (1, 2, 2), + symbol: str = "Fe", + center_cell: bool = True, ) -> Atoms: - """Create an oriented BCC supercell.""" + """ + Create an oriented BCC supercell. + + Parameters + ---------- + lattice_parameter : float + The BCC lattice parameter in Angstroms. + orient_x, orient_y, orient_z : np.ndarray + Crystal orientation vectors for x, y, z axes. + size : tuple[int, int, int] + Number of periodic units in each direction. + dim_divisors : tuple[int, int, int] + Divisors for half-dimension calculation in each direction. + Formula: half_dim = a * ||orient|| * size / divisor + Use (1, 1, 1) for no division, (2, 2, 2) for /2 on all, etc. + Must match LAMMPS conventions for each dislocation type. + symbol : str + Atomic symbol (default 'Fe'). + center_cell : bool + If True, center cell at origin; if False, shift to positive coords. + + Returns + ------- + Atoms + ASE Atoms object with the oriented BCC cell. + """ a = lattice_parameter ox = orient_x / np.linalg.norm(orient_x) oy = orient_y / np.linalg.norm(orient_y) oz = orient_z / np.linalg.norm(orient_z) - R = np.array([ox, oy, oz]) - + rot = np.array([ox, oy, oz]) + len_x = np.linalg.norm(orient_x) len_y = np.linalg.norm(orient_y) len_z = np.linalg.norm(orient_z) - - half_lx = a * len_x * size[0] - half_ly = a * len_y * size[1] / 2 - half_lz = a * len_z * size[2] / 2 - + + # Use dim_divisors to match LAMMPS half-dimension conventions + half_lx = a * len_x * size[0] / dim_divisors[0] + half_ly = a * len_y * size[1] / dim_divisors[1] + half_lz = a * len_z * size[2] / dim_divisors[2] + lx = 2 * half_lx ly = 2 * half_ly lz = 2 * half_lz - + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) positions = [] max_range = int(max(size) * max(len_x, len_y, len_z) + 10) - + for i in range(-max_range, max_range + 1): for j in range(-max_range, max_range + 1): for k in range(-max_range, max_range + 1): for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = R @ pos_cubic + pos_oriented = rot @ pos_cubic eps = 1e-8 - if (-half_lx - eps <= pos_oriented[0] < half_lx - eps and - -half_ly - eps <= pos_oriented[1] < half_ly - eps and - -half_lz - eps <= pos_oriented[2] < half_lz - eps): + if ( + -half_lx - eps <= pos_oriented[0] < half_lx - eps + and -half_ly - eps <= pos_oriented[1] < half_ly - eps + and -half_lz - eps <= pos_oriented[2] < half_lz - eps + ): positions.append(pos_oriented) - + if len(positions) == 0: raise ValueError("No atoms found in the oriented cell") - + positions = np.array(positions) - _, unique_idx = np.unique(np.round(positions, decimals=6), axis=0, return_index=True) + _, unique_idx = np.unique( + np.round(positions, decimals=6), axis=0, return_index=True + ) positions = positions[unique_idx] - + if not center_cell: positions[:, 0] += half_lx positions[:, 1] += half_ly positions[:, 2] += half_lz - - atoms = Atoms(symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=[True, True, False]) - atoms.info['cell_center'] = np.array([0, 0, 0]) if center_cell else np.array([half_lx, half_ly, half_lz]) - atoms.info['half_dims'] = np.array([half_lx, half_ly, half_lz]) - + + atoms = Atoms( + symbols=[symbol] * len(positions), + positions=positions, + cell=cell, + pbc=[True, True, False], + ) + atoms.info["cell_center"] = ( + np.array([0, 0, 0]) if center_cell else np.array([half_lx, half_ly, half_lz]) + ) + atoms.info["half_dims"] = np.array([half_lx, half_ly, half_lz]) + return atoms def create_dislocation_cell( - lattice_parameter: float, - dislocation_type: str, - symbol: str = 'Fe' + lattice_parameter: float, dislocation_type: str, symbol: str = "Fe" ) -> Atoms: - """Create a cell for dislocation simulation.""" + """ + Create a cell for dislocation simulation. + + Uses the dim_divisors from DISLOCATION_CONFIGS to match LAMMPS cell sizes. + + Parameters + ---------- + lattice_parameter : float + The BCC lattice parameter in Angstroms. + dislocation_type : str + Type of dislocation (e.g., 'edge_100_010', 'screw_111'). + symbol : str, optional + Atomic symbol (default: 'Fe'). + + Returns + ------- + Atoms + ASE Atoms object with the dislocation cell. + """ if dislocation_type not in DISLOCATION_CONFIGS: raise ValueError(f"Unknown dislocation type: {dislocation_type}") config = DISLOCATION_CONFIGS[dislocation_type] return create_oriented_bcc_cell( - lattice_parameter, config['orient_x'], config['orient_y'], config['orient_z'], - config['size'], symbol, center_cell=True + lattice_parameter, + config["orient_x"], + config["orient_y"], + config["orient_z"], + config["size"], + dim_divisors=config.get("dim_divisors", (1, 2, 2)), + symbol=symbol, + center_cell=True, ) def get_dislocation_info(dislocation_type: str) -> dict[str, Any]: - """Get information about a dislocation type.""" + """ + Get information about a dislocation type. + + Parameters + ---------- + dislocation_type : str + Type of dislocation (e.g., 'edge_100_010', 'screw_111'). + + Returns + ------- + dict + Dictionary with dislocation configuration parameters. + """ if dislocation_type not in DISLOCATION_CONFIGS: raise ValueError(f"Unknown dislocation type: {dislocation_type}") return DISLOCATION_CONFIGS[dislocation_type].copy() def apply_screw_displacement(atoms: Atoms, burgers_magnitude: float) -> None: - """Apply screw dislocation displacement field.""" + """ + Apply screw dislocation displacement field. + + Parameters + ---------- + atoms : Atoms + ASE Atoms object with the dislocation cell. + burgers_magnitude : float + Magnitude of the Burgers vector. + """ positions = atoms.get_positions() cell = atoms.get_cell() - - if 'half_dims' in atoms.info: - half_lx = atoms.info['half_dims'][0] + + if "half_dims" in atoms.info: + half_lx = atoms.info["half_dims"][0] x_min, x_max = -half_lx, half_lx z_mid = 0 else: x_min, x_max = 0, cell[0, 0] z_mid = cell[2, 2] / 2 - + upper_mask = positions[:, 2] > z_mid x = positions[upper_mask, 0] fraction = (x - x_min) / (x_max - x_min) @@ -634,7 +874,21 @@ def apply_screw_displacement(atoms: Atoms, burgers_magnitude: float) -> None: def delete_overlapping_atoms(atoms: Atoms, cutoff: float = 0.5) -> Atoms: - """Delete atoms that are too close to each other.""" + """ + Delete atoms that are too close to each other. + + Parameters + ---------- + atoms : Atoms + ASE Atoms object. + cutoff : float, optional + Distance cutoff for overlap detection (default: 0.5 Angstroms). + + Returns + ------- + Atoms + ASE Atoms object with overlapping atoms removed. + """ if len(atoms) == 0: return atoms cutoffs = [cutoff / 2] * len(atoms) @@ -655,58 +909,148 @@ def apply_edge_displacement( atoms: Atoms, burgers_magnitude: float, lattice_parameter: float, - delete_half_plane: bool = True + delete_half_plane: bool = True, + delete_axis: int = 1, + delete_min: float = -0.6, + delete_max: float = 0.1, ) -> Atoms: - """Apply edge dislocation displacement.""" + """ + Apply edge dislocation displacement. + + Parameters + ---------- + atoms : Atoms + ASE Atoms object with the dislocation cell. + burgers_magnitude : float + Magnitude of the Burgers vector. + lattice_parameter : float + BCC lattice parameter. + delete_half_plane : bool + If True, delete atoms in the half-plane region. + delete_axis : int + Axis along which to delete atoms (0=x, 1=y). Default 1 (y). + delete_min : float + Minimum position factor for deletion region (factor * a). + delete_max : float + Maximum position factor for deletion region (factor * a). + + Returns + ------- + Atoms + Atoms object with edge dislocation displacement applied. + + Notes + ----- + LAMMPS deletion regions by dislocation type: + - edge_100_010: y from -0.6a to 0.1a + - edge_100_011: y from -0.6a to 0.1a + - edge_111_110: y from -0.3*sqrt(3)*a to 0.3*sqrt(3)*a + """ positions = atoms.get_positions() cell = atoms.get_cell() - - if 'half_dims' in atoms.info: - half_ly = atoms.info['half_dims'][1] + + if "half_dims" in atoms.info: + half_ly = atoms.info["half_dims"][1] y_min, y_max = -half_ly, half_ly z_mid = 0 else: y_min, y_max = 0, cell[1, 1] z_mid = cell[2, 2] / 2 - + a = lattice_parameter - + if delete_half_plane: - ymindip = -0.6 * a - ymaxdip = 0.1 * a - keep_mask = ~((positions[:, 1] >= ymindip) & (positions[:, 1] <= ymaxdip) & (positions[:, 2] < z_mid)) + # Use configurable deletion region + dip_min = delete_min * a + dip_max = delete_max * a + # Delete atoms in the specified axis range, below z_mid + keep_mask = ~( + (positions[:, delete_axis] >= dip_min) + & (positions[:, delete_axis] <= dip_max) + & (positions[:, 2] < z_mid) + ) atoms = atoms[keep_mask] positions = atoms.get_positions() - + quarter_b = 0.5 * a mask_ll = (positions[:, 1] < 0) & (positions[:, 2] < z_mid) if np.any(mask_ll): y = positions[mask_ll, 1] fraction = (y - y_min) / (0 - y_min) positions[mask_ll, 1] += fraction * quarter_b - + mask_lr = (positions[:, 1] >= 0) & (positions[:, 2] < z_mid) if np.any(mask_lr): y = positions[mask_lr, 1] fraction = (y - 0) / (y_max - 0) positions[mask_lr, 1] += (1 - fraction) * (-quarter_b) - + atoms.set_positions(positions) - atoms = delete_overlapping_atoms(atoms, cutoff=0.5) - return atoms + return delete_overlapping_atoms(atoms, cutoff=0.5) def apply_mixed_displacement( atoms: Atoms, burgers_magnitude: float, lattice_parameter: float, - screw_fraction: float = 0.7 + screw_fraction: float = 0.325568, + edge_factor: float = 0.5, + delete_axis: int = 0, + delete_min: float = -0.5, + delete_max: float = 0.1, ) -> Atoms: - """Apply mixed dislocation displacement.""" - edge_fraction = 1 - screw_fraction + """ + Apply mixed dislocation displacement. + + Parameters + ---------- + atoms : Atoms + ASE Atoms object with the dislocation cell. + burgers_magnitude : float + Magnitude of the Burgers vector (sqrt(3)/2 * a for <111>). + lattice_parameter : float + BCC lattice parameter. + screw_fraction : float + Fraction of Burgers vector for screw component. + Default 0.325568 = cos(71°) from LAMMPS M111 dislocation. + edge_factor : float + Additional factor applied to edge component. + Default 0.5 from LAMMPS: edgeB = 0.5 * sin(71°) * |b|. + delete_axis : int + Axis along which to delete atoms (0=x, 1=y). Default 0 (x) for mixed. + delete_min : float + Minimum position factor for deletion region (factor * a). + delete_max : float + Maximum position factor for deletion region (factor * a). + + Returns + ------- + Atoms + Atoms object with mixed dislocation displacement applied. + + Notes + ----- + LAMMPS M111 uses: + - Screw: msft_disp = 0.325568 * sqrt(3)/2 * a = cos(71°) * |b| + - Edge: edgeB = 0.5 * 0.94 * sqrt(3)/2 * a = 0.5 * sin(71°) * |b| + - Deletion region: x from -0.5a to 0.1a (along x-axis, not y!) + """ + # Calculate edge fraction from geometry (sin of character angle) + # For 71° angle: sin(71°) ≈ 0.9455 + edge_fraction = np.sqrt(1 - screw_fraction**2) # sin(θ) from cos(θ) + if edge_fraction > 0: - edge_magnitude = burgers_magnitude * edge_fraction - atoms = apply_edge_displacement(atoms, edge_magnitude, lattice_parameter, delete_half_plane=True) + # Apply edge_factor (0.5 from LAMMPS) + edge_magnitude = burgers_magnitude * edge_fraction * edge_factor + atoms = apply_edge_displacement( + atoms, + edge_magnitude, + lattice_parameter, + delete_half_plane=True, + delete_axis=delete_axis, + delete_min=delete_min, + delete_max=delete_max, + ) if screw_fraction > 0: screw_magnitude = burgers_magnitude * screw_fraction apply_screw_displacement(atoms, screw_magnitude) @@ -717,135 +1061,238 @@ def apply_mixed_displacement( # LEFM / Crack Utilities # ============================================================================= -def get_crack_orientation(crack_system: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Get crystallographic orientation vectors for a crack system.""" + +def get_crack_orientation( + crack_system: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Get crystallographic orientation vectors for a crack system. + + Parameters + ---------- + crack_system : int + Crack system index (1-4). + + Returns + ------- + tuple[np.ndarray, np.ndarray, np.ndarray] + Orientation vectors (a1, a2, a3) for the crack system. + """ if crack_system == 1: return np.array([0, 0, 1]), np.array([1, 0, 0]), np.array([0, 1, 0]) - elif crack_system == 2: + if crack_system == 2: return np.array([0, -1, 1]), np.array([1, 0, 0]), np.array([0, 1, 1]) - elif crack_system == 3: + if crack_system == 3: return np.array([1, -1, 0]), np.array([1, 1, 0]), np.array([0, 0, 1]) - elif crack_system == 4: + if crack_system == 4: return np.array([0, 0, -1]), np.array([1, 1, 0]), np.array([1, -1, 0]) - else: - raise ValueError(f"Invalid crack system: {crack_system}") + raise ValueError(f"Invalid crack system: {crack_system}") def aniso_disp_solution( - C: np.ndarray, - a1: np.ndarray, - a2: np.ndarray, - a3: np.ndarray, - surfE: float + c_mat: np.ndarray, a1: np.ndarray, a2: np.ndarray, a3: np.ndarray, surf_e: float ) -> tuple: - """Solve the anisotropic LEFM displacement field coefficients.""" - S = np.linalg.inv(C) + """ + Solve the anisotropic LEFM displacement field coefficients. + + Parameters + ---------- + c_mat : np.ndarray + 6x6 elastic stiffness matrix in Voigt notation. + a1 : np.ndarray + First orientation vector. + a2 : np.ndarray + Second orientation vector. + a3 : np.ndarray + Third orientation vector. + surf_e : float + Surface energy in J/m^2. + + Returns + ------- + tuple + (s, p, q, K_I, G_I) where s, p, q are complex coefficients, + K_I is the Griffith stress intensity factor, and G_I is the + energy release rate. + """ + s_inv = np.linalg.inv(c_mat) a1 = a1 / np.linalg.norm(a1) a2 = a2 / np.linalg.norm(a2) a3 = a3 / np.linalg.norm(a3) - Q = np.array([a1, a2, a3]) - - K1 = np.array([ - [Q[0, 0]**2, Q[0, 1]**2, Q[0, 2]**2], - [Q[1, 0]**2, Q[1, 1]**2, Q[1, 2]**2], - [Q[2, 0]**2, Q[2, 1]**2, Q[2, 2]**2] - ]) - K2 = np.array([ - [Q[0, 1]*Q[0, 2], Q[0, 2]*Q[0, 0], Q[0, 0]*Q[0, 1]], - [Q[1, 1]*Q[1, 2], Q[1, 2]*Q[1, 0], Q[1, 0]*Q[1, 1]], - [Q[2, 1]*Q[2, 2], Q[2, 2]*Q[2, 0], Q[2, 0]*Q[2, 1]] - ]) - K3 = np.array([ - [Q[1, 0]*Q[2, 0], Q[1, 1]*Q[2, 1], Q[1, 2]*Q[2, 2]], - [Q[2, 0]*Q[0, 0], Q[2, 1]*Q[0, 1], Q[2, 2]*Q[0, 2]], - [Q[0, 0]*Q[1, 0], Q[0, 1]*Q[1, 1], Q[0, 2]*Q[1, 2]] - ]) - K4 = np.array([ - [Q[1, 1]*Q[2, 2] + Q[1, 2]*Q[2, 1], Q[1, 2]*Q[2, 0] + Q[1, 0]*Q[2, 2], Q[1, 0]*Q[2, 1] + Q[1, 1]*Q[2, 0]], - [Q[2, 1]*Q[0, 2] + Q[2, 2]*Q[0, 1], Q[2, 2]*Q[0, 0] + Q[2, 0]*Q[0, 2], Q[2, 0]*Q[0, 1] + Q[2, 1]*Q[0, 0]], - [Q[0, 1]*Q[1, 2] + Q[0, 2]*Q[1, 1], Q[0, 2]*Q[1, 0] + Q[0, 0]*Q[1, 2], Q[0, 0]*Q[1, 1] + Q[0, 1]*Q[1, 0]] - ]) - - K_mat = np.vstack((np.hstack((K1, 2*K2)), np.hstack((K3, K4)))) - S_star = np.linalg.inv(K_mat).T @ S @ np.linalg.inv(K_mat) - - b_11 = (S_star[0, 0] * S_star[2, 2] - S_star[0, 2]**2) / S_star[2, 2] - b_22 = (S_star[1, 1] * S_star[2, 2] - S_star[1, 2]**2) / S_star[2, 2] - b_66 = (S_star[5, 5] * S_star[2, 2] - S_star[2, 5]**2) / S_star[2, 2] - b_12 = (S_star[0, 1] * S_star[2, 2] - S_star[0, 2] * S_star[1, 2]) / S_star[2, 2] - b_16 = (S_star[0, 5] * S_star[2, 2] - S_star[0, 2] * S_star[2, 5]) / S_star[2, 2] - b_26 = (S_star[1, 5] * S_star[2, 2] - S_star[1, 2] * S_star[2, 5]) / S_star[2, 2] - - B = np.sqrt((b_11 * b_22 / 2) * (np.sqrt(b_22 / b_11) + ((2 * b_12 + b_66) / (2 * b_11)))) - K_I = np.sqrt(2 * surfE * (1 / (B * 1000))) - G_I = 2 * surfE - + q = np.array([a1, a2, a3]) + + k1 = np.array( + [ + [q[0, 0] ** 2, q[0, 1] ** 2, q[0, 2] ** 2], + [q[1, 0] ** 2, q[1, 1] ** 2, q[1, 2] ** 2], + [q[2, 0] ** 2, q[2, 1] ** 2, q[2, 2] ** 2], + ] + ) + k2 = np.array( + [ + [q[0, 1] * q[0, 2], q[0, 2] * q[0, 0], q[0, 0] * q[0, 1]], + [q[1, 1] * q[1, 2], q[1, 2] * q[1, 0], q[1, 0] * q[1, 1]], + [q[2, 1] * q[2, 2], q[2, 2] * q[2, 0], q[2, 0] * q[2, 1]], + ] + ) + k3 = np.array( + [ + [q[1, 0] * q[2, 0], q[1, 1] * q[2, 1], q[1, 2] * q[2, 2]], + [q[2, 0] * q[0, 0], q[2, 1] * q[0, 1], q[2, 2] * q[0, 2]], + [q[0, 0] * q[1, 0], q[0, 1] * q[1, 1], q[0, 2] * q[1, 2]], + ] + ) + k4 = np.array( + [ + [ + q[1, 1] * q[2, 2] + q[1, 2] * q[2, 1], + q[1, 2] * q[2, 0] + q[1, 0] * q[2, 2], + q[1, 0] * q[2, 1] + q[1, 1] * q[2, 0], + ], + [ + q[2, 1] * q[0, 2] + q[2, 2] * q[0, 1], + q[2, 2] * q[0, 0] + q[2, 0] * q[0, 2], + q[2, 0] * q[0, 1] + q[2, 1] * q[0, 0], + ], + [ + q[0, 1] * q[1, 2] + q[0, 2] * q[1, 1], + q[0, 2] * q[1, 0] + q[0, 0] * q[1, 2], + q[0, 0] * q[1, 1] + q[0, 1] * q[1, 0], + ], + ] + ) + + k_mat = np.vstack((np.hstack((k1, 2 * k2)), np.hstack((k3, k4)))) + s_star = np.linalg.inv(k_mat).T @ s_inv @ np.linalg.inv(k_mat) + + b_11 = (s_star[0, 0] * s_star[2, 2] - s_star[0, 2] ** 2) / s_star[2, 2] + b_22 = (s_star[1, 1] * s_star[2, 2] - s_star[1, 2] ** 2) / s_star[2, 2] + b_66 = (s_star[5, 5] * s_star[2, 2] - s_star[2, 5] ** 2) / s_star[2, 2] + b_12 = (s_star[0, 1] * s_star[2, 2] - s_star[0, 2] * s_star[1, 2]) / s_star[2, 2] + b_16 = (s_star[0, 5] * s_star[2, 2] - s_star[0, 2] * s_star[2, 5]) / s_star[2, 2] + b_26 = (s_star[1, 5] * s_star[2, 2] - s_star[1, 2] * s_star[2, 5]) / s_star[2, 2] + + b_factor = np.sqrt( + (b_11 * b_22 / 2) * (np.sqrt(b_22 / b_11) + ((2 * b_12 + b_66) / (2 * b_11))) + ) + k_i = np.sqrt(2 * surf_e * (1 / (b_factor * 1000))) + g_i = 2 * surf_e + coefvct = [b_11, -2 * b_16, 2 * b_12 + b_66, -2 * b_26, b_22] rt = np.roots(coefvct) s = rt[np.imag(rt) >= 0] if np.real(s[0]) < np.real(s[1]): s[0], s[1] = s[1], s[0] - - p = np.array([b_11 * s[0]**2 + b_12 - b_16 * s[0], b_11 * s[1]**2 + b_12 - b_16 * s[1]]) + + p = np.array( + [b_11 * s[0] ** 2 + b_12 - b_16 * s[0], b_11 * s[1] ** 2 + b_12 - b_16 * s[1]] + ) q = np.array([b_12 * s[0] + b_22 / s[0] - b_26, b_12 * s[1] + b_22 / s[1] - b_26]) - - return s, p, q, K_I, G_I + + return s, p, q, k_i, g_i def compute_lefm_coefficients( - C11: float, - C12: float, - C44: float, - surface_energy: float, - crack_system: int + c11: float, c12: float, c44: float, surface_energy: float, crack_system: int ) -> dict[str, Any]: - """Compute LEFM coefficients for anisotropic crack analysis.""" - C = np.array([ - [C11, C12, C12, 0, 0, 0], - [C12, C11, C12, 0, 0, 0], - [C12, C12, C11, 0, 0, 0], - [0, 0, 0, C44, 0, 0], - [0, 0, 0, 0, C44, 0], - [0, 0, 0, 0, 0, C44] - ]) + """ + Compute LEFM coefficients for anisotropic crack analysis. + + Parameters + ---------- + c11 : float + Elastic constant C11 in GPa. + c12 : float + Elastic constant C12 in GPa. + c44 : float + Elastic constant C44 in GPa. + surface_energy : float + Surface energy in J/m^2. + crack_system : int + Crack system index (1-4). + + Returns + ------- + dict + Dictionary with LEFM coefficients s1, s2, p1, p2, q1, q2, K_I, G_I. + """ + c_mat = np.array( + [ + [c11, c12, c12, 0, 0, 0], + [c12, c11, c12, 0, 0, 0], + [c12, c12, c11, 0, 0, 0], + [0, 0, 0, c44, 0, 0], + [0, 0, 0, 0, c44, 0], + [0, 0, 0, 0, 0, c44], + ] + ) a1, a2, a3 = get_crack_orientation(crack_system) - s, p, q, K_I, G_I = aniso_disp_solution(C, a1, a2, a3, surface_energy) - return {'s1': s[0], 's2': s[1], 'p1': p[0], 'p2': p[1], 'q1': q[0], 'q2': q[1], 'K_I': K_I, 'G_I': G_I} + s, p, q, k_i, g_i = aniso_disp_solution(c_mat, a1, a2, a3, surface_energy) + return { + "s1": s[0], + "s2": s[1], + "p1": p[0], + "p2": p[1], + "q1": q[0], + "q2": q[1], + "K_I": k_i, + "G_I": g_i, + } def apply_crack_displacement( positions: np.ndarray, - K: float, + k_sif: float, coeffs: dict[str, Any], crack_tip: tuple[float, float], - reference_positions: np.ndarray | None = None + reference_positions: np.ndarray | None = None, ) -> np.ndarray: - """Apply anisotropic LEFM crack displacement field to atomic positions.""" - s1, s2 = coeffs['s1'], coeffs['s2'] - p1, p2 = coeffs['p1'], coeffs['p2'] - q1, q2 = coeffs['q1'], coeffs['q2'] + """ + Apply anisotropic LEFM crack displacement field to atomic positions. + + Parameters + ---------- + positions : np.ndarray + Current atomic positions (N, 3). + k_sif : float + Stress intensity factor. + coeffs : dict + LEFM coefficients from compute_lefm_coefficients. + crack_tip : tuple[float, float] + (x, y) coordinates of the crack tip. + reference_positions : np.ndarray, optional + Reference positions for displacement calculation. + + Returns + ------- + np.ndarray + Updated atomic positions with crack displacement applied. + """ + s1, s2 = coeffs["s1"], coeffs["s2"] + p1, p2 = coeffs["p1"], coeffs["p2"] + q1, q2 = coeffs["q1"], coeffs["q2"] xtip, ytip = crack_tip - + if reference_positions is None: reference_positions = positions.copy() - + new_positions = positions.copy() x = reference_positions[:, 0] - xtip y = reference_positions[:, 1] - ytip r = np.maximum(np.sqrt(x**2 + y**2), 1e-10) theta = np.arctan2(y, x) - - coef = K * np.sqrt(2.0 * r / np.pi) + + coef = k_sif * np.sqrt(2.0 * r / np.pi) z1 = np.cos(theta) + s1 * np.sin(theta) z2 = np.cos(theta) + s2 * np.sin(theta) - + sqrt_coef_1 = np.sqrt(z2.astype(complex)) sqrt_coef_2 = np.sqrt(z1.astype(complex)) denom = s1 - s2 - + coef_x = (s1 * p2 * sqrt_coef_1 - s2 * p1 * sqrt_coef_2) / denom coef_y = (s1 * q2 * sqrt_coef_1 - s2 * q1 * sqrt_coef_2) / denom - + new_positions[:, 0] = positions[:, 0] + coef * np.real(coef_x) new_positions[:, 1] = positions[:, 1] + coef * np.real(coef_y) return new_positions @@ -853,90 +1300,134 @@ def apply_crack_displacement( def compute_incremental_displacement( positions: np.ndarray, - dK: float, + dk: float, coeffs: dict[str, Any], - crack_tip: tuple[float, float] + crack_tip: tuple[float, float], ) -> tuple[np.ndarray, np.ndarray]: - """Compute the incremental displacement for a K increment.""" - s1, s2 = coeffs['s1'], coeffs['s2'] - p1, p2 = coeffs['p1'], coeffs['p2'] - q1, q2 = coeffs['q1'], coeffs['q2'] + """ + Compute the incremental displacement for a K increment. + + Parameters + ---------- + positions : np.ndarray + Atomic positions (N, 3). + dk : float + Increment in stress intensity factor. + coeffs : dict + LEFM coefficients from compute_lefm_coefficients. + crack_tip : tuple[float, float] + (x, y) coordinates of the crack tip. + + Returns + ------- + tuple[np.ndarray, np.ndarray] + (dx, dy) incremental displacements for each atom. + """ + s1, s2 = coeffs["s1"], coeffs["s2"] + p1, p2 = coeffs["p1"], coeffs["p2"] + q1, q2 = coeffs["q1"], coeffs["q2"] xtip, ytip = crack_tip - + x = positions[:, 0] - xtip y = positions[:, 1] - ytip r = np.maximum(np.sqrt(x**2 + y**2), 1e-10) theta = np.arctan2(y, x) - - coef = dK * np.sqrt(2.0 * r / np.pi) + + coef = dk * np.sqrt(2.0 * r / np.pi) z1 = np.cos(theta) + s1 * np.sin(theta) z2 = np.cos(theta) + s2 * np.sin(theta) - + sqrt_coef_1 = np.sqrt(z2.astype(complex)) sqrt_coef_2 = np.sqrt(z1.astype(complex)) denom = s1 - s2 - + coef_x = (s1 * p2 * sqrt_coef_1 - s2 * p1 * sqrt_coef_2) / denom coef_y = (s1 * q2 * sqrt_coef_1 - s2 * q1 * sqrt_coef_2) / denom - + return coef * np.real(coef_x), coef * np.real(coef_y) def create_crack_cell( - lattice_parameter: float, - crack_system: int + lattice_parameter: float, crack_system: int ) -> tuple[Atoms, tuple[float, float], float]: - """Create a circular domain for crack simulation.""" + """ + Create a circular domain for crack simulation. + + Parameters + ---------- + lattice_parameter : float + Lattice parameter in Angstroms. + crack_system : int + Crack system index (1-4). + + Returns + ------- + tuple[Atoms, tuple[float, float], float] + (atoms, crack_tip, radius) where atoms is the ASE Atoms object, + crack_tip is the (x, y) position of the crack tip, and radius + is the domain radius. + """ config = CRACK_SYSTEMS_CONFIG[crack_system] a0 = lattice_parameter - - ox = config['orient_x'] / np.linalg.norm(config['orient_x']) - oy = config['orient_y'] / np.linalg.norm(config['orient_y']) - oz = config['orient_z'] / np.linalg.norm(config['orient_z']) - R = np.array([ox, oy, oz]) - - len_x = np.linalg.norm(config['orient_x']) - len_y = np.linalg.norm(config['orient_y']) - len_z = np.linalg.norm(config['orient_z']) - - box_x, box_y = config['box_size'] + + ox = config["orient_x"] / np.linalg.norm(config["orient_x"]) + oy = config["orient_y"] / np.linalg.norm(config["orient_y"]) + oz = config["orient_z"] / np.linalg.norm(config["orient_z"]) + rot = np.array([ox, oy, oz]) + + len_x = np.linalg.norm(config["orient_x"]) + len_y = np.linalg.norm(config["orient_y"]) + len_z = np.linalg.norm(config["orient_z"]) + + box_x, box_y = config["box_size"] lx = 2 * a0 * len_x * box_x ly = 2 * a0 * len_y * box_y lz = a0 * len_z - + positions = [] max_range = int(max(box_x, box_y) * max(len_x, len_y) + 10) - + for i in range(-max_range, max_range + 1): for j in range(-max_range, max_range + 1): for k in range(2): for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: - pos_cubic = a0 * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = R @ pos_cubic - if (-lx/2 <= pos_oriented[0] < lx/2 and - -ly/2 <= pos_oriented[1] < ly/2 and - 0 <= pos_oriented[2] < lz): + pos_cubic = a0 * np.array( + [i + basis[0], j + basis[1], k + basis[2]] + ) + pos_oriented = rot @ pos_cubic + if ( + -lx / 2 <= pos_oriented[0] < lx / 2 + and -ly / 2 <= pos_oriented[1] < ly / 2 + and 0 <= pos_oriented[2] < lz + ): positions.append(pos_oriented) - + positions = np.array(positions) - _, unique_idx = np.unique(np.round(positions, decimals=5), axis=0, return_index=True) + _, unique_idx = np.unique( + np.round(positions, decimals=5), axis=0, return_index=True + ) positions = positions[unique_idx] - - tip_x = a0 * config['tip_factors'][0] * 0.25 - tip_y = a0 * config['tip_factors'][1] * 0.25 - - radius = min(lx/2, ly/2) - 1.0 - r = np.sqrt((positions[:, 0] - tip_x)**2 + (positions[:, 1] - tip_y)**2) + + tip_x = a0 * config["tip_factors"][0] * 0.25 + tip_y = a0 * config["tip_factors"][1] * 0.25 + + radius = min(lx / 2, ly / 2) - 1.0 + r = np.sqrt((positions[:, 0] - tip_x) ** 2 + (positions[:, 1] - tip_y) ** 2) keep_mask = r < radius positions = positions[keep_mask] - + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) - atoms = Atoms(symbols=['Fe'] * len(positions), positions=positions, cell=cell, pbc=[False, False, True]) + atoms = Atoms( + symbols=["Fe"] * len(positions), + positions=positions, + cell=cell, + pbc=[False, False, True], + ) atoms.center() - - center = np.array([lx/2, ly/2, lz/2]) - crack_tip = (tip_x + center[0] - lx/2, tip_y + center[1] - ly/2) - + + center = np.array([lx / 2, ly / 2, lz / 2]) + crack_tip = (tip_x + center[0] - lx / 2, tip_y + center[1] - ly / 2) + return atoms, crack_tip, radius @@ -944,19 +1435,48 @@ def create_crack_cell( # Elastic Calculation Utilities # ============================================================================= + def apply_strain(atoms: Atoms, strain_matrix: np.ndarray) -> Atoms: - """Apply a strain to the atoms object.""" + """ + Apply a strain to the atoms object. + + Parameters + ---------- + atoms : Atoms + ASE Atoms object. + strain_matrix : np.ndarray + 3x3 strain matrix. + + Returns + ------- + Atoms + Strained ASE Atoms object. + """ atoms_strained = atoms.copy() - F = np.eye(3) + strain_matrix - new_cell = atoms_strained.cell @ F.T + deformation = np.eye(3) + strain_matrix + new_cell = atoms_strained.cell @ deformation.T atoms_strained.set_cell(new_cell, scale_atoms=True) return atoms_strained def get_voigt_strain(direction: int, magnitude: float) -> np.ndarray: - """Get the strain tensor for a given Voigt direction (1-6).""" + """ + Get the strain tensor for a given Voigt direction (1-6). + + Parameters + ---------- + direction : int + Voigt direction (1-6). + magnitude : float + Strain magnitude. + + Returns + ------- + np.ndarray + 3x3 strain matrix. + """ strain = np.zeros((3, 3)) - + if direction == 1: strain[0, 0] = magnitude elif direction == 2: @@ -972,11 +1492,27 @@ def get_voigt_strain(direction: int, magnitude: float) -> np.ndarray: elif direction == 6: strain[0, 1] = magnitude / 2 strain[1, 0] = magnitude / 2 - + return strain -def calculate_surface_energy(E_slab: float, E_bulk: float, area: float) -> float: - """Calculate surface energy in J/m^2.""" - delta_E = E_slab - E_bulk - return delta_E * EV_TO_J / (2 * area * ANGSTROM_TO_M**2) +def calculate_surface_energy(e_slab: float, e_bulk: float, area: float) -> float: + """ + Calculate surface energy in J/m^2. + + Parameters + ---------- + e_slab : float + Total energy of the slab with vacuum (eV). + e_bulk : float + Total energy of the bulk reference (eV). + area : float + Surface area (Angstrom^2). + + Returns + ------- + float + Surface energy in J/m^2. + """ + delta_e = e_slab - e_bulk + return delta_e * EV_TO_J / (2 * area * ANGSTROM_TO_M**2) From f0c000efde83871721fdd935e58b335de5ccb33e Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Fri, 30 Jan 2026 11:06:43 +0000 Subject: [PATCH 03/12] fix notation --- ml_peg/calcs/utils/iron_utils.py | 168 +++++++++++++++++-------------- 1 file changed, 90 insertions(+), 78 deletions(-) diff --git a/ml_peg/calcs/utils/iron_utils.py b/ml_peg/calcs/utils/iron_utils.py index 880d78b66..79265bfc1 100644 --- a/ml_peg/calcs/utils/iron_utils.py +++ b/ml_peg/calcs/utils/iron_utils.py @@ -453,14 +453,14 @@ def create_surface_111( ex = np.array([-1, 1, 0]) / np.sqrt(2) ey = np.array([1, 1, 1]) / np.sqrt(3) ez = np.array([1, 1, -2]) / np.sqrt(6) - rot = np.array([ex, ey, ez]) + R = np.array([ex, ey, ez]) # noqa: N806 for i in range(-max_range, max_range + 1): for j in range(-max_range, max_range + 1): for k in range(-max_range, max_range + 1): for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = rot @ pos_cubic + pos_oriented = R @ pos_cubic frac_x = pos_oriented[0] / lx frac_y = pos_oriented[1] / ly frac_z = pos_oriented[2] / lz @@ -524,14 +524,14 @@ def create_surface_112( ex = np.array([-1, 1, 0]) / np.sqrt(2) ey = np.array([1, 1, 1]) / np.sqrt(3) ez = np.array([1, 1, -2]) / np.sqrt(6) - rot = np.array([ex, ey, ez]) + R = np.array([ex, ey, ez]) # noqa: N806 for i in range(-max_range, max_range + 1): for j in range(-max_range, max_range + 1): for k in range(-max_range, max_range + 1): for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = rot @ pos_cubic + pos_oriented = R @ pos_cubic frac_x = pos_oriented[0] / lx frac_y = pos_oriented[1] / ly frac_z = pos_oriented[2] / lz @@ -581,7 +581,7 @@ def create_sfe_110_structure(lattice_parameter: float) -> Atoms: ex = np.array([-1, 1, 0]) / np.sqrt(2) ey = np.array([1, 1, 1]) / np.sqrt(3) ez = np.array([1, 1, -2]) / np.sqrt(6) - rot = np.array([ex, ey, ez]) + R = np.array([ex, ey, ez]) # noqa: N806 lx = a * np.sqrt(2) * size[0] ly = a * np.sqrt(3) * size[1] @@ -596,7 +596,7 @@ def create_sfe_110_structure(lattice_parameter: float) -> Atoms: for k in range(-max_range, max_range + 1): for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = rot @ pos_cubic + pos_oriented = R @ pos_cubic frac_x = pos_oriented[0] / lx frac_y = pos_oriented[1] / ly frac_z = pos_oriented[2] / lz @@ -641,7 +641,7 @@ def create_sfe_112_structure(lattice_parameter: float) -> Atoms: ex = np.array([1, 1, -2]) / np.sqrt(6) ey = np.array([-1, 1, 0]) / np.sqrt(2) ez = np.array([1, 1, 1]) / np.sqrt(3) - rot = np.array([ex, ey, ez]) + R = np.array([ex, ey, ez]) # noqa: N806 lx = a * np.sqrt(6) * size[0] ly = a * np.sqrt(2) * size[1] @@ -656,7 +656,7 @@ def create_sfe_112_structure(lattice_parameter: float) -> Atoms: for k in range(-max_range, max_range + 1): for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = rot @ pos_cubic + pos_oriented = R @ pos_cubic frac_x = pos_oriented[0] / lx frac_y = pos_oriented[1] / ly frac_z = pos_oriented[2] / lz @@ -726,7 +726,7 @@ def create_oriented_bcc_cell( ox = orient_x / np.linalg.norm(orient_x) oy = orient_y / np.linalg.norm(orient_y) oz = orient_z / np.linalg.norm(orient_z) - rot = np.array([ox, oy, oz]) + R = np.array([ox, oy, oz]) # noqa: N806 len_x = np.linalg.norm(orient_x) len_y = np.linalg.norm(orient_y) @@ -750,7 +750,7 @@ def create_oriented_bcc_cell( for k in range(-max_range, max_range + 1): for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = rot @ pos_cubic + pos_oriented = R @ pos_cubic eps = 1e-8 if ( -half_lx - eps <= pos_oriented[0] < half_lx - eps @@ -1090,14 +1090,18 @@ def get_crack_orientation( def aniso_disp_solution( - c_mat: np.ndarray, a1: np.ndarray, a2: np.ndarray, a3: np.ndarray, surf_e: float + C: np.ndarray, # noqa: N803 + a1: np.ndarray, + a2: np.ndarray, + a3: np.ndarray, + surfE: float, # noqa: N803 ) -> tuple: """ Solve the anisotropic LEFM displacement field coefficients. Parameters ---------- - c_mat : np.ndarray + C : np.ndarray 6x6 elastic stiffness matrix in Voigt notation. a1 : np.ndarray First orientation vector. @@ -1105,7 +1109,7 @@ def aniso_disp_solution( Second orientation vector. a3 : np.ndarray Third orientation vector. - surf_e : float + surfE : float Surface energy in J/m^2. Returns @@ -1115,68 +1119,68 @@ def aniso_disp_solution( K_I is the Griffith stress intensity factor, and G_I is the energy release rate. """ - s_inv = np.linalg.inv(c_mat) + S = np.linalg.inv(C) # noqa: N806 a1 = a1 / np.linalg.norm(a1) a2 = a2 / np.linalg.norm(a2) a3 = a3 / np.linalg.norm(a3) - q = np.array([a1, a2, a3]) + Q = np.array([a1, a2, a3]) # noqa: N806 - k1 = np.array( + K1 = np.array( # noqa: N806 [ - [q[0, 0] ** 2, q[0, 1] ** 2, q[0, 2] ** 2], - [q[1, 0] ** 2, q[1, 1] ** 2, q[1, 2] ** 2], - [q[2, 0] ** 2, q[2, 1] ** 2, q[2, 2] ** 2], + [Q[0, 0] ** 2, Q[0, 1] ** 2, Q[0, 2] ** 2], + [Q[1, 0] ** 2, Q[1, 1] ** 2, Q[1, 2] ** 2], + [Q[2, 0] ** 2, Q[2, 1] ** 2, Q[2, 2] ** 2], ] ) - k2 = np.array( + K2 = np.array( # noqa: N806 [ - [q[0, 1] * q[0, 2], q[0, 2] * q[0, 0], q[0, 0] * q[0, 1]], - [q[1, 1] * q[1, 2], q[1, 2] * q[1, 0], q[1, 0] * q[1, 1]], - [q[2, 1] * q[2, 2], q[2, 2] * q[2, 0], q[2, 0] * q[2, 1]], + [Q[0, 1] * Q[0, 2], Q[0, 2] * Q[0, 0], Q[0, 0] * Q[0, 1]], + [Q[1, 1] * Q[1, 2], Q[1, 2] * Q[1, 0], Q[1, 0] * Q[1, 1]], + [Q[2, 1] * Q[2, 2], Q[2, 2] * Q[2, 0], Q[2, 0] * Q[2, 1]], ] ) - k3 = np.array( + K3 = np.array( # noqa: N806 [ - [q[1, 0] * q[2, 0], q[1, 1] * q[2, 1], q[1, 2] * q[2, 2]], - [q[2, 0] * q[0, 0], q[2, 1] * q[0, 1], q[2, 2] * q[0, 2]], - [q[0, 0] * q[1, 0], q[0, 1] * q[1, 1], q[0, 2] * q[1, 2]], + [Q[1, 0] * Q[2, 0], Q[1, 1] * Q[2, 1], Q[1, 2] * Q[2, 2]], + [Q[2, 0] * Q[0, 0], Q[2, 1] * Q[0, 1], Q[2, 2] * Q[0, 2]], + [Q[0, 0] * Q[1, 0], Q[0, 1] * Q[1, 1], Q[0, 2] * Q[1, 2]], ] ) - k4 = np.array( + K4 = np.array( # noqa: N806 [ [ - q[1, 1] * q[2, 2] + q[1, 2] * q[2, 1], - q[1, 2] * q[2, 0] + q[1, 0] * q[2, 2], - q[1, 0] * q[2, 1] + q[1, 1] * q[2, 0], + Q[1, 1] * Q[2, 2] + Q[1, 2] * Q[2, 1], + Q[1, 2] * Q[2, 0] + Q[1, 0] * Q[2, 2], + Q[1, 0] * Q[2, 1] + Q[1, 1] * Q[2, 0], ], [ - q[2, 1] * q[0, 2] + q[2, 2] * q[0, 1], - q[2, 2] * q[0, 0] + q[2, 0] * q[0, 2], - q[2, 0] * q[0, 1] + q[2, 1] * q[0, 0], + Q[2, 1] * Q[0, 2] + Q[2, 2] * Q[0, 1], + Q[2, 2] * Q[0, 0] + Q[2, 0] * Q[0, 2], + Q[2, 0] * Q[0, 1] + Q[2, 1] * Q[0, 0], ], [ - q[0, 1] * q[1, 2] + q[0, 2] * q[1, 1], - q[0, 2] * q[1, 0] + q[0, 0] * q[1, 2], - q[0, 0] * q[1, 1] + q[0, 1] * q[1, 0], + Q[0, 1] * Q[1, 2] + Q[0, 2] * Q[1, 1], + Q[0, 2] * Q[1, 0] + Q[0, 0] * Q[1, 2], + Q[0, 0] * Q[1, 1] + Q[0, 1] * Q[1, 0], ], ] ) - k_mat = np.vstack((np.hstack((k1, 2 * k2)), np.hstack((k3, k4)))) - s_star = np.linalg.inv(k_mat).T @ s_inv @ np.linalg.inv(k_mat) + K_mat = np.vstack((np.hstack((K1, 2 * K2)), np.hstack((K3, K4)))) # noqa: N806 + S_star = np.linalg.inv(K_mat).T @ S @ np.linalg.inv(K_mat) # noqa: N806 - b_11 = (s_star[0, 0] * s_star[2, 2] - s_star[0, 2] ** 2) / s_star[2, 2] - b_22 = (s_star[1, 1] * s_star[2, 2] - s_star[1, 2] ** 2) / s_star[2, 2] - b_66 = (s_star[5, 5] * s_star[2, 2] - s_star[2, 5] ** 2) / s_star[2, 2] - b_12 = (s_star[0, 1] * s_star[2, 2] - s_star[0, 2] * s_star[1, 2]) / s_star[2, 2] - b_16 = (s_star[0, 5] * s_star[2, 2] - s_star[0, 2] * s_star[2, 5]) / s_star[2, 2] - b_26 = (s_star[1, 5] * s_star[2, 2] - s_star[1, 2] * s_star[2, 5]) / s_star[2, 2] + b_11 = (S_star[0, 0] * S_star[2, 2] - S_star[0, 2] ** 2) / S_star[2, 2] + b_22 = (S_star[1, 1] * S_star[2, 2] - S_star[1, 2] ** 2) / S_star[2, 2] + b_66 = (S_star[5, 5] * S_star[2, 2] - S_star[2, 5] ** 2) / S_star[2, 2] + b_12 = (S_star[0, 1] * S_star[2, 2] - S_star[0, 2] * S_star[1, 2]) / S_star[2, 2] + b_16 = (S_star[0, 5] * S_star[2, 2] - S_star[0, 2] * S_star[2, 5]) / S_star[2, 2] + b_26 = (S_star[1, 5] * S_star[2, 2] - S_star[1, 2] * S_star[2, 5]) / S_star[2, 2] - b_factor = np.sqrt( + B = np.sqrt( # noqa: N806 (b_11 * b_22 / 2) * (np.sqrt(b_22 / b_11) + ((2 * b_12 + b_66) / (2 * b_11))) ) - k_i = np.sqrt(2 * surf_e * (1 / (b_factor * 1000))) - g_i = 2 * surf_e + K_I = np.sqrt(2 * surfE * (1 / (B * 1000))) # noqa: N806 + G_I = 2 * surfE # noqa: N806 coefvct = [b_11, -2 * b_16, 2 * b_12 + b_66, -2 * b_26, b_22] rt = np.roots(coefvct) @@ -1189,22 +1193,26 @@ def aniso_disp_solution( ) q = np.array([b_12 * s[0] + b_22 / s[0] - b_26, b_12 * s[1] + b_22 / s[1] - b_26]) - return s, p, q, k_i, g_i + return s, p, q, K_I, G_I def compute_lefm_coefficients( - c11: float, c12: float, c44: float, surface_energy: float, crack_system: int + C11: float, # noqa: N803 + C12: float, # noqa: N803 + C44: float, # noqa: N803 + surface_energy: float, + crack_system: int, ) -> dict[str, Any]: """ Compute LEFM coefficients for anisotropic crack analysis. Parameters ---------- - c11 : float + C11 : float Elastic constant C11 in GPa. - c12 : float + C12 : float Elastic constant C12 in GPa. - c44 : float + C44 : float Elastic constant C44 in GPa. surface_energy : float Surface energy in J/m^2. @@ -1216,18 +1224,18 @@ def compute_lefm_coefficients( dict Dictionary with LEFM coefficients s1, s2, p1, p2, q1, q2, K_I, G_I. """ - c_mat = np.array( + C = np.array( # noqa: N806 [ - [c11, c12, c12, 0, 0, 0], - [c12, c11, c12, 0, 0, 0], - [c12, c12, c11, 0, 0, 0], - [0, 0, 0, c44, 0, 0], - [0, 0, 0, 0, c44, 0], - [0, 0, 0, 0, 0, c44], + [C11, C12, C12, 0, 0, 0], + [C12, C11, C12, 0, 0, 0], + [C12, C12, C11, 0, 0, 0], + [0, 0, 0, C44, 0, 0], + [0, 0, 0, 0, C44, 0], + [0, 0, 0, 0, 0, C44], ] ) a1, a2, a3 = get_crack_orientation(crack_system) - s, p, q, k_i, g_i = aniso_disp_solution(c_mat, a1, a2, a3, surface_energy) + s, p, q, K_I, G_I = aniso_disp_solution(C, a1, a2, a3, surface_energy) # noqa: N806 return { "s1": s[0], "s2": s[1], @@ -1235,14 +1243,14 @@ def compute_lefm_coefficients( "p2": p[1], "q1": q[0], "q2": q[1], - "K_I": k_i, - "G_I": g_i, + "K_I": K_I, + "G_I": G_I, } def apply_crack_displacement( positions: np.ndarray, - k_sif: float, + K: float, # noqa: N803 coeffs: dict[str, Any], crack_tip: tuple[float, float], reference_positions: np.ndarray | None = None, @@ -1254,7 +1262,7 @@ def apply_crack_displacement( ---------- positions : np.ndarray Current atomic positions (N, 3). - k_sif : float + K : float Stress intensity factor. coeffs : dict LEFM coefficients from compute_lefm_coefficients. @@ -1282,7 +1290,7 @@ def apply_crack_displacement( r = np.maximum(np.sqrt(x**2 + y**2), 1e-10) theta = np.arctan2(y, x) - coef = k_sif * np.sqrt(2.0 * r / np.pi) + coef = K * np.sqrt(2.0 * r / np.pi) z1 = np.cos(theta) + s1 * np.sin(theta) z2 = np.cos(theta) + s2 * np.sin(theta) @@ -1300,7 +1308,7 @@ def apply_crack_displacement( def compute_incremental_displacement( positions: np.ndarray, - dk: float, + dK: float, # noqa: N803 coeffs: dict[str, Any], crack_tip: tuple[float, float], ) -> tuple[np.ndarray, np.ndarray]: @@ -1311,7 +1319,7 @@ def compute_incremental_displacement( ---------- positions : np.ndarray Atomic positions (N, 3). - dk : float + dK : float Increment in stress intensity factor. coeffs : dict LEFM coefficients from compute_lefm_coefficients. @@ -1333,7 +1341,7 @@ def compute_incremental_displacement( r = np.maximum(np.sqrt(x**2 + y**2), 1e-10) theta = np.arctan2(y, x) - coef = dk * np.sqrt(2.0 * r / np.pi) + coef = dK * np.sqrt(2.0 * r / np.pi) z1 = np.cos(theta) + s1 * np.sin(theta) z2 = np.cos(theta) + s2 * np.sin(theta) @@ -1373,7 +1381,7 @@ def create_crack_cell( ox = config["orient_x"] / np.linalg.norm(config["orient_x"]) oy = config["orient_y"] / np.linalg.norm(config["orient_y"]) oz = config["orient_z"] / np.linalg.norm(config["orient_z"]) - rot = np.array([ox, oy, oz]) + R = np.array([ox, oy, oz]) # noqa: N806 len_x = np.linalg.norm(config["orient_x"]) len_y = np.linalg.norm(config["orient_y"]) @@ -1394,7 +1402,7 @@ def create_crack_cell( pos_cubic = a0 * np.array( [i + basis[0], j + basis[1], k + basis[2]] ) - pos_oriented = rot @ pos_cubic + pos_oriented = R @ pos_cubic if ( -lx / 2 <= pos_oriented[0] < lx / 2 and -ly / 2 <= pos_oriented[1] < ly / 2 @@ -1453,8 +1461,8 @@ def apply_strain(atoms: Atoms, strain_matrix: np.ndarray) -> Atoms: Strained ASE Atoms object. """ atoms_strained = atoms.copy() - deformation = np.eye(3) + strain_matrix - new_cell = atoms_strained.cell @ deformation.T + F = np.eye(3) + strain_matrix # noqa: N806 + new_cell = atoms_strained.cell @ F.T atoms_strained.set_cell(new_cell, scale_atoms=True) return atoms_strained @@ -1496,15 +1504,19 @@ def get_voigt_strain(direction: int, magnitude: float) -> np.ndarray: return strain -def calculate_surface_energy(e_slab: float, e_bulk: float, area: float) -> float: +def calculate_surface_energy( + E_slab: float, # noqa: N803 + E_bulk: float, # noqa: N803 + area: float, +) -> float: """ Calculate surface energy in J/m^2. Parameters ---------- - e_slab : float + E_slab : float Total energy of the slab with vacuum (eV). - e_bulk : float + E_bulk : float Total energy of the bulk reference (eV). area : float Surface area (Angstrom^2). @@ -1514,5 +1526,5 @@ def calculate_surface_energy(e_slab: float, e_bulk: float, area: float) -> float float Surface energy in J/m^2. """ - delta_e = e_slab - e_bulk - return delta_e * EV_TO_J / (2 * area * ANGSTROM_TO_M**2) + delta_E = E_slab - E_bulk # noqa: N806 + return delta_E * EV_TO_J / (2 * area * ANGSTROM_TO_M**2) From 2a3a3c2cf9b0cc4dccaadf9ca27bb494fceb620c Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Mon, 2 Feb 2026 21:01:23 +0000 Subject: [PATCH 04/12] remove crack and dislocation tests as they use too many atoms for most models --- .../analyse_iron_properties.py | 393 +++++--- .../physicality/iron_properties/metrics.yml | 38 - .../iron_properties/app_iron_properties.py | 38 +- .../iron_properties/calc_iron_properties.py | 333 +------ ml_peg/calcs/utils/iron_utils.py | 900 +----------------- 5 files changed, 242 insertions(+), 1460 deletions(-) diff --git a/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py index 87326a793..ed07669e1 100644 --- a/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py +++ b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py @@ -1,14 +1,14 @@ -"""Analyse BCC iron properties benchmark. +""" +Analyse BCC iron properties benchmark. -This analysis combines EOS, elastic, Bain path, defect, surface, stacking fault, -dislocation, and fracture properties. +This analysis combines EOS, elastic, Bain path, defect, surface, and stacking fault +properties. Reference ---------- +---------- Zhang, L., Csányi, G., van der Giessen, E., & Maresca, F. (2023). Efficiency, Accuracy, and Transferability of Machine Learning Potentials: Application to Dislocations and Cracks in Iron. -arXiv:2307.10072. https://arxiv.org/abs/2307.10072 """ from __future__ import annotations @@ -38,39 +38,34 @@ # DFT reference values DFT_REFERENCE = { # EOS properties - 'a0': 2.831, # Lattice parameter (Å) - 'B0': 178.0, # Bulk modulus (GPa) - 'E_bcc_fcc': 83.5, # BCC-FCC energy difference (meV/atom) + "a0": 2.831, # Lattice parameter (Å) + "B0": 178.0, # Bulk modulus (GPa) + "E_bcc_fcc": 83.5, # BCC-FCC energy difference (meV/atom) # Defect properties - 'E_vac': 2.02, # Vacancy formation energy (eV) - 'gamma_100': 2.41, # Surface energy (J/m²) - 'gamma_110': 2.37, - 'gamma_111': 2.58, - 'gamma_112': 2.48, - 'gamma_us_110': 0.75, # Unstable SFE (J/m²) - 'gamma_us_112': 1.12, - # Dislocation properties (approximate) - 'core_energy_screw_111': 1.8, # eV - 'core_energy_edge_111_110': 2.2, # eV - # Crack K_Griffith (MPa*sqrt(m)) - 'K_Griffith_1': 1.05, - 'K_Griffith_2': 1.02, - 'K_Griffith_3': 0.98, - 'K_Griffith_4': 0.95, -} - -# Dislocation type mapping -DISLOCATION_NAMES = { - 'edge_100_010': 'Edge a0[100](010)', - 'edge_100_011': 'Edge a0[100](011)', - 'edge_111_110': 'Edge a0/2[111](110)', - 'mixed_111': 'Mixed 70.5° a0/2[111](110)', - 'screw_111': 'Screw a0/2[111](112)', + "E_vac": 2.02, # Vacancy formation energy (eV) + "gamma_100": 2.41, # Surface energy (J/m²) + "gamma_110": 2.37, + "gamma_111": 2.58, + "gamma_112": 2.48, + "gamma_us_110": 0.75, # Unstable SFE (J/m²) + "gamma_us_112": 1.12, } def load_model_results(model_name: str) -> dict[str, Any] | None: - """Load iron properties results for a model.""" + """ + Load iron properties results for a model. + + Parameters + ---------- + model_name : str + Name of the model to load results for. + + Returns + ------- + dict[str, Any] | None + Dictionary of results, or None if file does not exist. + """ json_path = CALC_PATH / model_name / "results.json" if not json_path.exists(): return None @@ -78,7 +73,19 @@ def load_model_results(model_name: str) -> dict[str, Any] | None: def load_eos_curve(model_name: str) -> pd.DataFrame: - """Load EOS curve data for a model.""" + """ + Load EOS curve data for a model. + + Parameters + ---------- + model_name : str + Name of the model to load EOS curve for. + + Returns + ------- + pd.DataFrame + EOS curve data, or empty DataFrame if file does not exist. + """ csv_path = CALC_PATH / model_name / "eos_curve.csv" if not csv_path.exists(): return pd.DataFrame() @@ -86,7 +93,19 @@ def load_eos_curve(model_name: str) -> pd.DataFrame: def load_bain_curve(model_name: str) -> pd.DataFrame: - """Load Bain path curve data for a model.""" + """ + Load Bain path curve data for a model. + + Parameters + ---------- + model_name : str + Name of the model to load Bain path curve for. + + Returns + ------- + pd.DataFrame + Bain path curve data, or empty DataFrame if file does not exist. + """ csv_path = CALC_PATH / model_name / "bain_path.csv" if not csv_path.exists(): return pd.DataFrame() @@ -94,7 +113,19 @@ def load_bain_curve(model_name: str) -> pd.DataFrame: def load_sfe_110_curve(model_name: str) -> pd.DataFrame: - """Load SFE 110 curve data for a model.""" + """ + Load SFE 110 curve data for a model. + + Parameters + ---------- + model_name : str + Name of the model to load SFE 110 curve for. + + Returns + ------- + pd.DataFrame + SFE 110 curve data, or empty DataFrame if file does not exist. + """ csv_path = CALC_PATH / model_name / "sfe_110_curve.csv" if not csv_path.exists(): return pd.DataFrame() @@ -102,134 +133,138 @@ def load_sfe_110_curve(model_name: str) -> pd.DataFrame: def load_sfe_112_curve(model_name: str) -> pd.DataFrame: - """Load SFE 112 curve data for a model.""" - csv_path = CALC_PATH / model_name / "sfe_112_curve.csv" - if not csv_path.exists(): - return pd.DataFrame() - return pd.read_csv(csv_path) + """ + Load SFE 112 curve data for a model. + Parameters + ---------- + model_name : str + Name of the model to load SFE 112 curve for. -def load_crack_ke_curve(model_name: str, crack_system: int) -> pd.DataFrame: - """Load crack K-E curve data for a model.""" - csv_path = CALC_PATH / model_name / f"crack_{crack_system}_KE.csv" + Returns + ------- + pd.DataFrame + SFE 112 curve data, or empty DataFrame if file does not exist. + """ + csv_path = CALC_PATH / model_name / "sfe_112_curve.csv" if not csv_path.exists(): return pd.DataFrame() return pd.read_csv(csv_path) def compute_metrics(results: dict[str, Any]) -> dict[str, float]: - """Compute metrics from model results.""" + """ + Compute metrics from model results. + + Parameters + ---------- + results : dict[str, Any] + Dictionary containing model calculation results. + + Returns + ------- + dict[str, float] + Dictionary mapping metric names to computed values. + """ metrics: dict[str, float] = {} - + # ========================================================================== # EOS metrics # ========================================================================== - eos = results.get('eos', {}) - if 'a0' in eos: - a0_mlip = eos['a0'] - a0_error = abs(a0_mlip - DFT_REFERENCE['a0']) / DFT_REFERENCE['a0'] * 100 - metrics['a0 error (%)'] = a0_error - - if 'B0' in eos: - B0_mlip = eos['B0'] - B0_error = abs(B0_mlip - DFT_REFERENCE['B0']) / DFT_REFERENCE['B0'] * 100 - metrics['B0 error (%)'] = B0_error - + eos = results.get("eos", {}) + if "a0" in eos: + a0_mlip = eos["a0"] + a0_error = abs(a0_mlip - DFT_REFERENCE["a0"]) / DFT_REFERENCE["a0"] * 100 + metrics["a0 error (%)"] = a0_error + + if "B0" in eos: + B0_mlip = eos["B0"] # noqa: N806 + B0_error = abs(B0_mlip - DFT_REFERENCE["B0"]) / DFT_REFERENCE["B0"] * 100 # noqa: N806 + metrics["B0 error (%)"] = B0_error + # ========================================================================== # Bain path metrics # ========================================================================== - bain = results.get('bain_path', {}) - if 'delta_E_meV' in bain: - E_bcc_fcc_mlip = bain['delta_E_meV'] - E_bcc_fcc_error = abs(E_bcc_fcc_mlip - DFT_REFERENCE['E_bcc_fcc']) - metrics['BCC-FCC ΔE error (meV)'] = E_bcc_fcc_error - + bain = results.get("bain_path", {}) + if "delta_E_meV" in bain: + E_bcc_fcc_mlip = bain["delta_E_meV"] # noqa: N806 + E_bcc_fcc_error = abs(E_bcc_fcc_mlip - DFT_REFERENCE["E_bcc_fcc"]) # noqa: N806 + metrics["BCC-FCC ΔE error (meV)"] = E_bcc_fcc_error + # ========================================================================== # Elastic constants metrics # ========================================================================== - elastic = results.get('elastic', {}) - if 'C11' in elastic: - metrics['C11 (GPa)'] = elastic['C11'] - if 'C12' in elastic: - metrics['C12 (GPa)'] = elastic['C12'] - if 'C44' in elastic: - metrics['C44 (GPa)'] = elastic['C44'] - + elastic = results.get("elastic", {}) + if "C11" in elastic: + metrics["C11 (GPa)"] = elastic["C11"] + if "C12" in elastic: + metrics["C12 (GPa)"] = elastic["C12"] + if "C44" in elastic: + metrics["C44 (GPa)"] = elastic["C44"] + # ========================================================================== # Vacancy metrics # ========================================================================== - vacancy = results.get('vacancy', {}) - if 'E_vac' in vacancy: - E_vac_mlip = vacancy['E_vac'] - E_vac_error = abs(E_vac_mlip - DFT_REFERENCE['E_vac']) / DFT_REFERENCE['E_vac'] * 100 - metrics['E_vac error (%)'] = E_vac_error - + vacancy = results.get("vacancy", {}) + if "E_vac" in vacancy: + E_vac_mlip = vacancy["E_vac"] # noqa: N806 + E_vac_error = ( # noqa: N806 + abs(E_vac_mlip - DFT_REFERENCE["E_vac"]) / DFT_REFERENCE["E_vac"] * 100 + ) + metrics["E_vac error (%)"] = E_vac_error + # ========================================================================== # Surface energy metrics # ========================================================================== - surfaces = results.get('surfaces', {}) + surfaces = results.get("surfaces", {}) surface_errors = [] - - for surface in ['100', '110', '111', '112']: - key_mlip = f'gamma_{surface}' + + for surface in ["100", "110", "111", "112"]: + key_mlip = f"gamma_{surface}" if key_mlip in surfaces: gamma_mlip = surfaces[key_mlip] gamma_dft = DFT_REFERENCE[key_mlip] error = abs(gamma_mlip - gamma_dft) surface_errors.append(error) - + if surface_errors: - metrics['Surface MAE (J/m²)'] = np.mean(surface_errors) - + metrics["Surface MAE (J/m²)"] = np.mean(surface_errors) + # ========================================================================== # Stacking fault metrics # ========================================================================== - sfe_110 = results.get('sfe_110', {}) - if 'max_sfe' in sfe_110: - max_sfe_110_mlip = sfe_110['max_sfe'] - max_sfe_110_error = abs(max_sfe_110_mlip - DFT_REFERENCE['gamma_us_110']) / DFT_REFERENCE['gamma_us_110'] * 100 - metrics['Max SFE 110 error (%)'] = max_sfe_110_error - - sfe_112 = results.get('sfe_112', {}) - if 'max_sfe' in sfe_112: - max_sfe_112_mlip = sfe_112['max_sfe'] - max_sfe_112_error = abs(max_sfe_112_mlip - DFT_REFERENCE['gamma_us_112']) / DFT_REFERENCE['gamma_us_112'] * 100 - metrics['Max SFE 112 error (%)'] = max_sfe_112_error - - # ========================================================================== - # Dislocation core energy metrics - # ========================================================================== - dislocations = results.get('dislocations', {}) - core_energies = [] - - for disl_type, disl_data in dislocations.items(): - if isinstance(disl_data, dict) and 'core_energy' in disl_data: - core_energies.append(disl_data['core_energy']) - metrics[f'Core E {DISLOCATION_NAMES.get(disl_type, disl_type)} (eV)'] = disl_data['core_energy'] - - if core_energies: - metrics['Mean core energy (eV)'] = np.mean(core_energies) - - # ========================================================================== - # Crack K-test metrics - # ========================================================================== - cracks = results.get('cracks', {}) - K_Griffith_values = [] - - for crack_sys, crack_data in cracks.items(): - if isinstance(crack_data, dict) and 'K_Griffith' in crack_data: - K_G = crack_data['K_Griffith'] - K_Griffith_values.append(K_G) - metrics[f'K_Griffith {crack_data.get("name", f"System {crack_sys}")} (MPa√m)'] = K_G - - if K_Griffith_values: - metrics['Mean K_Griffith (MPa√m)'] = np.mean(K_Griffith_values) - + sfe_110 = results.get("sfe_110", {}) + if "max_sfe" in sfe_110: + max_sfe_110_mlip = sfe_110["max_sfe"] + max_sfe_110_error = ( + abs(max_sfe_110_mlip - DFT_REFERENCE["gamma_us_110"]) + / DFT_REFERENCE["gamma_us_110"] + * 100 + ) + metrics["Max SFE 110 error (%)"] = max_sfe_110_error + + sfe_112 = results.get("sfe_112", {}) + if "max_sfe" in sfe_112: + max_sfe_112_mlip = sfe_112["max_sfe"] + max_sfe_112_error = ( + abs(max_sfe_112_mlip - DFT_REFERENCE["gamma_us_112"]) + / DFT_REFERENCE["gamma_us_112"] + * 100 + ) + metrics["Max SFE 112 error (%)"] = max_sfe_112_error + return metrics def _load_all_results() -> dict[str, dict[str, Any]]: - """Load results for all models.""" + """ + Load results for all models. + + Returns + ------- + dict[str, dict[str, Any]] + Dictionary mapping model names to their results. + """ all_results: dict[str, dict[str, Any]] = {} for model_name in MODELS: results = load_model_results(model_name) @@ -240,7 +275,14 @@ def _load_all_results() -> dict[str, dict[str, Any]]: @pytest.fixture def iron_eos_curves() -> dict[str, pd.DataFrame]: - """Load EOS curves for all models.""" + """ + Load EOS curves for all models. + + Returns + ------- + dict[str, pd.DataFrame] + Dictionary mapping model names to their EOS curve DataFrames. + """ curves: dict[str, pd.DataFrame] = {} for model_name in MODELS: curve = load_eos_curve(model_name) @@ -251,7 +293,14 @@ def iron_eos_curves() -> dict[str, pd.DataFrame]: @pytest.fixture def iron_bain_curves() -> dict[str, pd.DataFrame]: - """Load Bain path curves for all models.""" + """ + Load Bain path curves for all models. + + Returns + ------- + dict[str, pd.DataFrame] + Dictionary mapping model names to their Bain path curve DataFrames. + """ curves: dict[str, pd.DataFrame] = {} for model_name in MODELS: curve = load_bain_curve(model_name) @@ -262,7 +311,14 @@ def iron_bain_curves() -> dict[str, pd.DataFrame]: @pytest.fixture def iron_sfe_110_curves() -> dict[str, pd.DataFrame]: - """Load SFE 110 curves for all models.""" + """ + Load SFE 110 curves for all models. + + Returns + ------- + dict[str, pd.DataFrame] + Dictionary mapping model names to their SFE 110 curve DataFrames. + """ curves: dict[str, pd.DataFrame] = {} for model_name in MODELS: curve = load_sfe_110_curve(model_name) @@ -273,7 +329,14 @@ def iron_sfe_110_curves() -> dict[str, pd.DataFrame]: @pytest.fixture def iron_sfe_112_curves() -> dict[str, pd.DataFrame]: - """Load SFE 112 curves for all models.""" + """ + Load SFE 112 curves for all models. + + Returns + ------- + dict[str, pd.DataFrame] + Dictionary mapping model names to their SFE 112 curve DataFrames. + """ curves: dict[str, pd.DataFrame] = {} for model_name in MODELS: curve = load_sfe_112_curve(model_name) @@ -282,42 +345,41 @@ def iron_sfe_112_curves() -> dict[str, pd.DataFrame]: return curves -@pytest.fixture -def iron_crack_curves() -> dict[str, dict[int, pd.DataFrame]]: - """Load crack K-E curves for all models.""" - curves: dict[str, dict[int, pd.DataFrame]] = {} - for model_name in MODELS: - model_curves: dict[int, pd.DataFrame] = {} - for crack_sys in [1, 2, 3, 4]: - curve = load_crack_ke_curve(model_name, crack_sys) - if not curve.empty: - model_curves[crack_sys] = curve - if model_curves: - curves[model_name] = model_curves - return curves - - def collect_metrics() -> pd.DataFrame: - """Gather metrics for all models.""" + """ + Gather metrics for all models. + + Returns + ------- + pd.DataFrame + DataFrame containing metrics for all models. + """ metrics_rows: list[dict[str, float | str]] = [] - + OUT_PATH.mkdir(parents=True, exist_ok=True) - + all_results = _load_all_results() - + for model_name, results in all_results.items(): model_metrics = compute_metrics(results) row = {"Model": model_name} | model_metrics metrics_rows.append(row) - + columns = ["Model"] + list(DEFAULT_THRESHOLDS.keys()) - + return pd.DataFrame(metrics_rows).reindex(columns=columns) @pytest.fixture def iron_properties_collection() -> pd.DataFrame: - """Collect iron properties metrics across all models.""" + """ + Collect iron properties metrics across all models. + + Returns + ------- + pd.DataFrame + DataFrame containing iron properties metrics for all models. + """ return collect_metrics() @@ -325,7 +387,19 @@ def iron_properties_collection() -> pd.DataFrame: def iron_properties_metrics_dataframe( iron_properties_collection: pd.DataFrame, ) -> pd.DataFrame: - """Provide the aggregated iron properties metrics dataframe.""" + """ + Provide the aggregated iron properties metrics dataframe. + + Parameters + ---------- + iron_properties_collection : pd.DataFrame + Collection of iron properties metrics. + + Returns + ------- + pd.DataFrame + The aggregated iron properties metrics DataFrame. + """ return iron_properties_collection @@ -341,12 +415,12 @@ def metrics( ) -> dict[str, dict]: """ Compute iron properties metrics for all models. - + Parameters ---------- iron_properties_metrics_dataframe Aggregated per-model metrics. - + Returns ------- dict[str, dict] @@ -365,5 +439,12 @@ def metrics( def test_iron_properties(metrics: dict[str, dict]) -> None: - """Run iron properties analysis.""" + """ + Run iron properties analysis. + + Parameters + ---------- + metrics : dict[str, dict] + Dictionary of iron properties metrics from the metrics fixture. + """ return diff --git a/ml_peg/analysis/physicality/iron_properties/metrics.yml b/ml_peg/analysis/physicality/iron_properties/metrics.yml index cd8064340..dc874db5a 100644 --- a/ml_peg/analysis/physicality/iron_properties/metrics.yml +++ b/ml_peg/analysis/physicality/iron_properties/metrics.yml @@ -61,41 +61,3 @@ metrics: unit: "%" tooltip: "Error in maximum stacking fault energy for {112}<111> slip system" level_of_theory: PBE - # Dislocation properties - Mean core energy (eV): - good: 0.0 - bad: 5.0 - unit: "eV" - tooltip: "Mean dislocation core energy across all types" - level_of_theory: DFT - Core E Screw a0/2[111](112) (eV): - good: 0.0 - bad: 3.0 - unit: "eV" - tooltip: "Core energy of screw a0/2[111](112) dislocation" - level_of_theory: DFT - Core E Edge a0/2[111](110) (eV): - good: 0.0 - bad: 3.0 - unit: "eV" - tooltip: "Core energy of edge a0/2[111](110) dislocation" - level_of_theory: DFT - # Fracture properties - Mean K_Griffith (MPa√m): - good: 1.0 - bad: 2.0 - unit: "MPa√m" - tooltip: "Mean Griffith stress intensity factor across crack systems" - level_of_theory: DFT - K_Griffith (100)[010] (MPa√m): - good: 1.0 - bad: 2.0 - unit: "MPa√m" - tooltip: "Griffith K for (100)[010] crack system" - level_of_theory: DFT - K_Griffith (110)[001] (MPa√m): - good: 1.0 - bad: 2.0 - unit: "MPa√m" - tooltip: "Griffith K for (110)[001] crack system" - level_of_theory: DFT diff --git a/ml_peg/app/physicality/iron_properties/app_iron_properties.py b/ml_peg/app/physicality/iron_properties/app_iron_properties.py index 35e2da98c..181391367 100644 --- a/ml_peg/app/physicality/iron_properties/app_iron_properties.py +++ b/ml_peg/app/physicality/iron_properties/app_iron_properties.py @@ -48,10 +48,6 @@ def _load_curve_data(model_name: str, curve_type: str) -> pd.DataFrame | None: "bain": "bain_path.csv", "sfe_110": "sfe_110_curve.csv", "sfe_112": "sfe_112_curve.csv", - "crack_1": "crack_1_KE.csv", - "crack_2": "crack_2_KE.csv", - "crack_3": "crack_3_KE.csv", - "crack_4": "crack_4_KE.csv", } filename = file_map.get(curve_type) @@ -158,30 +154,6 @@ def _create_figure(df: pd.DataFrame, curve_type: str, model_name: str) -> go.Fig yaxis_title="SFE (J/m²)", ) - elif curve_type.startswith("crack_"): - crack_names = { - "crack_1": "(100)[010]", - "crack_2": "(100)[001]", - "crack_3": "(110)[001]", - "crack_4": "(110)[1-10]", - } - crack_name = crack_names.get(curve_type, curve_type) - fig.add_trace( - go.Scatter( - x=df["K"], - y=df["energy"], - mode="lines+markers", - name=model_name, - line={"width": 2}, - marker={"size": 6}, - ) - ) - fig.update_layout( - title=f"Crack K-E Curve {crack_name} - {model_name}", - xaxis_title="K (MPa√m)", - yaxis_title="Energy (eV)", - ) - fig.update_layout( template="plotly_white", showlegend=True, @@ -276,10 +248,6 @@ def get_app() -> IronPropertiesApp: {"label": "Bain Path", "value": "bain"}, {"label": "SFE {110}<111>", "value": "sfe_110"}, {"label": "SFE {112}<111>", "value": "sfe_112"}, - {"label": "(100)[010] K-E Curve", "value": "crack_1"}, - {"label": "(100)[001] K-E Curve", "value": "crack_2"}, - {"label": "(110)[001] K-E Curve", "value": "crack_3"}, - {"label": "(110)[1-10] K-E Curve", "value": "crack_4"}, ], value="eos", clearable=False, @@ -304,10 +272,8 @@ def get_app() -> IronPropertiesApp: "Includes equation of state (lattice parameter, bulk modulus), " "elastic constants (C11, C12, C44), Bain path (BCC-FCC transformation), " "vacancy formation energy, surface energies (100, 110, 111, 112), " - "generalized stacking fault energy curves for {110}<111> and " - "{112}<111> slip systems, " - "dislocation core energies for 5 dislocation types (edge, mixed, screw), " - "and crack K-tests for 4 crack systems. " + "and generalized stacking fault energy curves for {110}<111> and " + "{112}<111> slip systems. " "This benchmark is computationally expensive and marked with " "@pytest.mark.slow." ), diff --git a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py index 7ba0af1c2..8e155d091 100644 --- a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py +++ b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py @@ -8,17 +8,8 @@ - Vacancy formation energy - Surface energies (100, 110, 111, 112) - Generalized stacking fault energy curves (110, 112) -- Dislocation core energies (5 types) -- Crack K-tests (4 systems) This benchmark is computationally expensive and marked with @pytest.mark.slow. - -References ----------- -Zhang, L., Csányi, G., van der Giessen, E., & Maresca, F. (2023). -Efficiency, Accuracy, and Transferability of Machine Learning Potentials: -Application to Dislocations and Cracks in Iron. -arXiv:2307.10072. https://arxiv.org/abs/2307.10072 """ from __future__ import annotations @@ -28,45 +19,27 @@ from typing import Any from ase.build import bulk -from ase.constraints import FixAtoms, FixedLine +from ase.constraints import FixedLine from ase.filters import ExpCellFilter -from ase.optimize import BFGS, FIRE +from ase.optimize import BFGS import numpy as np import pandas as pd import pytest from ml_peg.calcs.utils.iron_utils import ( - CRACK_SYSTEMS_CONFIG, - # Configurations - DISLOCATION_TYPES, EV_PER_A2_TO_J_PER_M2, EV_PER_A3_TO_GPA, - # Constants - apply_crack_displacement, - apply_edge_displacement, - apply_mixed_displacement, - apply_screw_displacement, - # Elastic utilities apply_strain, calculate_surface_energy, - compute_incremental_displacement, - # LEFM utilities - compute_lefm_coefficients, create_bain_cell, - # Structure creation create_bcc_supercell, - create_crack_cell, - # Dislocation utilities - create_dislocation_cell, create_sfe_110_structure, create_sfe_112_structure, create_surface_100, create_surface_110, create_surface_111, create_surface_112, - # EOS fitting fit_eos, - get_dislocation_info, get_voigt_strain, ) from ml_peg.models.get_models import load_models @@ -107,14 +80,6 @@ SFE_STEP_SIZE = 0.04 # Angstroms SFE_FMAX = 1e-5 -# Dislocation test parameters -DISLOCATION_FMAX = 1e-5 -DISLOCATION_STRESS_TOL = 100.0 # bar -DISLOCATION_MAX_ITERATIONS = 10 - -# Crack test parameters -CRACK_K_STEPS = 100 - # ============================================================================= # EOS Calculation @@ -598,243 +563,6 @@ def run_sfe_112_calculation(calc: Any, lattice_parameter: float) -> dict[str, An } -# ============================================================================= -# Dislocation Tests -# ============================================================================= - - -def run_dislocation_test( - calc: Any, a0: float, e_coh: float, dislocation_type: str -) -> dict[str, Any]: - """ - Run dislocation test for a specific type. - - Parameters - ---------- - calc : Any - ASE calculator object. - a0 : float - Equilibrium lattice parameter. - e_coh : float - Cohesive energy per atom. - dislocation_type : str - Type of dislocation (e.g., 'edge_100_010', 'screw_111'). - - Returns - ------- - dict - Dictionary with dislocation_type, name, core_energy, and n_atoms. - """ - config = get_dislocation_info(dislocation_type) - atoms = create_dislocation_cell(a0, dislocation_type) - atoms.calc = calc - - atoms_perfect = atoms.copy() - atoms_perfect.calc = calc - - # Box relaxation for perfect crystal with stress convergence loop - mask = [True, True, False, False, False, True] - for _iteration in range(DISLOCATION_MAX_ITERATIONS): - ecf = ExpCellFilter(atoms_perfect, mask=mask, scalar_pressure=0.0) - opt = BFGS(ecf, logfile=None) - opt.run(fmax=DISLOCATION_FMAX, steps=2000) - - # Check stress convergence (convert eV/ų to bar) - stress = atoms_perfect.get_stress() * 160.2176621 * 10000 # bar - if ( - abs(stress[0]) < DISLOCATION_STRESS_TOL - and abs(stress[1]) < DISLOCATION_STRESS_TOL - ): - break - - # Final relaxation with FIRE - opt = FIRE(atoms_perfect, logfile=None) - opt.run(fmax=DISLOCATION_FMAX, steps=5000) - - E_perfect = atoms_perfect.get_potential_energy() # noqa: N806 - n_atoms_perfect = len(atoms_perfect) - E_coh_local = E_perfect / n_atoms_perfect # noqa: N806 - - atoms = atoms_perfect.copy() - atoms.calc = calc - - burgers_vec = config["burgers"] - b_mag = a0 * np.linalg.norm(burgers_vec) - disl_type = config["type"] - - if disl_type == "screw": - apply_screw_displacement(atoms, b_mag) - elif disl_type == "edge": - # Use LAMMPS-matched deletion region parameters from config - delete_axis = config.get("delete_axis", 1) - delete_min = config.get("delete_min", -0.6) - delete_max = config.get("delete_max", 0.1) - atoms = apply_edge_displacement( - atoms, - b_mag, - a0, - delete_half_plane=True, - delete_axis=delete_axis, - delete_min=delete_min, - delete_max=delete_max, - ) - atoms.calc = calc - elif disl_type == "mixed": - # Use LAMMPS-matched parameters from config - screw_frac = config.get("screw_fraction", 0.325568) - edge_fac = config.get("edge_factor", 0.5) - delete_axis = config.get("delete_axis", 0) - delete_min = config.get("delete_min", -0.5) - delete_max = config.get("delete_max", 0.1) - atoms = apply_mixed_displacement( - atoms, - b_mag, - a0, - screw_fraction=screw_frac, - edge_factor=edge_fac, - delete_axis=delete_axis, - delete_min=delete_min, - delete_max=delete_max, - ) - atoms.calc = calc - - # Multi-stage relaxation of dislocation structure with stress convergence loop - mask = [True, True, False, False, False, True] - for _iteration in range(DISLOCATION_MAX_ITERATIONS): - ecf = ExpCellFilter(atoms, mask=mask, scalar_pressure=0.0) - opt = BFGS(ecf, logfile=None) - opt.run(fmax=DISLOCATION_FMAX, steps=2000) - - stress = atoms.get_stress() * 160.2176621 * 10000 # bar - if ( - abs(stress[0]) < DISLOCATION_STRESS_TOL - and abs(stress[1]) < DISLOCATION_STRESS_TOL - ): - break - - # Final relaxation with FIRE - opt = FIRE(atoms, logfile=None) - opt.run(fmax=DISLOCATION_FMAX, steps=5000) - - E_disl = atoms.get_potential_energy() # noqa: N806 - n_atoms_disl = len(atoms) - core_energy = E_disl - n_atoms_disl * E_coh_local - - return { - "dislocation_type": dislocation_type, - "name": config["name"], - "core_energy": core_energy, - "n_atoms": n_atoms_disl, - } - - -# ============================================================================= -# Crack Tests -# ============================================================================= - - -def run_crack_test( - calc: Any, - a0: float, - elastic_constants: dict[str, float], - surface_energies: dict[str, float], - crack_system: int, - k_steps: int = CRACK_K_STEPS, -) -> dict[str, Any]: - """ - Run crack K-test for a specific system. - - Parameters - ---------- - calc : Any - ASE calculator object. - a0 : float - Equilibrium lattice parameter. - elastic_constants : dict[str, float] - Dictionary with elastic constants C11, C12, C44. - surface_energies : dict[str, float] - Dictionary with surface energies for relevant surfaces. - crack_system : int - Crack system index (1-4). - k_steps : int, optional - Number of K steps for the K-test (default: CRACK_K_STEPS). - - Returns - ------- - dict - Dictionary with crack_system, name, K_Griffith, K_values, and energies. - """ - config = CRACK_SYSTEMS_CONFIG[crack_system] - surface = config["surface"] - surf_energy = surface_energies.get(surface, 2.0) - - coeffs = compute_lefm_coefficients( - elastic_constants["C11"], - elastic_constants["C12"], - elastic_constants["C44"], - surf_energy, - crack_system, - ) - k_griffith = coeffs["K_I"] - - atoms, crack_tip, radius = create_crack_cell(a0, crack_system) - atoms.calc = calc - - positions = atoms.get_positions() - xtip, ytip = crack_tip - r = np.sqrt((positions[:, 0] - xtip) ** 2 + (positions[:, 1] - ytip) ** 2) - boundary_mask = r > (radius - 10.0) - boundary_indices = np.where(boundary_mask)[0] - - opt = BFGS(atoms, logfile=None) - opt.run(fmax=1e-3, steps=10000) - - ref_positions = atoms.get_positions().copy() - - k_start = max(0.5, k_griffith * 100 - 10) - k_stop = k_start + k_steps - - new_positions = apply_crack_displacement( - atoms.get_positions(), k_start, coeffs, crack_tip, ref_positions - ) - atoms.set_positions(new_positions) - - dx, dy = compute_incremental_displacement(ref_positions, 1.0, coeffs, crack_tip) - - k_values = [] - energies = [] - - atoms.set_constraint(FixAtoms(indices=boundary_indices)) - dk = (k_stop - k_start) / k_steps if k_steps > 0 else 1.0 - - for i in range(k_steps + 1): - k = k_start + i * dk - - if i > 0: - positions = atoms.get_positions() - positions[:, 0] += dx * dk - positions[:, 1] += dy * dk - atoms.set_positions(positions) - - opt = BFGS(atoms, logfile=None) - try: - opt.run(fmax=1e-3, steps=5000) - except Exception: - pass - - energy = atoms.get_potential_energy() - k_values.append(k) - energies.append(energy) - - return { - "crack_system": crack_system, - "name": config["name"], - "K_Griffith": k_griffith, - "K_values": k_values, - "energies": energies, - } - - # ============================================================================= # Main Benchmark Function # ============================================================================= @@ -851,8 +579,6 @@ def run_iron_properties(model_name: str, model: Any) -> None: - Vacancy formation energy - Surface energies (100, 110, 111, 112) - Stacking fault energy curves (110, 112) - - Dislocation core energies (5 types) - - Crack K-tests (4 systems) Parameters ---------- @@ -872,7 +598,6 @@ def run_iron_properties(model_name: str, model: Any) -> None: eos_results = run_eos_calculation(calc) results["eos"] = eos_results a0 = eos_results["a0"] - e_coh = eos_results["E0"] print( f"[{model_name}] Lattice parameter: {a0:.4f} Å, " f"Bulk modulus: {eos_results['B0']:.1f} GPa" @@ -946,52 +671,6 @@ def run_iron_properties(model_name: str, model: Any) -> None: ) sfe_112_df.to_csv(write_dir / "sfe_112_curve.csv", index=False) - # Dislocation tests - print(f"[{model_name}] Running dislocation tests...") - dislocation_results = {} - for disl_type in DISLOCATION_TYPES: - print(f"[{model_name}] {disl_type}...") - try: - disl_result = run_dislocation_test(calc, a0, e_coh, disl_type) - dislocation_results[disl_type] = disl_result - except Exception as e: - print(f"[{model_name}] Error: {e}") - dislocation_results[disl_type] = {"error": str(e)} - results["dislocations"] = dislocation_results - - # Crack K-tests - print(f"[{model_name}] Running crack K-tests...") - crack_results = {} - for crack_sys in [1, 2, 3, 4]: - print(f"[{model_name}] System {crack_sys}...") - try: - crack_result = run_crack_test( - calc, - a0, - elastic_results, - { - "100": surface_results["gamma_100"], - "110": surface_results["gamma_110"], - }, - crack_sys, - K_steps=CRACK_K_STEPS, - ) - crack_results[crack_sys] = { - "name": crack_result["name"], - "K_Griffith": crack_result["K_Griffith"], - } - ke_df = pd.DataFrame( - { - "K": crack_result["K_values"], - "energy": crack_result["energies"], - } - ) - ke_df.to_csv(write_dir / f"crack_{crack_sys}_KE.csv", index=False) - except Exception as e: - print(f"[{model_name}] Error: {e}") - crack_results[crack_sys] = {"error": str(e)} - results["cracks"] = crack_results - # Save all results as JSON (write_dir / "results.json").write_text(json.dumps(results, indent=2, default=str)) @@ -1012,14 +691,6 @@ def run_iron_properties(model_name: str, model: Any) -> None: "max_sfe_112": sfe_112_results["max_sfe"], } - for disl_type, disl_data in dislocation_results.items(): - if "core_energy" in disl_data: - summary[f"core_energy_{disl_type}"] = disl_data["core_energy"] - - for crack_sys, crack_data in crack_results.items(): - if "K_Griffith" in crack_data: - summary[f"K_Griffith_{crack_sys}"] = crack_data["K_Griffith"] - (write_dir / "summary.json").write_text(json.dumps(summary, indent=2)) print(f"[{model_name}] Done. Results saved to {write_dir}") diff --git a/ml_peg/calcs/utils/iron_utils.py b/ml_peg/calcs/utils/iron_utils.py index 79265bfc1..554ef9b39 100644 --- a/ml_peg/calcs/utils/iron_utils.py +++ b/ml_peg/calcs/utils/iron_utils.py @@ -1,15 +1,7 @@ """ Utility functions for BCC iron property calculations. -This module provides structure creation, EOS fitting, dislocation utilities, -and LEFM functions for iron benchmarks. - -References ----------- -Zhang, L., Csányi, G., van der Giessen, E., & Maresca, F. (2023). -Efficiency, Accuracy, and Transferability of Machine Learning Potentials: -Application to Dislocations and Cracks in Iron. -arXiv:2307.10072. https://arxiv.org/abs/2307.10072 +This module provides structure creation and EOS fitting functions for iron benchmarks. """ from __future__ import annotations @@ -18,7 +10,6 @@ from ase import Atoms from ase.build import bulk -from ase.neighborlist import NeighborList import numpy as np from scipy.optimize import leastsq @@ -32,137 +23,6 @@ EV_PER_A3_TO_GPA = 160.21765 -# ============================================================================= -# Dislocation Configuration -# ============================================================================= - -DISLOCATION_CONFIGS = { - "edge_100_010": { - "name": "Edge a0[100](010)", - "orient_x": np.array([0, 0, 1]), - "orient_y": np.array([1, 0, 0]), - "orient_z": np.array([0, 1, 0]), - "size": (1, 50, 20), - "dim_divisors": (1, 1, 1), # LAMMPS: sqrt(1)*N*a for all (no /2) - "burgers": np.array([1, 0, 0]), - "type": "edge", - "slip_direction": 1, - # Half-plane deletion region: LAMMPS uses - # ymindip=-0.6*sqrt(1)*a, ymaxdip=0.1*sqrt(1)*a - "delete_axis": 1, # y-axis - "delete_min": -0.6, # factor * a - "delete_max": 0.1, # factor * a - }, - "edge_100_011": { - "name": "Edge a0[100](011)", - "orient_x": np.array([0, -1, 1]), - "orient_y": np.array([1, 0, 0]), - "orient_z": np.array([0, 1, 1]), - "size": (1, 80, 22), - "dim_divisors": (1, 2, 2), # LAMMPS: sqrt(2)*N, sqrt(1)/2*N, sqrt(2)/2*N - "burgers": np.array([1, 0, 0]), - "type": "edge", - "slip_direction": 1, - # Half-plane deletion region: LAMMPS uses - # ymindip=-0.6*sqrt(1)*a, ymaxdip=0.1*sqrt(1)*a - "delete_axis": 1, # y-axis - "delete_min": -0.6, # factor * a - "delete_max": 0.1, # factor * a - }, - "edge_111_110": { - "name": "Edge a0/2[111](110)", - "orient_x": np.array([1, 2, -1]), - "orient_y": np.array([-1, 1, 1]), - "orient_z": np.array([1, 0, 1]), - "size": (1, 40, 20), - "dim_divisors": (1, 2, 2), # LAMMPS: sqrt(6)*N, sqrt(3)/2*N, sqrt(2)/2*N - "burgers": np.array([0.5, 0.5, 0.5]), - "type": "edge", - "slip_direction": 1, - # Half-plane deletion region: LAMMPS uses - # ymindip=-0.3*sqrt(3)*a, ymaxdip=0.3*sqrt(3)*a - "delete_axis": 1, # y-axis - "delete_min": -0.3 * np.sqrt(3), # -0.52*a - "delete_max": 0.3 * np.sqrt(3), # 0.52*a - }, - "mixed_111": { - "name": "Mixed 70.5 deg a0/2[111](110)", - "orient_x": np.array([1, 2, -1]), - "orient_y": np.array([-1, 1, 1]), - "orient_z": np.array([1, 0, 1]), - "size": (40, 2, 19), - "dim_divisors": (2, 2, 2), # LAMMPS: sqrt(6)/2*N for all dimensions - "burgers": np.array([0.5, 0.5, 0.5]), - "type": "mixed", - "slip_direction": 1, - "screw_fraction": 0.325568, # cos(71°) from LAMMPS - "edge_factor": 0.5, # Additional factor from LAMMPS edge component - # Half-plane deletion region: LAMMPS uses - # xmindip=-0.5*sqrt(1)*a, xmaxdip=0.1*sqrt(1)*a - "delete_axis": 0, # x-axis (different from edge dislocations!) - "delete_min": -0.5, # factor * a - "delete_max": 0.1, # factor * a - }, - "screw_111": { - "name": "Screw a0/2[111](112)", - "orient_x": np.array([1, 2, -1]), - "orient_y": np.array([-1, 1, 1]), - "orient_z": np.array([1, 0, 1]), - "size": (60, 2, 19), - "dim_divisors": (2, 2, 2), # LAMMPS: sqrt(6)/2*N, sqrt(3)/2*N, sqrt(2)/2*N - "burgers": np.array([0.5, 0.5, 0.5]), - "type": "screw", - "slip_direction": 1, - }, -} - -DISLOCATION_TYPES = list(DISLOCATION_CONFIGS.keys()) - - -# ============================================================================= -# Crack System Configuration -# ============================================================================= - -CRACK_SYSTEMS_CONFIG = { - 1: { - "name": "(100)[010]", - "orient_x": np.array([0, 0, 1]), - "orient_y": np.array([1, 0, 0]), - "orient_z": np.array([0, 1, 0]), - "surface": "100", - "box_size": (50, 50), - "tip_factors": (1.0, 1.0), - }, - 2: { - "name": "(100)[001]", - "orient_x": np.array([0, -1, 1]), - "orient_y": np.array([1, 0, 0]), - "orient_z": np.array([0, 1, 1]), - "surface": "100", - "box_size": (38, 54), - "tip_factors": (np.sqrt(2), 1.0), - }, - 3: { - "name": "(110)[001]", - "orient_x": np.array([1, -1, 0]), - "orient_y": np.array([1, 1, 0]), - "orient_z": np.array([0, 0, 1]), - "surface": "110", - "box_size": (38, 38), - "tip_factors": (np.sqrt(2), np.sqrt(2)), - }, - 4: { - "name": "(110)[1-10]", - "orient_x": np.array([0, 0, -1]), - "orient_y": np.array([1, 1, 0]), - "orient_z": np.array([1, -1, 0]), - "surface": "110", - "box_size": (55, 38), - "tip_factors": (1.0, np.sqrt(2)), - }, -} - - # ============================================================================= # EOS Fitting Functions # ============================================================================= @@ -681,764 +541,6 @@ def create_sfe_112_structure(lattice_parameter: float) -> Atoms: return atoms -# ============================================================================= -# Dislocation Utilities -# ============================================================================= - - -def create_oriented_bcc_cell( - lattice_parameter: float, - orient_x: np.ndarray, - orient_y: np.ndarray, - orient_z: np.ndarray, - size: tuple[int, int, int], - dim_divisors: tuple[int, int, int] = (1, 2, 2), - symbol: str = "Fe", - center_cell: bool = True, -) -> Atoms: - """ - Create an oriented BCC supercell. - - Parameters - ---------- - lattice_parameter : float - The BCC lattice parameter in Angstroms. - orient_x, orient_y, orient_z : np.ndarray - Crystal orientation vectors for x, y, z axes. - size : tuple[int, int, int] - Number of periodic units in each direction. - dim_divisors : tuple[int, int, int] - Divisors for half-dimension calculation in each direction. - Formula: half_dim = a * ||orient|| * size / divisor - Use (1, 1, 1) for no division, (2, 2, 2) for /2 on all, etc. - Must match LAMMPS conventions for each dislocation type. - symbol : str - Atomic symbol (default 'Fe'). - center_cell : bool - If True, center cell at origin; if False, shift to positive coords. - - Returns - ------- - Atoms - ASE Atoms object with the oriented BCC cell. - """ - a = lattice_parameter - ox = orient_x / np.linalg.norm(orient_x) - oy = orient_y / np.linalg.norm(orient_y) - oz = orient_z / np.linalg.norm(orient_z) - R = np.array([ox, oy, oz]) # noqa: N806 - - len_x = np.linalg.norm(orient_x) - len_y = np.linalg.norm(orient_y) - len_z = np.linalg.norm(orient_z) - - # Use dim_divisors to match LAMMPS half-dimension conventions - half_lx = a * len_x * size[0] / dim_divisors[0] - half_ly = a * len_y * size[1] / dim_divisors[1] - half_lz = a * len_z * size[2] / dim_divisors[2] - - lx = 2 * half_lx - ly = 2 * half_ly - lz = 2 * half_lz - - cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) - positions = [] - max_range = int(max(size) * max(len_x, len_y, len_z) + 10) - - for i in range(-max_range, max_range + 1): - for j in range(-max_range, max_range + 1): - for k in range(-max_range, max_range + 1): - for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: - pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = R @ pos_cubic - eps = 1e-8 - if ( - -half_lx - eps <= pos_oriented[0] < half_lx - eps - and -half_ly - eps <= pos_oriented[1] < half_ly - eps - and -half_lz - eps <= pos_oriented[2] < half_lz - eps - ): - positions.append(pos_oriented) - - if len(positions) == 0: - raise ValueError("No atoms found in the oriented cell") - - positions = np.array(positions) - _, unique_idx = np.unique( - np.round(positions, decimals=6), axis=0, return_index=True - ) - positions = positions[unique_idx] - - if not center_cell: - positions[:, 0] += half_lx - positions[:, 1] += half_ly - positions[:, 2] += half_lz - - atoms = Atoms( - symbols=[symbol] * len(positions), - positions=positions, - cell=cell, - pbc=[True, True, False], - ) - atoms.info["cell_center"] = ( - np.array([0, 0, 0]) if center_cell else np.array([half_lx, half_ly, half_lz]) - ) - atoms.info["half_dims"] = np.array([half_lx, half_ly, half_lz]) - - return atoms - - -def create_dislocation_cell( - lattice_parameter: float, dislocation_type: str, symbol: str = "Fe" -) -> Atoms: - """ - Create a cell for dislocation simulation. - - Uses the dim_divisors from DISLOCATION_CONFIGS to match LAMMPS cell sizes. - - Parameters - ---------- - lattice_parameter : float - The BCC lattice parameter in Angstroms. - dislocation_type : str - Type of dislocation (e.g., 'edge_100_010', 'screw_111'). - symbol : str, optional - Atomic symbol (default: 'Fe'). - - Returns - ------- - Atoms - ASE Atoms object with the dislocation cell. - """ - if dislocation_type not in DISLOCATION_CONFIGS: - raise ValueError(f"Unknown dislocation type: {dislocation_type}") - config = DISLOCATION_CONFIGS[dislocation_type] - return create_oriented_bcc_cell( - lattice_parameter, - config["orient_x"], - config["orient_y"], - config["orient_z"], - config["size"], - dim_divisors=config.get("dim_divisors", (1, 2, 2)), - symbol=symbol, - center_cell=True, - ) - - -def get_dislocation_info(dislocation_type: str) -> dict[str, Any]: - """ - Get information about a dislocation type. - - Parameters - ---------- - dislocation_type : str - Type of dislocation (e.g., 'edge_100_010', 'screw_111'). - - Returns - ------- - dict - Dictionary with dislocation configuration parameters. - """ - if dislocation_type not in DISLOCATION_CONFIGS: - raise ValueError(f"Unknown dislocation type: {dislocation_type}") - return DISLOCATION_CONFIGS[dislocation_type].copy() - - -def apply_screw_displacement(atoms: Atoms, burgers_magnitude: float) -> None: - """ - Apply screw dislocation displacement field. - - Parameters - ---------- - atoms : Atoms - ASE Atoms object with the dislocation cell. - burgers_magnitude : float - Magnitude of the Burgers vector. - """ - positions = atoms.get_positions() - cell = atoms.get_cell() - - if "half_dims" in atoms.info: - half_lx = atoms.info["half_dims"][0] - x_min, x_max = -half_lx, half_lx - z_mid = 0 - else: - x_min, x_max = 0, cell[0, 0] - z_mid = cell[2, 2] / 2 - - upper_mask = positions[:, 2] > z_mid - x = positions[upper_mask, 0] - fraction = (x - x_min) / (x_max - x_min) - displacement = -burgers_magnitude + fraction * burgers_magnitude - positions[upper_mask, 1] += displacement - atoms.set_positions(positions) - - -def delete_overlapping_atoms(atoms: Atoms, cutoff: float = 0.5) -> Atoms: - """ - Delete atoms that are too close to each other. - - Parameters - ---------- - atoms : Atoms - ASE Atoms object. - cutoff : float, optional - Distance cutoff for overlap detection (default: 0.5 Angstroms). - - Returns - ------- - Atoms - ASE Atoms object with overlapping atoms removed. - """ - if len(atoms) == 0: - return atoms - cutoffs = [cutoff / 2] * len(atoms) - nl = NeighborList(cutoffs, self_interaction=False, bothways=False) - nl.update(atoms) - to_delete = set() - for i in range(len(atoms)): - indices, _ = nl.get_neighbors(i) - for j in indices: - if j > i: - to_delete.add(j) - keep_mask = np.ones(len(atoms), dtype=bool) - keep_mask[list(to_delete)] = False - return atoms[keep_mask] - - -def apply_edge_displacement( - atoms: Atoms, - burgers_magnitude: float, - lattice_parameter: float, - delete_half_plane: bool = True, - delete_axis: int = 1, - delete_min: float = -0.6, - delete_max: float = 0.1, -) -> Atoms: - """ - Apply edge dislocation displacement. - - Parameters - ---------- - atoms : Atoms - ASE Atoms object with the dislocation cell. - burgers_magnitude : float - Magnitude of the Burgers vector. - lattice_parameter : float - BCC lattice parameter. - delete_half_plane : bool - If True, delete atoms in the half-plane region. - delete_axis : int - Axis along which to delete atoms (0=x, 1=y). Default 1 (y). - delete_min : float - Minimum position factor for deletion region (factor * a). - delete_max : float - Maximum position factor for deletion region (factor * a). - - Returns - ------- - Atoms - Atoms object with edge dislocation displacement applied. - - Notes - ----- - LAMMPS deletion regions by dislocation type: - - edge_100_010: y from -0.6a to 0.1a - - edge_100_011: y from -0.6a to 0.1a - - edge_111_110: y from -0.3*sqrt(3)*a to 0.3*sqrt(3)*a - """ - positions = atoms.get_positions() - cell = atoms.get_cell() - - if "half_dims" in atoms.info: - half_ly = atoms.info["half_dims"][1] - y_min, y_max = -half_ly, half_ly - z_mid = 0 - else: - y_min, y_max = 0, cell[1, 1] - z_mid = cell[2, 2] / 2 - - a = lattice_parameter - - if delete_half_plane: - # Use configurable deletion region - dip_min = delete_min * a - dip_max = delete_max * a - # Delete atoms in the specified axis range, below z_mid - keep_mask = ~( - (positions[:, delete_axis] >= dip_min) - & (positions[:, delete_axis] <= dip_max) - & (positions[:, 2] < z_mid) - ) - atoms = atoms[keep_mask] - positions = atoms.get_positions() - - quarter_b = 0.5 * a - mask_ll = (positions[:, 1] < 0) & (positions[:, 2] < z_mid) - if np.any(mask_ll): - y = positions[mask_ll, 1] - fraction = (y - y_min) / (0 - y_min) - positions[mask_ll, 1] += fraction * quarter_b - - mask_lr = (positions[:, 1] >= 0) & (positions[:, 2] < z_mid) - if np.any(mask_lr): - y = positions[mask_lr, 1] - fraction = (y - 0) / (y_max - 0) - positions[mask_lr, 1] += (1 - fraction) * (-quarter_b) - - atoms.set_positions(positions) - return delete_overlapping_atoms(atoms, cutoff=0.5) - - -def apply_mixed_displacement( - atoms: Atoms, - burgers_magnitude: float, - lattice_parameter: float, - screw_fraction: float = 0.325568, - edge_factor: float = 0.5, - delete_axis: int = 0, - delete_min: float = -0.5, - delete_max: float = 0.1, -) -> Atoms: - """ - Apply mixed dislocation displacement. - - Parameters - ---------- - atoms : Atoms - ASE Atoms object with the dislocation cell. - burgers_magnitude : float - Magnitude of the Burgers vector (sqrt(3)/2 * a for <111>). - lattice_parameter : float - BCC lattice parameter. - screw_fraction : float - Fraction of Burgers vector for screw component. - Default 0.325568 = cos(71°) from LAMMPS M111 dislocation. - edge_factor : float - Additional factor applied to edge component. - Default 0.5 from LAMMPS: edgeB = 0.5 * sin(71°) * |b|. - delete_axis : int - Axis along which to delete atoms (0=x, 1=y). Default 0 (x) for mixed. - delete_min : float - Minimum position factor for deletion region (factor * a). - delete_max : float - Maximum position factor for deletion region (factor * a). - - Returns - ------- - Atoms - Atoms object with mixed dislocation displacement applied. - - Notes - ----- - LAMMPS M111 uses: - - Screw: msft_disp = 0.325568 * sqrt(3)/2 * a = cos(71°) * |b| - - Edge: edgeB = 0.5 * 0.94 * sqrt(3)/2 * a = 0.5 * sin(71°) * |b| - - Deletion region: x from -0.5a to 0.1a (along x-axis, not y!) - """ - # Calculate edge fraction from geometry (sin of character angle) - # For 71° angle: sin(71°) ≈ 0.9455 - edge_fraction = np.sqrt(1 - screw_fraction**2) # sin(θ) from cos(θ) - - if edge_fraction > 0: - # Apply edge_factor (0.5 from LAMMPS) - edge_magnitude = burgers_magnitude * edge_fraction * edge_factor - atoms = apply_edge_displacement( - atoms, - edge_magnitude, - lattice_parameter, - delete_half_plane=True, - delete_axis=delete_axis, - delete_min=delete_min, - delete_max=delete_max, - ) - if screw_fraction > 0: - screw_magnitude = burgers_magnitude * screw_fraction - apply_screw_displacement(atoms, screw_magnitude) - return atoms - - -# ============================================================================= -# LEFM / Crack Utilities -# ============================================================================= - - -def get_crack_orientation( - crack_system: int, -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """ - Get crystallographic orientation vectors for a crack system. - - Parameters - ---------- - crack_system : int - Crack system index (1-4). - - Returns - ------- - tuple[np.ndarray, np.ndarray, np.ndarray] - Orientation vectors (a1, a2, a3) for the crack system. - """ - if crack_system == 1: - return np.array([0, 0, 1]), np.array([1, 0, 0]), np.array([0, 1, 0]) - if crack_system == 2: - return np.array([0, -1, 1]), np.array([1, 0, 0]), np.array([0, 1, 1]) - if crack_system == 3: - return np.array([1, -1, 0]), np.array([1, 1, 0]), np.array([0, 0, 1]) - if crack_system == 4: - return np.array([0, 0, -1]), np.array([1, 1, 0]), np.array([1, -1, 0]) - raise ValueError(f"Invalid crack system: {crack_system}") - - -def aniso_disp_solution( - C: np.ndarray, # noqa: N803 - a1: np.ndarray, - a2: np.ndarray, - a3: np.ndarray, - surfE: float, # noqa: N803 -) -> tuple: - """ - Solve the anisotropic LEFM displacement field coefficients. - - Parameters - ---------- - C : np.ndarray - 6x6 elastic stiffness matrix in Voigt notation. - a1 : np.ndarray - First orientation vector. - a2 : np.ndarray - Second orientation vector. - a3 : np.ndarray - Third orientation vector. - surfE : float - Surface energy in J/m^2. - - Returns - ------- - tuple - (s, p, q, K_I, G_I) where s, p, q are complex coefficients, - K_I is the Griffith stress intensity factor, and G_I is the - energy release rate. - """ - S = np.linalg.inv(C) # noqa: N806 - a1 = a1 / np.linalg.norm(a1) - a2 = a2 / np.linalg.norm(a2) - a3 = a3 / np.linalg.norm(a3) - Q = np.array([a1, a2, a3]) # noqa: N806 - - K1 = np.array( # noqa: N806 - [ - [Q[0, 0] ** 2, Q[0, 1] ** 2, Q[0, 2] ** 2], - [Q[1, 0] ** 2, Q[1, 1] ** 2, Q[1, 2] ** 2], - [Q[2, 0] ** 2, Q[2, 1] ** 2, Q[2, 2] ** 2], - ] - ) - K2 = np.array( # noqa: N806 - [ - [Q[0, 1] * Q[0, 2], Q[0, 2] * Q[0, 0], Q[0, 0] * Q[0, 1]], - [Q[1, 1] * Q[1, 2], Q[1, 2] * Q[1, 0], Q[1, 0] * Q[1, 1]], - [Q[2, 1] * Q[2, 2], Q[2, 2] * Q[2, 0], Q[2, 0] * Q[2, 1]], - ] - ) - K3 = np.array( # noqa: N806 - [ - [Q[1, 0] * Q[2, 0], Q[1, 1] * Q[2, 1], Q[1, 2] * Q[2, 2]], - [Q[2, 0] * Q[0, 0], Q[2, 1] * Q[0, 1], Q[2, 2] * Q[0, 2]], - [Q[0, 0] * Q[1, 0], Q[0, 1] * Q[1, 1], Q[0, 2] * Q[1, 2]], - ] - ) - K4 = np.array( # noqa: N806 - [ - [ - Q[1, 1] * Q[2, 2] + Q[1, 2] * Q[2, 1], - Q[1, 2] * Q[2, 0] + Q[1, 0] * Q[2, 2], - Q[1, 0] * Q[2, 1] + Q[1, 1] * Q[2, 0], - ], - [ - Q[2, 1] * Q[0, 2] + Q[2, 2] * Q[0, 1], - Q[2, 2] * Q[0, 0] + Q[2, 0] * Q[0, 2], - Q[2, 0] * Q[0, 1] + Q[2, 1] * Q[0, 0], - ], - [ - Q[0, 1] * Q[1, 2] + Q[0, 2] * Q[1, 1], - Q[0, 2] * Q[1, 0] + Q[0, 0] * Q[1, 2], - Q[0, 0] * Q[1, 1] + Q[0, 1] * Q[1, 0], - ], - ] - ) - - K_mat = np.vstack((np.hstack((K1, 2 * K2)), np.hstack((K3, K4)))) # noqa: N806 - S_star = np.linalg.inv(K_mat).T @ S @ np.linalg.inv(K_mat) # noqa: N806 - - b_11 = (S_star[0, 0] * S_star[2, 2] - S_star[0, 2] ** 2) / S_star[2, 2] - b_22 = (S_star[1, 1] * S_star[2, 2] - S_star[1, 2] ** 2) / S_star[2, 2] - b_66 = (S_star[5, 5] * S_star[2, 2] - S_star[2, 5] ** 2) / S_star[2, 2] - b_12 = (S_star[0, 1] * S_star[2, 2] - S_star[0, 2] * S_star[1, 2]) / S_star[2, 2] - b_16 = (S_star[0, 5] * S_star[2, 2] - S_star[0, 2] * S_star[2, 5]) / S_star[2, 2] - b_26 = (S_star[1, 5] * S_star[2, 2] - S_star[1, 2] * S_star[2, 5]) / S_star[2, 2] - - B = np.sqrt( # noqa: N806 - (b_11 * b_22 / 2) * (np.sqrt(b_22 / b_11) + ((2 * b_12 + b_66) / (2 * b_11))) - ) - K_I = np.sqrt(2 * surfE * (1 / (B * 1000))) # noqa: N806 - G_I = 2 * surfE # noqa: N806 - - coefvct = [b_11, -2 * b_16, 2 * b_12 + b_66, -2 * b_26, b_22] - rt = np.roots(coefvct) - s = rt[np.imag(rt) >= 0] - if np.real(s[0]) < np.real(s[1]): - s[0], s[1] = s[1], s[0] - - p = np.array( - [b_11 * s[0] ** 2 + b_12 - b_16 * s[0], b_11 * s[1] ** 2 + b_12 - b_16 * s[1]] - ) - q = np.array([b_12 * s[0] + b_22 / s[0] - b_26, b_12 * s[1] + b_22 / s[1] - b_26]) - - return s, p, q, K_I, G_I - - -def compute_lefm_coefficients( - C11: float, # noqa: N803 - C12: float, # noqa: N803 - C44: float, # noqa: N803 - surface_energy: float, - crack_system: int, -) -> dict[str, Any]: - """ - Compute LEFM coefficients for anisotropic crack analysis. - - Parameters - ---------- - C11 : float - Elastic constant C11 in GPa. - C12 : float - Elastic constant C12 in GPa. - C44 : float - Elastic constant C44 in GPa. - surface_energy : float - Surface energy in J/m^2. - crack_system : int - Crack system index (1-4). - - Returns - ------- - dict - Dictionary with LEFM coefficients s1, s2, p1, p2, q1, q2, K_I, G_I. - """ - C = np.array( # noqa: N806 - [ - [C11, C12, C12, 0, 0, 0], - [C12, C11, C12, 0, 0, 0], - [C12, C12, C11, 0, 0, 0], - [0, 0, 0, C44, 0, 0], - [0, 0, 0, 0, C44, 0], - [0, 0, 0, 0, 0, C44], - ] - ) - a1, a2, a3 = get_crack_orientation(crack_system) - s, p, q, K_I, G_I = aniso_disp_solution(C, a1, a2, a3, surface_energy) # noqa: N806 - return { - "s1": s[0], - "s2": s[1], - "p1": p[0], - "p2": p[1], - "q1": q[0], - "q2": q[1], - "K_I": K_I, - "G_I": G_I, - } - - -def apply_crack_displacement( - positions: np.ndarray, - K: float, # noqa: N803 - coeffs: dict[str, Any], - crack_tip: tuple[float, float], - reference_positions: np.ndarray | None = None, -) -> np.ndarray: - """ - Apply anisotropic LEFM crack displacement field to atomic positions. - - Parameters - ---------- - positions : np.ndarray - Current atomic positions (N, 3). - K : float - Stress intensity factor. - coeffs : dict - LEFM coefficients from compute_lefm_coefficients. - crack_tip : tuple[float, float] - (x, y) coordinates of the crack tip. - reference_positions : np.ndarray, optional - Reference positions for displacement calculation. - - Returns - ------- - np.ndarray - Updated atomic positions with crack displacement applied. - """ - s1, s2 = coeffs["s1"], coeffs["s2"] - p1, p2 = coeffs["p1"], coeffs["p2"] - q1, q2 = coeffs["q1"], coeffs["q2"] - xtip, ytip = crack_tip - - if reference_positions is None: - reference_positions = positions.copy() - - new_positions = positions.copy() - x = reference_positions[:, 0] - xtip - y = reference_positions[:, 1] - ytip - r = np.maximum(np.sqrt(x**2 + y**2), 1e-10) - theta = np.arctan2(y, x) - - coef = K * np.sqrt(2.0 * r / np.pi) - z1 = np.cos(theta) + s1 * np.sin(theta) - z2 = np.cos(theta) + s2 * np.sin(theta) - - sqrt_coef_1 = np.sqrt(z2.astype(complex)) - sqrt_coef_2 = np.sqrt(z1.astype(complex)) - denom = s1 - s2 - - coef_x = (s1 * p2 * sqrt_coef_1 - s2 * p1 * sqrt_coef_2) / denom - coef_y = (s1 * q2 * sqrt_coef_1 - s2 * q1 * sqrt_coef_2) / denom - - new_positions[:, 0] = positions[:, 0] + coef * np.real(coef_x) - new_positions[:, 1] = positions[:, 1] + coef * np.real(coef_y) - return new_positions - - -def compute_incremental_displacement( - positions: np.ndarray, - dK: float, # noqa: N803 - coeffs: dict[str, Any], - crack_tip: tuple[float, float], -) -> tuple[np.ndarray, np.ndarray]: - """ - Compute the incremental displacement for a K increment. - - Parameters - ---------- - positions : np.ndarray - Atomic positions (N, 3). - dK : float - Increment in stress intensity factor. - coeffs : dict - LEFM coefficients from compute_lefm_coefficients. - crack_tip : tuple[float, float] - (x, y) coordinates of the crack tip. - - Returns - ------- - tuple[np.ndarray, np.ndarray] - (dx, dy) incremental displacements for each atom. - """ - s1, s2 = coeffs["s1"], coeffs["s2"] - p1, p2 = coeffs["p1"], coeffs["p2"] - q1, q2 = coeffs["q1"], coeffs["q2"] - xtip, ytip = crack_tip - - x = positions[:, 0] - xtip - y = positions[:, 1] - ytip - r = np.maximum(np.sqrt(x**2 + y**2), 1e-10) - theta = np.arctan2(y, x) - - coef = dK * np.sqrt(2.0 * r / np.pi) - z1 = np.cos(theta) + s1 * np.sin(theta) - z2 = np.cos(theta) + s2 * np.sin(theta) - - sqrt_coef_1 = np.sqrt(z2.astype(complex)) - sqrt_coef_2 = np.sqrt(z1.astype(complex)) - denom = s1 - s2 - - coef_x = (s1 * p2 * sqrt_coef_1 - s2 * p1 * sqrt_coef_2) / denom - coef_y = (s1 * q2 * sqrt_coef_1 - s2 * q1 * sqrt_coef_2) / denom - - return coef * np.real(coef_x), coef * np.real(coef_y) - - -def create_crack_cell( - lattice_parameter: float, crack_system: int -) -> tuple[Atoms, tuple[float, float], float]: - """ - Create a circular domain for crack simulation. - - Parameters - ---------- - lattice_parameter : float - Lattice parameter in Angstroms. - crack_system : int - Crack system index (1-4). - - Returns - ------- - tuple[Atoms, tuple[float, float], float] - (atoms, crack_tip, radius) where atoms is the ASE Atoms object, - crack_tip is the (x, y) position of the crack tip, and radius - is the domain radius. - """ - config = CRACK_SYSTEMS_CONFIG[crack_system] - a0 = lattice_parameter - - ox = config["orient_x"] / np.linalg.norm(config["orient_x"]) - oy = config["orient_y"] / np.linalg.norm(config["orient_y"]) - oz = config["orient_z"] / np.linalg.norm(config["orient_z"]) - R = np.array([ox, oy, oz]) # noqa: N806 - - len_x = np.linalg.norm(config["orient_x"]) - len_y = np.linalg.norm(config["orient_y"]) - len_z = np.linalg.norm(config["orient_z"]) - - box_x, box_y = config["box_size"] - lx = 2 * a0 * len_x * box_x - ly = 2 * a0 * len_y * box_y - lz = a0 * len_z - - positions = [] - max_range = int(max(box_x, box_y) * max(len_x, len_y) + 10) - - for i in range(-max_range, max_range + 1): - for j in range(-max_range, max_range + 1): - for k in range(2): - for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: - pos_cubic = a0 * np.array( - [i + basis[0], j + basis[1], k + basis[2]] - ) - pos_oriented = R @ pos_cubic - if ( - -lx / 2 <= pos_oriented[0] < lx / 2 - and -ly / 2 <= pos_oriented[1] < ly / 2 - and 0 <= pos_oriented[2] < lz - ): - positions.append(pos_oriented) - - positions = np.array(positions) - _, unique_idx = np.unique( - np.round(positions, decimals=5), axis=0, return_index=True - ) - positions = positions[unique_idx] - - tip_x = a0 * config["tip_factors"][0] * 0.25 - tip_y = a0 * config["tip_factors"][1] * 0.25 - - radius = min(lx / 2, ly / 2) - 1.0 - r = np.sqrt((positions[:, 0] - tip_x) ** 2 + (positions[:, 1] - tip_y) ** 2) - keep_mask = r < radius - positions = positions[keep_mask] - - cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) - atoms = Atoms( - symbols=["Fe"] * len(positions), - positions=positions, - cell=cell, - pbc=[False, False, True], - ) - atoms.center() - - center = np.array([lx / 2, ly / 2, lz / 2]) - crack_tip = (tip_x + center[0] - lx / 2, tip_y + center[1] - ly / 2) - - return atoms, crack_tip, radius - - # ============================================================================= # Elastic Calculation Utilities # ============================================================================= From 3c25c2d897b7abb112bc968640dad05ded9df9a2 Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Mon, 2 Feb 2026 21:49:16 +0000 Subject: [PATCH 05/12] add T-S test for iron --- .../analyse_iron_properties.py | 172 ++++----- .../physicality/iron_properties/metrics.yml | 13 + .../iron_properties/app_iron_properties.py | 155 ++++---- .../iron_properties/calc_iron_properties.py | 364 +++++++++++------- ml_peg/calcs/utils/iron_utils.py | 333 +++++++++------- 5 files changed, 598 insertions(+), 439 deletions(-) diff --git a/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py index ed07669e1..2e5cef366 100644 --- a/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py +++ b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py @@ -49,6 +49,20 @@ "gamma_112": 2.48, "gamma_us_110": 0.75, # Unstable SFE (J/m²) "gamma_us_112": 1.12, + # Traction-separation properties + "max_traction_100": 35.0, # Max traction for (100) cleavage (GPa) + "max_traction_110": 30.0, # Max traction for (110) cleavage (GPa) +} + + +# Curve file mapping for CSV loading +CURVE_FILES = { + "eos": "eos_curve.csv", + "bain": "bain_path.csv", + "sfe_110": "sfe_110_curve.csv", + "sfe_112": "sfe_112_curve.csv", + "ts_100": "ts_100_curve.csv", + "ts_110": "ts_110_curve.csv", } @@ -72,81 +86,26 @@ def load_model_results(model_name: str) -> dict[str, Any] | None: return json.loads(json_path.read_text()) -def load_eos_curve(model_name: str) -> pd.DataFrame: - """ - Load EOS curve data for a model. - - Parameters - ---------- - model_name : str - Name of the model to load EOS curve for. - - Returns - ------- - pd.DataFrame - EOS curve data, or empty DataFrame if file does not exist. - """ - csv_path = CALC_PATH / model_name / "eos_curve.csv" - if not csv_path.exists(): - return pd.DataFrame() - return pd.read_csv(csv_path) - - -def load_bain_curve(model_name: str) -> pd.DataFrame: - """ - Load Bain path curve data for a model. - - Parameters - ---------- - model_name : str - Name of the model to load Bain path curve for. - - Returns - ------- - pd.DataFrame - Bain path curve data, or empty DataFrame if file does not exist. - """ - csv_path = CALC_PATH / model_name / "bain_path.csv" - if not csv_path.exists(): - return pd.DataFrame() - return pd.read_csv(csv_path) - - -def load_sfe_110_curve(model_name: str) -> pd.DataFrame: +def load_curve(model_name: str, curve_type: str) -> pd.DataFrame: """ - Load SFE 110 curve data for a model. + Load curve data for a model. Parameters ---------- model_name : str - Name of the model to load SFE 110 curve for. + Name of the model to load curve for. + curve_type : str + Type of curve to load (e.g., 'eos', 'bain', 'sfe_110'). Returns ------- pd.DataFrame - SFE 110 curve data, or empty DataFrame if file does not exist. + Curve data, or empty DataFrame if file does not exist. """ - csv_path = CALC_PATH / model_name / "sfe_110_curve.csv" - if not csv_path.exists(): + filename = CURVE_FILES.get(curve_type) + if not filename: return pd.DataFrame() - return pd.read_csv(csv_path) - - -def load_sfe_112_curve(model_name: str) -> pd.DataFrame: - """ - Load SFE 112 curve data for a model. - - Parameters - ---------- - model_name : str - Name of the model to load SFE 112 curve for. - - Returns - ------- - pd.DataFrame - SFE 112 curve data, or empty DataFrame if file does not exist. - """ - csv_path = CALC_PATH / model_name / "sfe_112_curve.csv" + csv_path = CALC_PATH / model_name / filename if not csv_path.exists(): return pd.DataFrame() return pd.read_csv(csv_path) @@ -253,6 +212,17 @@ def compute_metrics(results: dict[str, Any]) -> dict[str, float]: ) metrics["Max SFE 112 error (%)"] = max_sfe_112_error + # ========================================================================== + # Traction-separation metrics + # ========================================================================== + ts_100 = results.get("ts_100", {}) + if "max_traction" in ts_100: + metrics["Max traction (100) (GPa)"] = ts_100["max_traction"] + + ts_110 = results.get("ts_110", {}) + if "max_traction" in ts_110: + metrics["Max traction (110) (GPa)"] = ts_110["max_traction"] + return metrics @@ -273,24 +243,41 @@ def _load_all_results() -> dict[str, dict[str, Any]]: return all_results -@pytest.fixture -def iron_eos_curves() -> dict[str, pd.DataFrame]: +def _load_curves_for_all_models(curve_type: str) -> dict[str, pd.DataFrame]: """ - Load EOS curves for all models. + Load curves of given type for all models. + + Parameters + ---------- + curve_type : str + Type of curve to load (e.g., 'eos', 'bain', 'sfe_110'). Returns ------- dict[str, pd.DataFrame] - Dictionary mapping model names to their EOS curve DataFrames. + Dictionary mapping model names to their curve DataFrames. """ curves: dict[str, pd.DataFrame] = {} for model_name in MODELS: - curve = load_eos_curve(model_name) + curve = load_curve(model_name, curve_type) if not curve.empty: curves[model_name] = curve return curves +@pytest.fixture +def iron_eos_curves() -> dict[str, pd.DataFrame]: + """ + Load EOS curves for all models. + + Returns + ------- + dict[str, pd.DataFrame] + Dictionary mapping model names to their EOS curve DataFrames. + """ + return _load_curves_for_all_models("eos") + + @pytest.fixture def iron_bain_curves() -> dict[str, pd.DataFrame]: """ @@ -301,12 +288,7 @@ def iron_bain_curves() -> dict[str, pd.DataFrame]: dict[str, pd.DataFrame] Dictionary mapping model names to their Bain path curve DataFrames. """ - curves: dict[str, pd.DataFrame] = {} - for model_name in MODELS: - curve = load_bain_curve(model_name) - if not curve.empty: - curves[model_name] = curve - return curves + return _load_curves_for_all_models("bain") @pytest.fixture @@ -319,12 +301,7 @@ def iron_sfe_110_curves() -> dict[str, pd.DataFrame]: dict[str, pd.DataFrame] Dictionary mapping model names to their SFE 110 curve DataFrames. """ - curves: dict[str, pd.DataFrame] = {} - for model_name in MODELS: - curve = load_sfe_110_curve(model_name) - if not curve.empty: - curves[model_name] = curve - return curves + return _load_curves_for_all_models("sfe_110") @pytest.fixture @@ -337,12 +314,33 @@ def iron_sfe_112_curves() -> dict[str, pd.DataFrame]: dict[str, pd.DataFrame] Dictionary mapping model names to their SFE 112 curve DataFrames. """ - curves: dict[str, pd.DataFrame] = {} - for model_name in MODELS: - curve = load_sfe_112_curve(model_name) - if not curve.empty: - curves[model_name] = curve - return curves + return _load_curves_for_all_models("sfe_112") + + +@pytest.fixture +def iron_ts_100_curves() -> dict[str, pd.DataFrame]: + """ + Load T-S (100) curves for all models. + + Returns + ------- + dict[str, pd.DataFrame] + Dictionary mapping model names to their T-S (100) curve DataFrames. + """ + return _load_curves_for_all_models("ts_100") + + +@pytest.fixture +def iron_ts_110_curves() -> dict[str, pd.DataFrame]: + """ + Load T-S (110) curves for all models. + + Returns + ------- + dict[str, pd.DataFrame] + Dictionary mapping model names to their T-S (110) curve DataFrames. + """ + return _load_curves_for_all_models("ts_110") def collect_metrics() -> pd.DataFrame: diff --git a/ml_peg/analysis/physicality/iron_properties/metrics.yml b/ml_peg/analysis/physicality/iron_properties/metrics.yml index dc874db5a..e70de8c75 100644 --- a/ml_peg/analysis/physicality/iron_properties/metrics.yml +++ b/ml_peg/analysis/physicality/iron_properties/metrics.yml @@ -61,3 +61,16 @@ metrics: unit: "%" tooltip: "Error in maximum stacking fault energy for {112}<111> slip system" level_of_theory: PBE + # Traction-separation properties + Max traction (100) (GPa): + good: 35.0 + bad: 20.0 + unit: "GPa" + tooltip: "Maximum traction stress for (100) cleavage plane" + level_of_theory: DFT + Max traction (110) (GPa): + good: 30.0 + bad: 18.0 + unit: "GPa" + tooltip: "Maximum traction stress for (110) cleavage plane" + level_of_theory: DFT diff --git a/ml_peg/app/physicality/iron_properties/app_iron_properties.py b/ml_peg/app/physicality/iron_properties/app_iron_properties.py index 181391367..a30b5f9cc 100644 --- a/ml_peg/app/physicality/iron_properties/app_iron_properties.py +++ b/ml_peg/app/physicality/iron_properties/app_iron_properties.py @@ -23,6 +23,59 @@ DOCS_URL = "https://ddmms.github.io/ml-peg/user_guide/benchmarks/physicality.html#iron-properties" +# Curve configuration: file name, x column, y column, title, x label, y label +CURVE_CONFIG = { + "eos": { + "file": "eos_curve.csv", + "x": "volume", + "y": "energy", + "title": "Equation of State", + "x_label": "Volume (ų/atom)", + "y_label": "Energy (eV/atom)", + }, + "bain": { + "file": "bain_path.csv", + "x": "ca_ratio", + "y": "energy_meV", + "title": "Bain Path", + "x_label": "c/a ratio", + "y_label": "Energy (meV/atom)", + }, + "sfe_110": { + "file": "sfe_110_curve.csv", + "x": "displacement", + "y": "sfe_J_per_m2", + "title": "Stacking Fault Energy {110}<111>", + "x_label": "Displacement (Å)", + "y_label": "SFE (J/m²)", + }, + "sfe_112": { + "file": "sfe_112_curve.csv", + "x": "displacement", + "y": "sfe_J_per_m2", + "title": "Stacking Fault Energy {112}<111>", + "x_label": "Displacement (Å)", + "y_label": "SFE (J/m²)", + }, + "ts_100": { + "file": "ts_100_curve.csv", + "x": "separation", + "y": "traction", + "title": "Traction-Separation Curve (100)", + "x_label": "Separation (Å)", + "y_label": "Traction (GPa)", + }, + "ts_110": { + "file": "ts_110_curve.csv", + "x": "separation", + "y": "traction", + "title": "Traction-Separation Curve (110)", + "x_label": "Separation (Å)", + "y_label": "Traction (GPa)", + }, +} + + def _load_curve_data(model_name: str, curve_type: str) -> pd.DataFrame | None: """ Load curve data for a model. @@ -43,18 +96,11 @@ def _load_curve_data(model_name: str, curve_type: str) -> pd.DataFrame | None: if not model_dir.exists(): return None - file_map = { - "eos": "eos_curve.csv", - "bain": "bain_path.csv", - "sfe_110": "sfe_110_curve.csv", - "sfe_112": "sfe_112_curve.csv", - } - - filename = file_map.get(curve_type) - if not filename: + config = CURVE_CONFIG.get(curve_type) + if not config: return None - csv_path = model_dir / filename + csv_path = model_dir / config["file"] if not csv_path.exists(): return None @@ -79,82 +125,32 @@ def _create_figure(df: pd.DataFrame, curve_type: str, model_name: str) -> go.Fig go.Figure Plotly figure object. """ + config = CURVE_CONFIG.get(curve_type, {}) + fig = go.Figure() - if curve_type == "eos": - fig.add_trace( - go.Scatter( - x=df["volume"], - y=df["energy"], - mode="lines+markers", - name=model_name, - line={"width": 2}, - marker={"size": 6}, - ) - ) - fig.update_layout( - title=f"Equation of State - {model_name}", - xaxis_title="Volume (ų/atom)", - yaxis_title="Energy (eV/atom)", + fig.add_trace( + go.Scatter( + x=df[config["x"]], + y=df[config["y"]], + mode="lines+markers", + name=model_name, + line={"width": 2}, + marker={"size": 6}, ) + ) - elif curve_type == "bain": - fig.add_trace( - go.Scatter( - x=df["ca_ratio"], - y=df["energy_meV"], - mode="lines+markers", - name=model_name, - line={"width": 2}, - marker={"size": 6}, - ) - ) - # Add vertical lines for BCC and FCC + # Special handling for Bain path (add BCC/FCC reference lines) + if curve_type == "bain": fig.add_vline(x=1.0, line_dash="dash", line_color="gray", annotation_text="BCC") fig.add_vline( x=1.414, line_dash="dash", line_color="gray", annotation_text="FCC" ) - fig.update_layout( - title=f"Bain Path - {model_name}", - xaxis_title="c/a ratio", - yaxis_title="Energy (meV/atom)", - ) - - elif curve_type == "sfe_110": - fig.add_trace( - go.Scatter( - x=df["displacement"], - y=df["sfe_J_per_m2"], - mode="lines+markers", - name=model_name, - line={"width": 2}, - marker={"size": 6}, - ) - ) - fig.update_layout( - title=f"Stacking Fault Energy {{110}}<111> - {model_name}", - xaxis_title="Displacement (Å)", - yaxis_title="SFE (J/m²)", - ) - - elif curve_type == "sfe_112": - fig.add_trace( - go.Scatter( - x=df["displacement"], - y=df["sfe_J_per_m2"], - mode="lines+markers", - name=model_name, - line={"width": 2}, - marker={"size": 6}, - ) - ) - fig.update_layout( - title=f"Stacking Fault Energy {{112}}<111> - {model_name}", - xaxis_title="Displacement (Å)", - yaxis_title="SFE (J/m²)", - ) fig.update_layout( + title=f"{config['title']} - {model_name}", + xaxis_title=config["x_label"], + yaxis_title=config["y_label"], template="plotly_white", showlegend=True, height=500, @@ -248,6 +244,8 @@ def get_app() -> IronPropertiesApp: {"label": "Bain Path", "value": "bain"}, {"label": "SFE {110}<111>", "value": "sfe_110"}, {"label": "SFE {112}<111>", "value": "sfe_112"}, + {"label": "T-S Curve (100)", "value": "ts_100"}, + {"label": "T-S Curve (110)", "value": "ts_110"}, ], value="eos", clearable=False, @@ -272,8 +270,9 @@ def get_app() -> IronPropertiesApp: "Includes equation of state (lattice parameter, bulk modulus), " "elastic constants (C11, C12, C44), Bain path (BCC-FCC transformation), " "vacancy formation energy, surface energies (100, 110, 111, 112), " - "and generalized stacking fault energy curves for {110}<111> and " - "{112}<111> slip systems. " + "generalized stacking fault energy curves for {110}<111> and " + "{112}<111> slip systems, and traction-separation curves for (100) " + "and (110) cleavage planes. " "This benchmark is computationally expensive and marked with " "@pytest.mark.slow." ), diff --git a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py index 8e155d091..1b2631086 100644 --- a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py +++ b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py @@ -8,6 +8,7 @@ - Vacancy formation energy - Surface energies (100, 110, 111, 112) - Generalized stacking fault energy curves (110, 112) +- Traction-separation curves (100, 110) This benchmark is computationally expensive and marked with @pytest.mark.slow. """ @@ -39,6 +40,8 @@ create_surface_110, create_surface_111, create_surface_112, + create_ts_100_structure, + create_ts_110_structure, fit_eos, get_voigt_strain, ) @@ -80,6 +83,10 @@ SFE_STEP_SIZE = 0.04 # Angstroms SFE_FMAX = 1e-5 +# Traction-separation parameters +TS_MAX_SEPARATION = 5.0 # Angstroms +TS_STEP_SIZE = 0.05 # Angstroms + # ============================================================================= # EOS Calculation @@ -345,6 +352,34 @@ def run_vacancy_calculation(calc: Any, lattice_parameter: float) -> dict[str, An # Surface Calculations # ============================================================================= +# Surface configuration: create_fn, layers/size, area_axes, vacuum +SURFACE_CONFIG = { + "100": { + "create_fn": create_surface_100, + "layers": 10, + "area_axes": (0, 1), + "vacuum": SURFACE_VACUUM, + }, + "110": { + "create_fn": create_surface_110, + "layers": 10, + "area_axes": (0, 1), + "vacuum": SURFACE_VACUUM, + }, + "111": { + "create_fn": create_surface_111, + "size": (3, 15, 3), + "area_axes": (0, 2), + "vacuum": SURFACE_VACUUM, + }, + "112": { + "create_fn": create_surface_112, + "layers": 15, + "area_axes": (0, 1), + "vacuum": 5.0, + }, +} + def run_surface_calculations(calc: Any, lattice_parameter: float) -> dict[str, Any]: """ @@ -364,80 +399,54 @@ def run_surface_calculations(calc: Any, lattice_parameter: float) -> dict[str, A """ surfaces = {} - # (100) surface - atoms_bulk = create_surface_100(lattice_parameter, layers=10, vacuum=0.0) - atoms_bulk.calc = calc - E_bulk = atoms_bulk.get_potential_energy() # noqa: N806 - cell = atoms_bulk.get_cell() - area = np.linalg.norm(np.cross(cell[0], cell[1])) - - atoms_slab = create_surface_100(lattice_parameter, layers=10, vacuum=SURFACE_VACUUM) - atoms_slab.calc = calc - opt = BFGS(atoms_slab, logfile=None) - opt.run(fmax=SURFACE_FMAX, steps=10000) - E_slab = atoms_slab.get_potential_energy() # noqa: N806 - surfaces["100"] = calculate_surface_energy(E_slab, E_bulk, area) - - # (110) surface - atoms_bulk = create_surface_110(lattice_parameter, layers=10, vacuum=0.0) - atoms_bulk.calc = calc - E_bulk = atoms_bulk.get_potential_energy() # noqa: N806 - cell = atoms_bulk.get_cell() - area = np.linalg.norm(np.cross(cell[0], cell[1])) - - atoms_slab = create_surface_110(lattice_parameter, layers=10, vacuum=SURFACE_VACUUM) - atoms_slab.calc = calc - opt = BFGS(atoms_slab, logfile=None) - opt.run(fmax=SURFACE_FMAX, steps=10000) - E_slab = atoms_slab.get_potential_energy() # noqa: N806 - surfaces["110"] = calculate_surface_energy(E_slab, E_bulk, area) - - # (111) surface - atoms_bulk = create_surface_111(lattice_parameter, size=(3, 15, 3), vacuum=0.0) - atoms_bulk.calc = calc - E_bulk = atoms_bulk.get_potential_energy() # noqa: N806 - cell = atoms_bulk.get_cell() - area = np.linalg.norm(np.cross(cell[0], cell[2])) - - atoms_slab = create_surface_111( - lattice_parameter, size=(3, 15, 3), vacuum=SURFACE_VACUUM - ) - atoms_slab.calc = calc - opt = BFGS(atoms_slab, logfile=None) - opt.run(fmax=SURFACE_FMAX, steps=10000) - E_slab = atoms_slab.get_potential_energy() # noqa: N806 - surfaces["111"] = calculate_surface_energy(E_slab, E_bulk, area) - - # (112) surface - atoms_bulk = create_surface_112(lattice_parameter, layers=15, vacuum=0.0) - atoms_bulk.calc = calc - E_bulk = atoms_bulk.get_potential_energy() # noqa: N806 - cell = atoms_bulk.get_cell() - area = np.linalg.norm(np.cross(cell[0], cell[1])) - - atoms_slab = create_surface_112(lattice_parameter, layers=15, vacuum=5.0) - atoms_slab.calc = calc - opt = BFGS(atoms_slab, logfile=None) - opt.run(fmax=SURFACE_FMAX, steps=10000) - E_slab = atoms_slab.get_potential_energy() # noqa: N806 - surfaces["112"] = calculate_surface_energy(E_slab, E_bulk, area) + for name, cfg in SURFACE_CONFIG.items(): + create_fn = cfg["create_fn"] + area_axes = cfg["area_axes"] + vacuum = cfg["vacuum"] - return { - "gamma_100": surfaces["100"], - "gamma_110": surfaces["110"], - "gamma_111": surfaces["111"], - "gamma_112": surfaces["112"], - } + # Build kwargs for structure creation + if "size" in cfg: + bulk_kwargs = {"size": cfg["size"], "vacuum": 0.0} + slab_kwargs = {"size": cfg["size"], "vacuum": vacuum} + else: + bulk_kwargs = {"layers": cfg["layers"], "vacuum": 0.0} + slab_kwargs = {"layers": cfg["layers"], "vacuum": vacuum} + + # Bulk reference + atoms_bulk = create_fn(lattice_parameter, **bulk_kwargs) + atoms_bulk.calc = calc + e_bulk = atoms_bulk.get_potential_energy() + cell = atoms_bulk.get_cell() + area = np.linalg.norm(np.cross(cell[area_axes[0]], cell[area_axes[1]])) + + # Slab with vacuum + atoms_slab = create_fn(lattice_parameter, **slab_kwargs) + atoms_slab.calc = calc + opt = BFGS(atoms_slab, logfile=None) + opt.run(fmax=SURFACE_FMAX, steps=10000) + e_slab = atoms_slab.get_potential_energy() + + surfaces[name] = calculate_surface_energy(e_slab, e_bulk, area) + + return {f"gamma_{k}": v for k, v in surfaces.items()} # ============================================================================= # Stacking Fault Energy Calculations # ============================================================================= +# SFE configuration: create_fn, number of steps, displacement axis +SFE_CONFIG = { + "110": {"create_fn": create_sfe_110_structure, "steps": SFE_110_STEPS, "axis": 1}, + "112": {"create_fn": create_sfe_112_structure, "steps": SFE_112_STEPS, "axis": 2}, +} -def run_sfe_110_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: + +def run_sfe_calculation( + calc: Any, lattice_parameter: float, sfe_type: str +) -> dict[str, Any]: """ - Calculate GSFE curve for {110}<111> slip system. + Calculate GSFE curve for specified slip system. Parameters ---------- @@ -445,13 +454,16 @@ def run_sfe_110_calculation(calc: Any, lattice_parameter: float) -> dict[str, An ASE calculator object. lattice_parameter : float Equilibrium lattice parameter from EOS fit. + sfe_type : str + Type of SFE calculation ('110' or '112'). Returns ------- dict Dictionary with displacements, sfe_J_per_m2, and max_sfe. """ - atoms = create_sfe_110_structure(lattice_parameter) + config = SFE_CONFIG[sfe_type] + atoms = config["create_fn"](lattice_parameter) atoms.calc = calc cell = atoms.get_cell() @@ -461,7 +473,7 @@ def run_sfe_110_calculation(calc: Any, lattice_parameter: float) -> dict[str, An opt = BFGS(atoms, logfile=None) opt.run(fmax=SFE_FMAX, steps=10000) - E0 = atoms.get_potential_energy() # noqa: N806 + e0 = atoms.get_potential_energy() positions = atoms.get_positions() x_mid = (positions[:, 0].min() + positions[:, 0].max()) / 2 + 0.1 @@ -472,9 +484,11 @@ def run_sfe_110_calculation(calc: Any, lattice_parameter: float) -> dict[str, An sfe_j_per_m2 = [0.0] constraints = [FixedLine(idx, direction=[1, 0, 0]) for idx in range(len(atoms))] - for step in range(1, SFE_110_STEPS + 1): + displacement_axis = config["axis"] + + for step in range(1, config["steps"] + 1): positions = atoms.get_positions() - positions[upper_indices, 1] += SFE_STEP_SIZE + positions[upper_indices, displacement_axis] += SFE_STEP_SIZE atoms.set_positions(positions) atoms.set_constraint(constraints) @@ -486,8 +500,8 @@ def run_sfe_110_calculation(calc: Any, lattice_parameter: float) -> dict[str, An pass atoms.set_constraint() - E = atoms.get_potential_energy() # noqa: N806 - sfe = (E - E0) / (2 * area) * EV_PER_A2_TO_J_PER_M2 + energy = atoms.get_potential_energy() + sfe = (energy - e0) / (2 * area) * EV_PER_A2_TO_J_PER_M2 displacements.append(step * SFE_STEP_SIZE) sfe_j_per_m2.append(sfe) @@ -499,9 +513,25 @@ def run_sfe_110_calculation(calc: Any, lattice_parameter: float) -> dict[str, An } -def run_sfe_112_calculation(calc: Any, lattice_parameter: float) -> dict[str, Any]: +# ============================================================================= +# Traction-Separation Calculations +# ============================================================================= + +# T-S configuration: structure creation function +TS_CONFIG = { + "100": create_ts_100_structure, + "110": create_ts_110_structure, +} + + +def run_ts_calculation( + calc: Any, lattice_parameter: float, direction: str +) -> dict[str, Any]: """ - Calculate GSFE curve for {112}<111> slip system. + Calculate traction-separation curve for specified cleavage plane. + + The calculation incrementally separates crystal halves without relaxation + and measures energy and traction (stress from forces). Parameters ---------- @@ -509,60 +539,99 @@ def run_sfe_112_calculation(calc: Any, lattice_parameter: float) -> dict[str, An ASE calculator object. lattice_parameter : float Equilibrium lattice parameter from EOS fit. + direction : str + Cleavage plane direction ('100' or '110'). Returns ------- dict - Dictionary with displacements, sfe_J_per_m2, and max_sfe. + Dictionary with separations, energies, traction, and max_traction. """ - atoms = create_sfe_112_structure(lattice_parameter) - atoms.calc = calc + create_fn = TS_CONFIG[direction] + num_steps = int(TS_MAX_SEPARATION / TS_STEP_SIZE) + 1 - cell = atoms.get_cell() - ly = cell[1, 1] - lz = cell[2, 2] - area = ly * lz + separations = [] + energies = [] + traction = [] - opt = BFGS(atoms, logfile=None) - opt.run(fmax=SFE_FMAX, steps=10000) - E0 = atoms.get_potential_energy() # noqa: N806 + for i in range(num_steps): + dd = TS_STEP_SIZE * i - positions = atoms.get_positions() - x_mid = (positions[:, 0].min() + positions[:, 0].max()) / 2 + 0.1 - upper_mask = positions[:, 0] < x_mid - upper_indices = np.where(upper_mask)[0] + # Create fresh structure for each separation + atoms = create_fn(lattice_parameter) + atoms.calc = calc - displacements = [0.0] - sfe_j_per_m2 = [0.0] + # Get cell dimensions + cell = atoms.get_cell() + lx = cell[0, 0] + ly = cell[1, 1] + lz = cell[2, 2] + area = lx * ly - constraints = [FixedLine(idx, direction=[1, 0, 0]) for idx in range(len(atoms))] - for step in range(1, SFE_112_STEPS + 1): + # Identify upper and lower atoms positions = atoms.get_positions() - positions[upper_indices, 2] += SFE_STEP_SIZE + z_mid = lz / 2 - 0.1 + upper_mask = positions[:, 2] > z_mid + upper_indices = np.where(upper_mask)[0] + + # Expand cell in z direction + new_cell = cell.copy() + new_cell[2, 2] = lz + dd + atoms.set_cell(new_cell, scale_atoms=False) + + # Move upper atoms + positions = atoms.get_positions() + positions[upper_indices, 2] += dd atoms.set_positions(positions) - atoms.set_constraint(constraints) + # Calculate energy (no relaxation!) + energy = atoms.get_potential_energy() - opt = BFGS(atoms, logfile=None) - try: - opt.run(fmax=SFE_FMAX, steps=10000) - except Exception: - pass + # Calculate forces for stress + forces = atoms.get_forces() - atoms.set_constraint() - E = atoms.get_potential_energy() # noqa: N806 - sfe = (E - E0) / (2 * area) * EV_PER_A2_TO_J_PER_M2 + # Sum of z-forces on upper region + fz_upper = np.sum(forces[upper_indices, 2]) - displacements.append(step * SFE_STEP_SIZE) - sfe_j_per_m2.append(sfe) + # Convert to stress (GPa): σ = F / A + sig_upper = EV_PER_A3_TO_GPA * fz_upper / area + + separations.append(dd) + energies.append(energy) + traction.append(sig_upper) + + # Max traction from force-based calculation + max_traction = np.max(np.abs(traction)) return { - "displacements": displacements, - "sfe_J_per_m2": sfe_j_per_m2, - "max_sfe": max(sfe_j_per_m2), + "separations": separations, + "energies": energies, + "traction": traction, + "max_traction": max_traction, } +# ============================================================================= +# Helper Functions +# ============================================================================= + + +def _save_curve(write_dir: Path, name: str, data: dict[str, list]) -> None: + """ + Save curve data to CSV file. + + Parameters + ---------- + write_dir : Path + Directory to save the file. + name : str + Base name for the CSV file (without extension). + data : dict[str, list] + Column name to data mapping for the DataFrame. + """ + pd.DataFrame(data).to_csv(write_dir / f"{name}.csv", index=False) + + # ============================================================================= # Main Benchmark Function # ============================================================================= @@ -579,6 +648,7 @@ def run_iron_properties(model_name: str, model: Any) -> None: - Vacancy formation energy - Surface energies (100, 110, 111, 112) - Stacking fault energy curves (110, 112) + - Traction-separation curves (100, 110) Parameters ---------- @@ -604,13 +674,11 @@ def run_iron_properties(model_name: str, model: Any) -> None: ) # Save EOS curve data - eos_df = pd.DataFrame( - { - "volume": eos_results["volumes"], - "energy": eos_results["energies"], - } + _save_curve( + write_dir, + "eos_curve", + {"volume": eos_results["volumes"], "energy": eos_results["energies"]}, ) - eos_df.to_csv(write_dir / "eos_curve.csv", index=False) # Elastic constants calculation print(f"[{model_name}] Running elastic constants calculation...") @@ -627,14 +695,15 @@ def run_iron_properties(model_name: str, model: Any) -> None: results["bain_path"] = bain_results # Save Bain path data - bain_df = pd.DataFrame( + _save_curve( + write_dir, + "bain_path", { "ca_ratio": bain_results["ca_ratios"], "energy": bain_results["energies"], "energy_meV": bain_results["energies_meV"], - } + }, ) - bain_df.to_csv(write_dir / "bain_path.csv", index=False) # Vacancy calculation print(f"[{model_name}] Running vacancy calculation...") @@ -647,29 +716,42 @@ def run_iron_properties(model_name: str, model: Any) -> None: surface_results = run_surface_calculations(calc, a0) results["surfaces"] = surface_results - # SFE 110 calculation - print(f"[{model_name}] Running SFE 110 calculation...") - sfe_110_results = run_sfe_110_calculation(calc, a0) - results["sfe_110"] = {"max_sfe": sfe_110_results["max_sfe"]} - sfe_110_df = pd.DataFrame( - { - "displacement": sfe_110_results["displacements"], - "sfe_J_per_m2": sfe_110_results["sfe_J_per_m2"], - } - ) - sfe_110_df.to_csv(write_dir / "sfe_110_curve.csv", index=False) - - # SFE 112 calculation - print(f"[{model_name}] Running SFE 112 calculation...") - sfe_112_results = run_sfe_112_calculation(calc, a0) - results["sfe_112"] = {"max_sfe": sfe_112_results["max_sfe"]} - sfe_112_df = pd.DataFrame( - { - "displacement": sfe_112_results["displacements"], - "sfe_J_per_m2": sfe_112_results["sfe_J_per_m2"], - } - ) - sfe_112_df.to_csv(write_dir / "sfe_112_curve.csv", index=False) + # SFE calculations + sfe_results = {} + for sfe_type in ["110", "112"]: + print(f"[{model_name}] Running SFE {sfe_type} calculation...") + sfe_result = run_sfe_calculation(calc, a0, sfe_type) + sfe_results[sfe_type] = sfe_result + results[f"sfe_{sfe_type}"] = {"max_sfe": sfe_result["max_sfe"]} + _save_curve( + write_dir, + f"sfe_{sfe_type}_curve", + { + "displacement": sfe_result["displacements"], + "sfe_J_per_m2": sfe_result["sfe_J_per_m2"], + }, + ) + + # T-S calculations + ts_results = {} + for direction in ["100", "110"]: + print(f"[{model_name}] Running T-S ({direction}) calculation...") + ts_result = run_ts_calculation(calc, a0, direction) + ts_results[direction] = ts_result + results[f"ts_{direction}"] = {"max_traction": ts_result["max_traction"]} + _save_curve( + write_dir, + f"ts_{direction}_curve", + { + "separation": ts_result["separations"], + "energy": ts_result["energies"], + "traction": ts_result["traction"], + }, + ) + print( + f"[{model_name}] Max traction ({direction}): " + f"{ts_result['max_traction']:.2f} GPa" + ) # Save all results as JSON (write_dir / "results.json").write_text(json.dumps(results, indent=2, default=str)) @@ -687,8 +769,10 @@ def run_iron_properties(model_name: str, model: Any) -> None: "gamma_110": surface_results["gamma_110"], "gamma_111": surface_results["gamma_111"], "gamma_112": surface_results["gamma_112"], - "max_sfe_110": sfe_110_results["max_sfe"], - "max_sfe_112": sfe_112_results["max_sfe"], + "max_sfe_110": sfe_results["110"]["max_sfe"], + "max_sfe_112": sfe_results["112"]["max_sfe"], + "max_traction_100": ts_results["100"]["max_traction"], + "max_traction_110": ts_results["110"]["max_traction"], } (write_dir / "summary.json").write_text(json.dumps(summary, indent=2)) diff --git a/ml_peg/calcs/utils/iron_utils.py b/ml_peg/calcs/utils/iron_utils.py index 554ef9b39..3a58321a4 100644 --- a/ml_peg/calcs/utils/iron_utils.py +++ b/ml_peg/calcs/utils/iron_utils.py @@ -142,6 +142,83 @@ def residual(params, y, x): # ============================================================================= +def _create_oriented_bcc_structure( + lattice_parameter: float, + rotation: np.ndarray, + cell_dims: tuple[float, float, float], + max_range: int, + symbol: str = "Fe", + wrap: bool = True, +) -> Atoms: + """ + Create BCC structure with given orientation using rotation matrix. + + This is a generic function used by several oriented structure creators. + + Parameters + ---------- + lattice_parameter : float + BCC lattice parameter in Angstroms. + rotation : np.ndarray + 3x3 rotation matrix (rows are the new basis vectors). + cell_dims : tuple[float, float, float] + Cell dimensions (lx, ly, lz) in Angstroms. + max_range : int + Range for scanning cubic positions. + symbol : str, optional + Chemical symbol (default: 'Fe'). + wrap : bool, optional + Whether to wrap positions into cell (default: True). + + Returns + ------- + Atoms + ASE Atoms object with oriented structure. + """ + a = lattice_parameter + lx, ly, lz = cell_dims + cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) + + positions = [] + eps = 1e-8 + + for i in range(-max_range, max_range + 1): + for j in range(-max_range, max_range + 1): + for k in range(-max_range, max_range + 1): + for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: + pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) + pos_oriented = rotation @ pos_cubic + + frac_x = pos_oriented[0] / lx + frac_y = pos_oriented[1] / ly + frac_z = pos_oriented[2] / lz + + if ( + 0 - eps <= frac_x < 1 - eps + and 0 - eps <= frac_y < 1 - eps + and 0 - eps <= frac_z < 1 - eps + ): + positions.append(pos_oriented) + + if len(positions) == 0: + raise ValueError("No atoms found for oriented structure") + + positions = np.array(positions) + _, unique_idx = np.unique( + np.round(positions, decimals=6), axis=0, return_index=True + ) + positions = positions[unique_idx] + + atoms = Atoms( + symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True + ) + + if wrap: + atoms.wrap() + + return atoms + + def create_bcc_supercell( lattice_parameter: float, size: tuple = (4, 4, 4), symbol: str = "Fe" ) -> Atoms: @@ -302,51 +379,27 @@ def create_surface_111( ASE Atoms object with the (111) surface slab. """ a = lattice_parameter - lx = a * np.sqrt(2) * size[0] - ly = a * np.sqrt(3) * size[1] - lz = a * np.sqrt(6) * size[2] - - cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) - positions = [] - max_range = int(max(size) * 3 + 5) + # Rotation matrix for (111) orientation ex = np.array([-1, 1, 0]) / np.sqrt(2) ey = np.array([1, 1, 1]) / np.sqrt(3) ez = np.array([1, 1, -2]) / np.sqrt(6) - R = np.array([ex, ey, ez]) # noqa: N806 + rotation = np.array([ex, ey, ez]) - for i in range(-max_range, max_range + 1): - for j in range(-max_range, max_range + 1): - for k in range(-max_range, max_range + 1): - for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: - pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = R @ pos_cubic - frac_x = pos_oriented[0] / lx - frac_y = pos_oriented[1] / ly - frac_z = pos_oriented[2] / lz - eps = 1e-8 - if ( - 0 - eps <= frac_x < 1 - eps - and 0 - eps <= frac_y < 1 - eps - and 0 - eps <= frac_z < 1 - eps - ): - positions.append(pos_oriented) - - if len(positions) == 0: - raise ValueError("No atoms found for (111) surface") - - positions = np.array(positions) - _, unique_idx = np.unique( - np.round(positions, decimals=6), axis=0, return_index=True + cell_dims = ( + a * np.sqrt(2) * size[0], + a * np.sqrt(3) * size[1], + a * np.sqrt(6) * size[2], ) - positions = positions[unique_idx] + max_range = int(max(size) * 3 + 5) - atoms = Atoms( - symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True + atoms = _create_oriented_bcc_structure( + lattice_parameter, rotation, cell_dims, max_range, symbol ) - atoms.wrap() + if vacuum > 0: atoms.center(vacuum=vacuum, axis=1) + return atoms @@ -373,51 +426,23 @@ def create_surface_112( ASE Atoms object with the (112) surface slab. """ a = lattice_parameter - lx = a * np.sqrt(2) - ly = a * np.sqrt(3) - lz = a * np.sqrt(6) * layers - - cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) - positions = [] - max_range = int(layers * 3 + 5) + # Rotation matrix for (112) orientation ex = np.array([-1, 1, 0]) / np.sqrt(2) ey = np.array([1, 1, 1]) / np.sqrt(3) ez = np.array([1, 1, -2]) / np.sqrt(6) - R = np.array([ex, ey, ez]) # noqa: N806 + rotation = np.array([ex, ey, ez]) - for i in range(-max_range, max_range + 1): - for j in range(-max_range, max_range + 1): - for k in range(-max_range, max_range + 1): - for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: - pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = R @ pos_cubic - frac_x = pos_oriented[0] / lx - frac_y = pos_oriented[1] / ly - frac_z = pos_oriented[2] / lz - eps = 1e-8 - if ( - 0 - eps <= frac_x < 1 - eps - and 0 - eps <= frac_y < 1 - eps - and 0 - eps <= frac_z < 1 - eps - ): - positions.append(pos_oriented) - - if len(positions) == 0: - raise ValueError("No atoms found for (112) surface") + cell_dims = (a * np.sqrt(2), a * np.sqrt(3), a * np.sqrt(6) * layers) + max_range = int(layers * 3 + 5) - positions = np.array(positions) - _, unique_idx = np.unique( - np.round(positions, decimals=6), axis=0, return_index=True + atoms = _create_oriented_bcc_structure( + lattice_parameter, rotation, cell_dims, max_range, symbol ) - positions = positions[unique_idx] - atoms = Atoms( - symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True - ) - atoms.wrap() if vacuum > 0: atoms.center(vacuum=vacuum, axis=2) + return atoms @@ -438,47 +463,22 @@ def create_sfe_110_structure(lattice_parameter: float) -> Atoms: a = lattice_parameter size = (20, 1, 3) + # Rotation matrix for {110} orientation ex = np.array([-1, 1, 0]) / np.sqrt(2) ey = np.array([1, 1, 1]) / np.sqrt(3) ez = np.array([1, 1, -2]) / np.sqrt(6) - R = np.array([ex, ey, ez]) # noqa: N806 - - lx = a * np.sqrt(2) * size[0] - ly = a * np.sqrt(3) * size[1] - lz = a * np.sqrt(6) * size[2] - - cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) - positions = [] - max_range = int(max(size) * 3 + 5) + rotation = np.array([ex, ey, ez]) - for i in range(-max_range, max_range + 1): - for j in range(-max_range, max_range + 1): - for k in range(-max_range, max_range + 1): - for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: - pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = R @ pos_cubic - frac_x = pos_oriented[0] / lx - frac_y = pos_oriented[1] / ly - frac_z = pos_oriented[2] / lz - eps = 1e-8 - if ( - 0 - eps <= frac_x < 1 - eps - and 0 - eps <= frac_y < 1 - eps - and 0 - eps <= frac_z < 1 - eps - ): - positions.append(pos_oriented) - - positions = np.array(positions) - _, unique_idx = np.unique( - np.round(positions, decimals=6), axis=0, return_index=True + cell_dims = ( + a * np.sqrt(2) * size[0], + a * np.sqrt(3) * size[1], + a * np.sqrt(6) * size[2], ) - positions = positions[unique_idx] + max_range = int(max(size) * 3 + 5) - atoms = Atoms( - symbols=["Fe"] * len(positions), positions=positions, cell=cell, pbc=True + return _create_oriented_bcc_structure( + lattice_parameter, rotation, cell_dims, max_range ) - atoms.wrap() - return atoms def create_sfe_112_structure(lattice_parameter: float) -> Atoms: @@ -498,47 +498,112 @@ def create_sfe_112_structure(lattice_parameter: float) -> Atoms: a = lattice_parameter size = (15, 1, 1) + # Rotation matrix for {112} orientation ex = np.array([1, 1, -2]) / np.sqrt(6) ey = np.array([-1, 1, 0]) / np.sqrt(2) ez = np.array([1, 1, 1]) / np.sqrt(3) - R = np.array([ex, ey, ez]) # noqa: N806 + rotation = np.array([ex, ey, ez]) + + cell_dims = ( + a * np.sqrt(6) * size[0], + a * np.sqrt(2) * size[1], + a * np.sqrt(3) * size[2], + ) + max_range = int(max(size) * 3 + 5) + + return _create_oriented_bcc_structure( + lattice_parameter, rotation, cell_dims, max_range + ) + + +# ============================================================================= +# Traction-Separation Structure Functions +# ============================================================================= - lx = a * np.sqrt(6) * size[0] - ly = a * np.sqrt(2) * size[1] - lz = a * np.sqrt(3) * size[2] + +def create_ts_100_structure( + lattice_parameter: float, layers: int = 36, symbol: str = "Fe" +) -> Atoms: + """ + Create structure for (100) traction-separation calculation. + + Orientation: x=[100], y=[010], z=[001] + Slab: 1x1xlayers lattice units + Cleavage plane: perpendicular to z + + Parameters + ---------- + lattice_parameter : float + BCC lattice parameter in Angstroms. + layers : int, optional + Number of layers in z direction (default: 36). + symbol : str, optional + Chemical symbol (default: 'Fe'). + + Returns + ------- + Atoms + ASE Atoms object for T-S calculation. + """ + a = lattice_parameter + + # Cell dimensions + lx = a + ly = a + lz = a * layers cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) - positions = [] - max_range = int(max(size) * 3 + 5) - for i in range(-max_range, max_range + 1): - for j in range(-max_range, max_range + 1): - for k in range(-max_range, max_range + 1): - for basis in [(0, 0, 0), (0.5, 0.5, 0.5)]: - pos_cubic = a * np.array([i + basis[0], j + basis[1], k + basis[2]]) - pos_oriented = R @ pos_cubic - frac_x = pos_oriented[0] / lx - frac_y = pos_oriented[1] / ly - frac_z = pos_oriented[2] / lz - eps = 1e-8 - if ( - 0 - eps <= frac_x < 1 - eps - and 0 - eps <= frac_y < 1 - eps - and 0 - eps <= frac_z < 1 - eps - ): - positions.append(pos_oriented) + # Generate BCC atoms + positions = [] + for k in range(layers): + # Two atoms per unit cell in z + positions.append([0, 0, k * a]) + positions.append([0.5 * a, 0.5 * a, (k + 0.5) * a]) - positions = np.array(positions) - _, unique_idx = np.unique( - np.round(positions, decimals=6), axis=0, return_index=True + return Atoms( + symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True ) - positions = positions[unique_idx] - atoms = Atoms( - symbols=["Fe"] * len(positions), positions=positions, cell=cell, pbc=True + +def create_ts_110_structure( + lattice_parameter: float, layers: int = 10, symbol: str = "Fe" +) -> Atoms: + """ + Create structure for (110) traction-separation calculation. + + Orientation: x=[100], y=[01-1], z=[011] + Slab: 1x1xlayers lattice units + Cleavage plane: perpendicular to z (which is [011]) + + Parameters + ---------- + lattice_parameter : float + BCC lattice parameter in Angstroms. + layers : int, optional + Number of layers in z direction (default: 10). + symbol : str, optional + Chemical symbol (default: 'Fe'). + + Returns + ------- + Atoms + ASE Atoms object for T-S calculation. + """ + a = lattice_parameter + + # Rotation matrix for T-S 110 orientation + ex = np.array([1, 0, 0]) + ey = np.array([0, 1, -1]) / np.sqrt(2) + ez = np.array([0, 1, 1]) / np.sqrt(2) + rotation = np.array([ex, ey, ez]) + + cell_dims = (a, a * np.sqrt(2), a * np.sqrt(2) * layers) + max_range = int(layers * 2 + 5) + + return _create_oriented_bcc_structure( + lattice_parameter, rotation, cell_dims, max_range, symbol ) - atoms.wrap() - return atoms # ============================================================================= From 988e06493efd424802ea717239501f01b9670b31 Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Wed, 4 Feb 2026 14:29:13 +0000 Subject: [PATCH 06/12] adjustments to match LAMMPS implementation more closely --- .../iron_properties/calc_iron_properties.py | 73 +++++--- ml_peg/calcs/utils/iron_utils.py | 157 +++++++++++++++++- 2 files changed, 202 insertions(+), 28 deletions(-) diff --git a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py index 1b2631086..30a42fcff 100644 --- a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py +++ b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py @@ -30,7 +30,7 @@ from ml_peg.calcs.utils.iron_utils import ( EV_PER_A2_TO_J_PER_M2, EV_PER_A3_TO_GPA, - apply_strain, + apply_voigt_strain, calculate_surface_energy, create_bain_cell, create_bcc_supercell, @@ -43,7 +43,7 @@ create_ts_100_structure, create_ts_110_structure, fit_eos, - get_voigt_strain, + relax_volume_isotropic, ) from ml_peg.models.get_models import load_models from ml_peg.models.models import current_models @@ -65,6 +65,7 @@ ELASTIC_SUPERCELL_SIZE = (4, 4, 4) ELASTIC_FMAX = 1e-10 ELASTIC_MAX_ITER = 100 +ELASTIC_ATOM_JIGGLE = 1.0e-5 # Random perturbation to prevent saddle points # Bain path parameters BAIN_NUM_POINTS = 65 @@ -119,6 +120,11 @@ def run_eos_calculation(calc: Any) -> dict[str, Any]: atoms = bulk("Fe", "bcc", a=lat, cubic=True) atoms.calc = calc + # Relax atomic positions at fixed cell volume + # (matches LAMMPS minimize behavior) + opt = BFGS(atoms, logfile=None) + opt.run(fmax=1e-5, steps=1000) + energy = atoms.get_potential_energy() volume = atoms.get_volume() @@ -174,36 +180,42 @@ def run_elastic_calculation(calc: Any, lattice_parameter: float) -> dict[str, An opt = BFGS(ecf, logfile=None) opt.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) + # Apply random jiggle to atoms to prevent staying on saddle points + rng = np.random.default_rng(seed=87287) + jiggle = rng.uniform( + -ELASTIC_ATOM_JIGGLE, ELASTIC_ATOM_JIGGLE, atoms_ref.positions.shape + ) + atoms_ref.positions += jiggle + # Elastic constant matrix C = np.zeros((6, 6)) # noqa: N806 for i in range(6): direction = i + 1 - # Positive strain - strain_pos = get_voigt_strain(direction, ELASTIC_STRAIN) - atoms_pos = apply_strain(atoms_ref.copy(), strain_pos) + # Positive strain with off-diagonal cell adjustment + atoms_pos = apply_voigt_strain(atoms_ref.copy(), direction, ELASTIC_STRAIN) atoms_pos.calc = calc opt_pos = BFGS(atoms_pos, logfile=None) opt_pos.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) - stress_pos = -atoms_pos.get_stress(voigt=True) + stress_pos = atoms_pos.get_stress(voigt=True) - # Negative strain - strain_neg = get_voigt_strain(direction, -ELASTIC_STRAIN) - atoms_neg = apply_strain(atoms_ref.copy(), strain_neg) + # Negative strain with off-diagonal cell adjustment + atoms_neg = apply_voigt_strain(atoms_ref.copy(), direction, -ELASTIC_STRAIN) atoms_neg.calc = calc opt_neg = BFGS(atoms_neg, logfile=None) opt_neg.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) - stress_neg = -atoms_neg.get_stress(voigt=True) + stress_neg = atoms_neg.get_stress(voigt=True) - # Compute elastic constants + # Compute elastic constants using stress differences + # C_ij = dσ_i / dε_j = (σ_pos - σ_neg) / (2 * ε) delta_stress = stress_pos - stress_neg delta_strain = 2 * ELASTIC_STRAIN for j in range(6): - C[j, i] = -delta_stress[j] / delta_strain * EV_PER_A3_TO_GPA + C[j, i] = delta_stress[j] / delta_strain * EV_PER_A3_TO_GPA # Symmetrize C_sym = 0.5 * (C + C.T) # noqa: N806 @@ -233,6 +245,15 @@ def run_bain_path_calculation(calc: Any, lattice_parameter: float) -> dict[str, """ Calculate the Bain path energy curve. + For each target c/a ratio, creates a tetragonally distorted cell and performs + isotropic volume relaxation (uniform scaling only) to find the minimum energy + while maintaining the c/a ratio. This matches the LAMMPS behavior where + 'fix box/relax aniso 0.0 couple xyz' is used. + + The 'couple xyz' constraint in LAMMPS couples all three diagonal stress + components together, meaning x, y, and z dimensions can only change by the + same fractional amount. This preserves the c/a ratio during relaxation. + Parameters ---------- calc @@ -252,30 +273,30 @@ def run_bain_path_calculation(calc: Any, lattice_parameter: float) -> dict[str, energies = [] for ratio in ca_ratios_target: + # Create tetragonally distorted cell at target c/a ratio atoms = create_bain_cell(lattice_parameter, ratio) atoms.calc = calc - # Box relaxation - ecf = ExpCellFilter(atoms, scalar_pressure=0.0) - opt = BFGS(ecf, logfile=None) + # Step 1: Isotropic volume relaxation (maintains c/a ratio) + # This is equivalent to LAMMPS: fix box/relax aniso 0.0 couple xyz + # Only uniform scaling is allowed, preserving the cell shape + atoms_relaxed = relax_volume_isotropic(atoms, calc) + # Step 2: Atomic position relaxation at fixed cell + # This matches LAMMPS second minimize after 'unfix relaxB' + # For atoms at high-symmetry positions (0,0,0) and (½,½,½), + # this should be essentially a no-op, but included for completeness + opt = BFGS(atoms_relaxed, logfile=None) try: - opt.run(fmax=1e-5, steps=10000) + opt.run(fmax=1e-5, steps=1000) except Exception: pass - # Additional atomic relaxation - opt2 = BFGS(atoms, logfile=None) - try: - opt2.run(fmax=1e-5, steps=10000) - except Exception: - pass - - energy = atoms.get_potential_energy() - cell = atoms.get_cell() + energy = atoms_relaxed.get_potential_energy() + cell = atoms_relaxed.get_cell() ca_actual = cell[2, 2] / cell[1, 1] - n_atoms = len(atoms) + n_atoms = len(atoms_relaxed) ca_ratios.append(ca_actual) energies.append(energy / n_atoms) diff --git a/ml_peg/calcs/utils/iron_utils.py b/ml_peg/calcs/utils/iron_utils.py index 3a58321a4..6b2630344 100644 --- a/ml_peg/calcs/utils/iron_utils.py +++ b/ml_peg/calcs/utils/iron_utils.py @@ -11,7 +11,7 @@ from ase import Atoms from ase.build import bulk import numpy as np -from scipy.optimize import leastsq +from scipy.optimize import leastsq, minimize_scalar # ============================================================================= # Unit Conversion Constants @@ -137,6 +137,90 @@ def residual(params, y, x): return {"E0": E0, "B0": B0_GPa, "Bp": Bp, "V0": V0, "a0": a0} +# ============================================================================= +# Isotropic Volume Relaxation +# ============================================================================= + + +def relax_volume_isotropic( + atoms: Atoms, + calc: Any, + scale_bounds: tuple[float, float] = (0.9, 1.1), + xtol: float = 1e-8, +) -> Atoms: + """ + Relax cell volume isotropically (uniform scaling) to minimize energy. + + This maintains cell shape (all ratios between cell dimensions) while finding + the optimal volume. This is equivalent to LAMMPS 'fix box/relax aniso 0.0 + couple xyz' which couples all three diagonal stress components together, + allowing only uniform scaling during relaxation. + + For a tetragonal cell with c/a ratio, this preserves c/a while optimizing + the volume. + + Parameters + ---------- + atoms : Atoms + ASE Atoms object (will be copied, not modified). + calc : Any + ASE calculator. + scale_bounds : tuple[float, float], optional + Bounds for the scale factor search (min, max). Default: (0.9, 1.1). + xtol : float, optional + Tolerance for the scale factor optimization. Default: 1e-8. + + Returns + ------- + Atoms + New Atoms object at optimal volume with same cell shape. + + Notes + ----- + This function matches the LAMMPS behavior for Bain path calculations where + 'couple xyz' is used to maintain the c/a ratio during volume relaxation. + The optimization finds the uniform scale factor that minimizes the total + energy of the system. + """ + atoms = atoms.copy() + original_cell = atoms.cell.array.copy() + + def energy_at_scale(scale: float) -> float: + """ + Calculate energy at a given uniform scale factor. + + Parameters + ---------- + scale : float + Uniform scale factor to apply to the cell. + + Returns + ------- + float + Potential energy of the system at the given scale. + """ + test_atoms = atoms.copy() + test_atoms.set_cell(original_cell * scale, scale_atoms=True) + test_atoms.calc = calc + return test_atoms.get_potential_energy() + + # Find optimal scale factor that minimizes energy + result = minimize_scalar( + energy_at_scale, + bounds=scale_bounds, + method="bounded", + options={"xatol": xtol}, + ) + optimal_scale = result.x + + # Create relaxed structure at optimal volume + relaxed_atoms = atoms.copy() + relaxed_atoms.set_cell(original_cell * optimal_scale, scale_atoms=True) + relaxed_atoms.calc = calc + + return relaxed_atoms + + # ============================================================================= # Structure Creation Functions # ============================================================================= @@ -338,12 +422,18 @@ def create_surface_110( positions = [] d110 = a * np.sqrt(2) / 2 + # Each (110) plane in a cell of a x a*sqrt(2) contains 2 atoms. + # The BCC (110) surface has a centered rectangular structure. for k in range(layers * 2): z = k * d110 if k % 2 == 0: + # Even planes: atoms at (0, 0) and (a/2, ly/2) positions.append([0, 0, z]) - else: positions.append([0.5 * a, 0.5 * ly, z]) + else: + # Odd planes: atoms at (0, ly/2) and (a/2, 0) + positions.append([0, 0.5 * ly, z]) + positions.append([0.5 * a, 0, z]) atoms = Atoms( symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True @@ -634,6 +724,69 @@ def apply_strain(atoms: Atoms, strain_matrix: np.ndarray) -> Atoms: return atoms_strained +def apply_voigt_strain(atoms: Atoms, direction: int, magnitude: float) -> Atoms: + """ + Apply Voigt strain with off-diagonal cell adjustment (LAMMPS-style). + + For normal strains (directions 1-3), this scales the entire cell vector + rather than just the diagonal component. This maintains cell vector ratios + and is important for triclinic cells or pre-strained configurations. + + LAMMPS equivalent for direction 1 (xx): + change_box all x delta 0 ${delta} xy delta ${deltaxy} xz delta ${deltaxz} + where deltaxy = up * xy, deltaxz = up * xz + + For cubic/orthogonal cells (xy=xz=yz=0), this is equivalent to apply_strain(). + + Parameters + ---------- + atoms : Atoms + ASE Atoms object. + direction : int + Voigt direction (1-6): + 1=xx, 2=yy, 3=zz, 4=yz, 5=xz, 6=xy. + magnitude : float + Strain magnitude (e.g., 1e-5). + + Returns + ------- + Atoms + Strained ASE Atoms object. + """ + atoms_strained = atoms.copy() + cell = atoms_strained.cell.array.copy() + + if direction == 1: + # Scale entire x cell vector (a1): maintains xy/lx and xz/lx ratios + # LAMMPS: x -> x + delta, xy -> xy + up*xy, xz -> xz + up*xz + cell[0, :] *= 1 + magnitude + elif direction == 2: + # Scale entire y cell vector (a2): maintains yz/ly ratio + # LAMMPS: y -> y + delta, yz -> yz + up*yz + cell[1, :] *= 1 + magnitude + elif direction == 3: + # Scale entire z cell vector (a3) + # LAMMPS: z -> z + delta + cell[2, :] *= 1 + magnitude + elif direction == 4: + # yz shear: LAMMPS changes yz tilt only + # For LAMMPS compatibility: simple shear (not symmetric) + # cell[1, 2] is the yz tilt component + lz = cell[2, 2] + cell[1, 2] += magnitude * lz + elif direction == 5: + # xz shear: LAMMPS changes xz tilt only + lz = cell[2, 2] + cell[0, 2] += magnitude * lz + elif direction == 6: + # xy shear: LAMMPS changes xy tilt only + ly = cell[1, 1] + cell[0, 1] += magnitude * ly + + atoms_strained.set_cell(cell, scale_atoms=True) + return atoms_strained + + def get_voigt_strain(direction: int, magnitude: float) -> np.ndarray: """ Get the strain tensor for a given Voigt direction (1-6). From 7e0a4de5a3f68fd48b8c3b5021926fd8c2c213fb Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Wed, 4 Feb 2026 15:16:58 +0000 Subject: [PATCH 07/12] code cleanup --- .../iron_properties/calc_iron_properties.py | 3 +- ml_peg/calcs/utils/iron_utils.py | 144 +++--------------- 2 files changed, 19 insertions(+), 128 deletions(-) diff --git a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py index 30a42fcff..549ab4de9 100644 --- a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py +++ b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py @@ -40,7 +40,6 @@ create_surface_110, create_surface_111, create_surface_112, - create_ts_100_structure, create_ts_110_structure, fit_eos, relax_volume_isotropic, @@ -540,7 +539,7 @@ def run_sfe_calculation( # T-S configuration: structure creation function TS_CONFIG = { - "100": create_ts_100_structure, + "100": lambda a: create_surface_100(a, layers=36, vacuum=0.0), "110": create_ts_110_structure, } diff --git a/ml_peg/calcs/utils/iron_utils.py b/ml_peg/calcs/utils/iron_utils.py index 6b2630344..bd1590d11 100644 --- a/ml_peg/calcs/utils/iron_utils.py +++ b/ml_peg/calcs/utils/iron_utils.py @@ -22,6 +22,21 @@ EV_PER_A2_TO_J_PER_M2 = 16.0217733 EV_PER_A3_TO_GPA = 160.21765 +# ============================================================================= +# Crystallographic Rotation Matrices +# ============================================================================= + +# Rotation matrix for [-110]/[111]/[11-2] crystallographic frame +# Used for (111) surface, (112) surface, and {110}<111> SFE +ROTATION_111_FRAME = np.array( + [ + [-1, 1, 0], # ex: [-110] + [1, 1, 1], # ey: [111] + [1, 1, -2], # ez: [11-2] + ], + dtype=float, +) / np.array([[np.sqrt(2)], [np.sqrt(3)], [np.sqrt(6)]]) + # ============================================================================= # EOS Fitting Functions @@ -470,12 +485,6 @@ def create_surface_111( """ a = lattice_parameter - # Rotation matrix for (111) orientation - ex = np.array([-1, 1, 0]) / np.sqrt(2) - ey = np.array([1, 1, 1]) / np.sqrt(3) - ez = np.array([1, 1, -2]) / np.sqrt(6) - rotation = np.array([ex, ey, ez]) - cell_dims = ( a * np.sqrt(2) * size[0], a * np.sqrt(3) * size[1], @@ -484,7 +493,7 @@ def create_surface_111( max_range = int(max(size) * 3 + 5) atoms = _create_oriented_bcc_structure( - lattice_parameter, rotation, cell_dims, max_range, symbol + lattice_parameter, ROTATION_111_FRAME, cell_dims, max_range, symbol ) if vacuum > 0: @@ -517,17 +526,11 @@ def create_surface_112( """ a = lattice_parameter - # Rotation matrix for (112) orientation - ex = np.array([-1, 1, 0]) / np.sqrt(2) - ey = np.array([1, 1, 1]) / np.sqrt(3) - ez = np.array([1, 1, -2]) / np.sqrt(6) - rotation = np.array([ex, ey, ez]) - cell_dims = (a * np.sqrt(2), a * np.sqrt(3), a * np.sqrt(6) * layers) max_range = int(layers * 3 + 5) atoms = _create_oriented_bcc_structure( - lattice_parameter, rotation, cell_dims, max_range, symbol + lattice_parameter, ROTATION_111_FRAME, cell_dims, max_range, symbol ) if vacuum > 0: @@ -553,12 +556,6 @@ def create_sfe_110_structure(lattice_parameter: float) -> Atoms: a = lattice_parameter size = (20, 1, 3) - # Rotation matrix for {110} orientation - ex = np.array([-1, 1, 0]) / np.sqrt(2) - ey = np.array([1, 1, 1]) / np.sqrt(3) - ez = np.array([1, 1, -2]) / np.sqrt(6) - rotation = np.array([ex, ey, ez]) - cell_dims = ( a * np.sqrt(2) * size[0], a * np.sqrt(3) * size[1], @@ -567,7 +564,7 @@ def create_sfe_110_structure(lattice_parameter: float) -> Atoms: max_range = int(max(size) * 3 + 5) return _create_oriented_bcc_structure( - lattice_parameter, rotation, cell_dims, max_range + lattice_parameter, ROTATION_111_FRAME, cell_dims, max_range ) @@ -611,51 +608,6 @@ def create_sfe_112_structure(lattice_parameter: float) -> Atoms: # ============================================================================= -def create_ts_100_structure( - lattice_parameter: float, layers: int = 36, symbol: str = "Fe" -) -> Atoms: - """ - Create structure for (100) traction-separation calculation. - - Orientation: x=[100], y=[010], z=[001] - Slab: 1x1xlayers lattice units - Cleavage plane: perpendicular to z - - Parameters - ---------- - lattice_parameter : float - BCC lattice parameter in Angstroms. - layers : int, optional - Number of layers in z direction (default: 36). - symbol : str, optional - Chemical symbol (default: 'Fe'). - - Returns - ------- - Atoms - ASE Atoms object for T-S calculation. - """ - a = lattice_parameter - - # Cell dimensions - lx = a - ly = a - lz = a * layers - - cell = np.array([[lx, 0, 0], [0, ly, 0], [0, 0, lz]]) - - # Generate BCC atoms - positions = [] - for k in range(layers): - # Two atoms per unit cell in z - positions.append([0, 0, k * a]) - positions.append([0.5 * a, 0.5 * a, (k + 0.5) * a]) - - return Atoms( - symbols=[symbol] * len(positions), positions=positions, cell=cell, pbc=True - ) - - def create_ts_110_structure( lattice_parameter: float, layers: int = 10, symbol: str = "Fe" ) -> Atoms: @@ -701,29 +653,6 @@ def create_ts_110_structure( # ============================================================================= -def apply_strain(atoms: Atoms, strain_matrix: np.ndarray) -> Atoms: - """ - Apply a strain to the atoms object. - - Parameters - ---------- - atoms : Atoms - ASE Atoms object. - strain_matrix : np.ndarray - 3x3 strain matrix. - - Returns - ------- - Atoms - Strained ASE Atoms object. - """ - atoms_strained = atoms.copy() - F = np.eye(3) + strain_matrix # noqa: N806 - new_cell = atoms_strained.cell @ F.T - atoms_strained.set_cell(new_cell, scale_atoms=True) - return atoms_strained - - def apply_voigt_strain(atoms: Atoms, direction: int, magnitude: float) -> Atoms: """ Apply Voigt strain with off-diagonal cell adjustment (LAMMPS-style). @@ -787,43 +716,6 @@ def apply_voigt_strain(atoms: Atoms, direction: int, magnitude: float) -> Atoms: return atoms_strained -def get_voigt_strain(direction: int, magnitude: float) -> np.ndarray: - """ - Get the strain tensor for a given Voigt direction (1-6). - - Parameters - ---------- - direction : int - Voigt direction (1-6). - magnitude : float - Strain magnitude. - - Returns - ------- - np.ndarray - 3x3 strain matrix. - """ - strain = np.zeros((3, 3)) - - if direction == 1: - strain[0, 0] = magnitude - elif direction == 2: - strain[1, 1] = magnitude - elif direction == 3: - strain[2, 2] = magnitude - elif direction == 4: - strain[1, 2] = magnitude / 2 - strain[2, 1] = magnitude / 2 - elif direction == 5: - strain[0, 2] = magnitude / 2 - strain[2, 0] = magnitude / 2 - elif direction == 6: - strain[0, 1] = magnitude / 2 - strain[1, 0] = magnitude / 2 - - return strain - - def calculate_surface_energy( E_slab: float, # noqa: N803 E_bulk: float, # noqa: N803 From 128437797adf6d95745fbf7240adcee862c8ecb5 Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Wed, 4 Feb 2026 20:30:51 +0000 Subject: [PATCH 08/12] code cleanup --- .../analyse_iron_properties.py | 29 ++++- .../physicality/iron_properties/metrics.yml | 55 ++++----- .../iron_properties/app_iron_properties.py | 58 +++++++++ .../iron_properties/calc_iron_properties.py | 35 +++--- ml_peg/calcs/utils/iron_utils.py | 113 +++++++++++------- 5 files changed, 195 insertions(+), 95 deletions(-) diff --git a/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py index 2e5cef366..c1c1308a6 100644 --- a/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py +++ b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py @@ -155,11 +155,20 @@ def compute_metrics(results: dict[str, Any]) -> dict[str, float]: # ========================================================================== elastic = results.get("elastic", {}) if "C11" in elastic: - metrics["C11 (GPa)"] = elastic["C11"] + C11_error = ( # noqa: N806 + abs(elastic["C11"] - DFT_REFERENCE["C11"]) / DFT_REFERENCE["C11"] * 100 + ) + metrics["C11 error (%)"] = C11_error if "C12" in elastic: - metrics["C12 (GPa)"] = elastic["C12"] + C12_error = ( # noqa: N806 + abs(elastic["C12"] - DFT_REFERENCE["C12"]) / DFT_REFERENCE["C12"] * 100 + ) + metrics["C12 error (%)"] = C12_error if "C44" in elastic: - metrics["C44 (GPa)"] = elastic["C44"] + C44_error = ( # noqa: N806 + abs(elastic["C44"] - DFT_REFERENCE["C44"]) / DFT_REFERENCE["C44"] * 100 + ) + metrics["C44 error (%)"] = C44_error # ========================================================================== # Vacancy metrics @@ -217,11 +226,21 @@ def compute_metrics(results: dict[str, Any]) -> dict[str, float]: # ========================================================================== ts_100 = results.get("ts_100", {}) if "max_traction" in ts_100: - metrics["Max traction (100) (GPa)"] = ts_100["max_traction"] + traction_100_error = ( + abs(ts_100["max_traction"] - DFT_REFERENCE["max_traction_100"]) + / DFT_REFERENCE["max_traction_100"] + * 100 + ) + metrics["Max traction (100) error (%)"] = traction_100_error ts_110 = results.get("ts_110", {}) if "max_traction" in ts_110: - metrics["Max traction (110) (GPa)"] = ts_110["max_traction"] + traction_110_error = ( + abs(ts_110["max_traction"] - DFT_REFERENCE["max_traction_110"]) + / DFT_REFERENCE["max_traction_110"] + * 100 + ) + metrics["Max traction (110) error (%)"] = traction_110_error return metrics diff --git a/ml_peg/analysis/physicality/iron_properties/metrics.yml b/ml_peg/analysis/physicality/iron_properties/metrics.yml index e70de8c75..011274642 100644 --- a/ml_peg/analysis/physicality/iron_properties/metrics.yml +++ b/ml_peg/analysis/physicality/iron_properties/metrics.yml @@ -18,23 +18,24 @@ metrics: unit: "meV" tooltip: "Error in BCC-FCC energy difference along Bain path" level_of_theory: PBE - C11 (GPa): - good: 243.0 - bad: 150.0 - unit: "GPa" - tooltip: "Elastic constant C11" + # Elastic constants + C11 error (%): + good: 0.0 + bad: 20.0 + unit: "%" + tooltip: "Elastic constant C11 error relative to DFT (ref: 243 GPa)" level_of_theory: PBE - C12 (GPa): - good: 145.0 - bad: 100.0 - unit: "GPa" - tooltip: "Elastic constant C12" + C12 error (%): + good: 0.0 + bad: 20.0 + unit: "%" + tooltip: "Elastic constant C12 error relative to DFT (ref: 145 GPa)" level_of_theory: PBE - C44 (GPa): - good: 116.0 - bad: 80.0 - unit: "GPa" - tooltip: "Elastic constant C44 (shear modulus)" + C44 error (%): + good: 0.0 + bad: 20.0 + unit: "%" + tooltip: "Elastic constant C44 error relative to DFT (ref: 116 GPa)" level_of_theory: PBE # Defect properties E_vac error (%): @@ -62,15 +63,15 @@ metrics: tooltip: "Error in maximum stacking fault energy for {112}<111> slip system" level_of_theory: PBE # Traction-separation properties - Max traction (100) (GPa): - good: 35.0 - bad: 20.0 - unit: "GPa" - tooltip: "Maximum traction stress for (100) cleavage plane" - level_of_theory: DFT - Max traction (110) (GPa): - good: 30.0 - bad: 18.0 - unit: "GPa" - tooltip: "Maximum traction stress for (110) cleavage plane" - level_of_theory: DFT + Max traction (100) error (%): + good: 0.0 + bad: 30.0 + unit: "%" + tooltip: "Maximum traction error relative to DFT (ref: 35 GPa)" + level_of_theory: PBE + Max traction (110) error (%): + good: 0.0 + bad: 30.0 + unit: "%" + tooltip: "Maximum traction error relative to DFT (ref: 30 GPa)" + level_of_theory: PBE diff --git a/ml_peg/app/physicality/iron_properties/app_iron_properties.py b/ml_peg/app/physicality/iron_properties/app_iron_properties.py index a30b5f9cc..8bc861f41 100644 --- a/ml_peg/app/physicality/iron_properties/app_iron_properties.py +++ b/ml_peg/app/physicality/iron_properties/app_iron_properties.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + from dash import Dash, Input, Output, callback, dcc from dash.dcc import Loading from dash.exceptions import PreventUpdate @@ -12,6 +14,7 @@ from ml_peg.app import APP_ROOT from ml_peg.app.base_app import BaseApp from ml_peg.calcs import CALCS_ROOT +from ml_peg.calcs.utils.iron_utils import load_dft_curve from ml_peg.models.get_models import get_model_names from ml_peg.models.models import current_models @@ -22,6 +25,9 @@ CALC_PATH = CALCS_ROOT / "physicality" / "iron_properties" / "outputs" DOCS_URL = "https://ddmms.github.io/ml-peg/user_guide/benchmarks/physicality.html#iron-properties" +# Path to DFT reference data +DFT_DATA_PATH = Path(__file__).parent.parent.parent.parent / "data" / "iron_properties" + # Curve configuration: file name, x column, y column, title, x label, y label CURVE_CONFIG = { @@ -75,6 +81,43 @@ }, } +# DFT reference curve configuration +DFT_CURVE_CONFIG = { + "bain": { + "file": "BainPath_DFT.csv", + "sep": ",", + "decimal": ".", + "header": None, + "x_col": 0, + "y_col": 1, + "normalize_energy_mev": True, + }, + "sfe_110": { + "file": "sfe_iron_110.csv", + "sep": r"\s+", + "decimal": ".", + "header": 0, + "x_col": "displacement", + "y_col": "energy(J/m^2)", + }, + "ts_100": { + "file": "ts_100_dft.csv", + "sep": r"\s+", + "decimal": ".", + "header": None, + "x_col": 0, + "y_col": 1, + }, + "ts_110": { + "file": "ts_110_dft.csv", + "sep": r"\s+", + "decimal": ".", + "header": None, + "x_col": 0, + "y_col": 1, + }, +} + def _load_curve_data(model_name: str, curve_type: str) -> pd.DataFrame | None: """ @@ -129,6 +172,21 @@ def _create_figure(df: pd.DataFrame, curve_type: str, model_name: str) -> go.Fig fig = go.Figure() + # Add DFT reference curve if available + dft_data = load_dft_curve(curve_type, DFT_DATA_PATH, DFT_CURVE_CONFIG) + if dft_data is not None: + x_dft, y_dft = dft_data + fig.add_trace( + go.Scatter( + x=x_dft, + y=y_dft, + mode="lines", + name="DFT Reference", + line={"width": 2, "dash": "dash", "color": "gray"}, + ) + ) + + # Add model curve fig.add_trace( go.Scatter( x=df[config["x"]], diff --git a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py index 549ab4de9..931eec9b3 100644 --- a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py +++ b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py @@ -40,7 +40,6 @@ create_surface_110, create_surface_111, create_surface_112, - create_ts_110_structure, fit_eos, relax_volume_isotropic, ) @@ -59,11 +58,13 @@ # EOS calculation parameters EOS_NUM_POINTS = 30 +# BFGS optimization parameters +BFGS_FMAX = 1e-5 +BFGS_MAX_ITER = 100 + # Elastic constants parameters ELASTIC_STRAIN = 1.0e-5 ELASTIC_SUPERCELL_SIZE = (4, 4, 4) -ELASTIC_FMAX = 1e-10 -ELASTIC_MAX_ITER = 100 ELASTIC_ATOM_JIGGLE = 1.0e-5 # Random perturbation to prevent saddle points # Bain path parameters @@ -71,17 +72,14 @@ # Vacancy calculation parameters VACANCY_SUPERCELL_SIZE = (4, 4, 4) -VACANCY_FMAX = 1e-5 # Surface calculation parameters SURFACE_VACUUM = 10.0 # Angstroms -SURFACE_FMAX = 1e-5 # Stacking fault calculation parameters SFE_110_STEPS = 63 SFE_112_STEPS = 100 SFE_STEP_SIZE = 0.04 # Angstroms -SFE_FMAX = 1e-5 # Traction-separation parameters TS_MAX_SEPARATION = 5.0 # Angstroms @@ -122,7 +120,7 @@ def run_eos_calculation(calc: Any) -> dict[str, Any]: # Relax atomic positions at fixed cell volume # (matches LAMMPS minimize behavior) opt = BFGS(atoms, logfile=None) - opt.run(fmax=1e-5, steps=1000) + opt.run(fmax=BFGS_FMAX, steps=BFGS_MAX_ITER) energy = atoms.get_potential_energy() volume = atoms.get_volume() @@ -177,7 +175,7 @@ def run_elastic_calculation(calc: Any, lattice_parameter: float) -> dict[str, An # Box relaxation ecf = ExpCellFilter(atoms_ref) opt = BFGS(ecf, logfile=None) - opt.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) + opt.run(fmax=BFGS_FMAX, steps=BFGS_MAX_ITER) # Apply random jiggle to atoms to prevent staying on saddle points rng = np.random.default_rng(seed=87287) @@ -197,7 +195,7 @@ def run_elastic_calculation(calc: Any, lattice_parameter: float) -> dict[str, An atoms_pos.calc = calc opt_pos = BFGS(atoms_pos, logfile=None) - opt_pos.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) + opt_pos.run(fmax=BFGS_FMAX, steps=BFGS_MAX_ITER) stress_pos = atoms_pos.get_stress(voigt=True) # Negative strain with off-diagonal cell adjustment @@ -205,7 +203,7 @@ def run_elastic_calculation(calc: Any, lattice_parameter: float) -> dict[str, An atoms_neg.calc = calc opt_neg = BFGS(atoms_neg, logfile=None) - opt_neg.run(fmax=ELASTIC_FMAX, steps=ELASTIC_MAX_ITER) + opt_neg.run(fmax=BFGS_FMAX, steps=BFGS_MAX_ITER) stress_neg = atoms_neg.get_stress(voigt=True) # Compute elastic constants using stress differences @@ -287,7 +285,7 @@ def run_bain_path_calculation(calc: Any, lattice_parameter: float) -> dict[str, # this should be essentially a no-op, but included for completeness opt = BFGS(atoms_relaxed, logfile=None) try: - opt.run(fmax=1e-5, steps=1000) + opt.run(fmax=BFGS_FMAX, steps=BFGS_MAX_ITER) except Exception: pass @@ -356,7 +354,7 @@ def run_vacancy_calculation(calc: Any, lattice_parameter: float) -> dict[str, An atoms_defect.calc = calc opt = BFGS(atoms_defect, logfile=None) - opt.run(fmax=VACANCY_FMAX, steps=10000) + opt.run(fmax=BFGS_FMAX, steps=BFGS_MAX_ITER) E_defect = atoms_defect.get_potential_energy() # noqa: N806 E_vac = (E_defect - E_perfect) + E_coh # noqa: N806 @@ -420,6 +418,7 @@ def run_surface_calculations(calc: Any, lattice_parameter: float) -> dict[str, A surfaces = {} for name, cfg in SURFACE_CONFIG.items(): + print(f"Running surface calculation for {name}...") create_fn = cfg["create_fn"] area_axes = cfg["area_axes"] vacuum = cfg["vacuum"] @@ -443,7 +442,7 @@ def run_surface_calculations(calc: Any, lattice_parameter: float) -> dict[str, A atoms_slab = create_fn(lattice_parameter, **slab_kwargs) atoms_slab.calc = calc opt = BFGS(atoms_slab, logfile=None) - opt.run(fmax=SURFACE_FMAX, steps=10000) + opt.run(fmax=BFGS_FMAX, steps=BFGS_MAX_ITER) e_slab = atoms_slab.get_potential_energy() surfaces[name] = calculate_surface_energy(e_slab, e_bulk, area) @@ -492,7 +491,7 @@ def run_sfe_calculation( area = ly * lz opt = BFGS(atoms, logfile=None) - opt.run(fmax=SFE_FMAX, steps=10000) + opt.run(fmax=BFGS_FMAX, steps=BFGS_MAX_ITER) e0 = atoms.get_potential_energy() positions = atoms.get_positions() @@ -515,7 +514,7 @@ def run_sfe_calculation( opt = BFGS(atoms, logfile=None) try: - opt.run(fmax=SFE_FMAX, steps=10000) + opt.run(fmax=BFGS_FMAX, steps=BFGS_MAX_ITER) except Exception: pass @@ -540,7 +539,7 @@ def run_sfe_calculation( # T-S configuration: structure creation function TS_CONFIG = { "100": lambda a: create_surface_100(a, layers=36, vacuum=0.0), - "110": create_ts_110_structure, + "110": lambda a: create_surface_110(a, layers=10, vacuum=0.0), } @@ -614,7 +613,9 @@ def run_ts_calculation( fz_upper = np.sum(forces[upper_indices, 2]) # Convert to stress (GPa): σ = F / A - sig_upper = EV_PER_A3_TO_GPA * fz_upper / area + # Negate because forces on upper atoms point downward (negative z) + # but traction (tensile stress) should be positive + sig_upper = -EV_PER_A3_TO_GPA * fz_upper / area separations.append(dd) energies.append(energy) diff --git a/ml_peg/calcs/utils/iron_utils.py b/ml_peg/calcs/utils/iron_utils.py index bd1590d11..10d1f47bd 100644 --- a/ml_peg/calcs/utils/iron_utils.py +++ b/ml_peg/calcs/utils/iron_utils.py @@ -6,6 +6,7 @@ from __future__ import annotations +from pathlib import Path from typing import Any from ase import Atoms @@ -603,51 +604,6 @@ def create_sfe_112_structure(lattice_parameter: float) -> Atoms: ) -# ============================================================================= -# Traction-Separation Structure Functions -# ============================================================================= - - -def create_ts_110_structure( - lattice_parameter: float, layers: int = 10, symbol: str = "Fe" -) -> Atoms: - """ - Create structure for (110) traction-separation calculation. - - Orientation: x=[100], y=[01-1], z=[011] - Slab: 1x1xlayers lattice units - Cleavage plane: perpendicular to z (which is [011]) - - Parameters - ---------- - lattice_parameter : float - BCC lattice parameter in Angstroms. - layers : int, optional - Number of layers in z direction (default: 10). - symbol : str, optional - Chemical symbol (default: 'Fe'). - - Returns - ------- - Atoms - ASE Atoms object for T-S calculation. - """ - a = lattice_parameter - - # Rotation matrix for T-S 110 orientation - ex = np.array([1, 0, 0]) - ey = np.array([0, 1, -1]) / np.sqrt(2) - ez = np.array([0, 1, 1]) / np.sqrt(2) - rotation = np.array([ex, ey, ez]) - - cell_dims = (a, a * np.sqrt(2), a * np.sqrt(2) * layers) - max_range = int(layers * 2 + 5) - - return _create_oriented_bcc_structure( - lattice_parameter, rotation, cell_dims, max_range, symbol - ) - - # ============================================================================= # Elastic Calculation Utilities # ============================================================================= @@ -655,7 +611,7 @@ def create_ts_110_structure( def apply_voigt_strain(atoms: Atoms, direction: int, magnitude: float) -> Atoms: """ - Apply Voigt strain with off-diagonal cell adjustment (LAMMPS-style). + Apply Voigt strain with off-diagonal cell adjustment. For normal strains (directions 1-3), this scales the entire cell vector rather than just the diagonal component. This maintains cell vector ratios @@ -740,3 +696,68 @@ def calculate_surface_energy( """ delta_E = E_slab - E_bulk # noqa: N806 return delta_E * EV_TO_J / (2 * area * ANGSTROM_TO_M**2) + + +# ============================================================================= +# DFT Reference Curve Loading +# ============================================================================= + + +def load_dft_curve( + curve_type: str, + dft_data_path: Path, + dft_curve_config: dict, +) -> tuple[np.ndarray, np.ndarray] | None: + """ + Load DFT reference curve data. + + Parameters + ---------- + curve_type : str + Type of curve to load (e.g., 'bain', 'sfe_110', 'ts_100', 'ts_110'). + dft_data_path : Path + Path to the directory containing DFT data files. + dft_curve_config : dict + Configuration dict mapping curve types to file info. + + Returns + ------- + tuple[np.ndarray, np.ndarray] or None + Tuple of (x_values, y_values) arrays, or None if not available. + """ + import pandas as pd + + dft_config = dft_curve_config.get(curve_type) + if not dft_config: + return None + + dft_path = dft_data_path / dft_config["file"] + if not dft_path.exists(): + return None + + try: + df = pd.read_csv( + dft_path, + sep=dft_config["sep"], + decimal=dft_config["decimal"], + header=dft_config["header"], + ) + + x_col = dft_config["x_col"] + y_col = dft_config["y_col"] + + x_values = ( + df.iloc[:, x_col].values if isinstance(x_col, int) else df[x_col].values + ) + y_values = ( + df.iloc[:, y_col].values if isinstance(y_col, int) else df[y_col].values + ) + + # Normalize energy relative to minimum (data already in meV) + if dft_config.get("normalize_energy_mev"): + y_min = np.min(y_values) + y_values = y_values - y_min + + return x_values, y_values + except Exception: + return None From 4a425004d1f495fe76ba26c26328d2f9ebc8f94b Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Wed, 4 Feb 2026 22:25:58 +0000 Subject: [PATCH 09/12] adjustments, code cleanup --- .../analyse_iron_properties.py | 4 +++ .../iron_properties/app_iron_properties.py | 31 ++++++++++++++++--- .../iron_properties/calc_iron_properties.py | 20 ++++++------ ml_peg/calcs/utils/iron_utils.py | 4 +++ 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py index c1c1308a6..639a3cabe 100644 --- a/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py +++ b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py @@ -41,6 +41,10 @@ "a0": 2.831, # Lattice parameter (Å) "B0": 178.0, # Bulk modulus (GPa) "E_bcc_fcc": 83.5, # BCC-FCC energy difference (meV/atom) + # Elastic constants (GPa) + "C11": 296.7, + "C12": 151.4, + "C44": 104.7, # Defect properties "E_vac": 2.02, # Vacancy formation energy (eV) "gamma_100": 2.41, # Surface energy (J/m²) diff --git a/ml_peg/app/physicality/iron_properties/app_iron_properties.py b/ml_peg/app/physicality/iron_properties/app_iron_properties.py index 8bc861f41..98f019206 100644 --- a/ml_peg/app/physicality/iron_properties/app_iron_properties.py +++ b/ml_peg/app/physicality/iron_properties/app_iron_properties.py @@ -8,6 +8,7 @@ from dash.dcc import Loading from dash.exceptions import PreventUpdate from dash.html import Div, Label +import numpy as np import pandas as pd import plotly.graph_objects as go @@ -93,12 +94,22 @@ "normalize_energy_mev": True, }, "sfe_110": { - "file": "sfe_iron_110.csv", - "sep": r"\s+", + "file": "sfe_110_dft.csv", + "sep": ",", "decimal": ".", - "header": 0, - "x_col": "displacement", - "y_col": "energy(J/m^2)", + "header": None, + "x_col": 0, + "y_col": 1, + "x_scale": 2.831 * 1.7320508 / 2, # Burgers vector: a * sqrt(3) / 2 + }, + "sfe_112": { + "file": "sfe_112_dft.csv", + "sep": ",", + "decimal": ".", + "header": None, + "x_col": 0, + "y_col": 1, + "x_scale": 2.831 * 1.7320508 / 2, # Burgers vector: a * sqrt(3) / 2 }, "ts_100": { "file": "ts_100_dft.csv", @@ -172,10 +183,20 @@ def _create_figure(df: pd.DataFrame, curve_type: str, model_name: str) -> go.Fig fig = go.Figure() + # Get model data x-range for limiting DFT reference + model_x_max = df[config["x"]].max() if config.get("x") else None + # Add DFT reference curve if available dft_data = load_dft_curve(curve_type, DFT_DATA_PATH, DFT_CURVE_CONFIG) if dft_data is not None: x_dft, y_dft = dft_data + + # For SFE curves, limit DFT data to model x-range (data is periodic) + if curve_type.startswith("sfe_") and model_x_max is not None: + mask = x_dft <= model_x_max + x_dft = np.array(x_dft)[mask] + y_dft = np.array(y_dft)[mask] + fig.add_trace( go.Scatter( x=x_dft, diff --git a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py index 931eec9b3..f7f326f21 100644 --- a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py +++ b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py @@ -77,9 +77,7 @@ SURFACE_VACUUM = 10.0 # Angstroms # Stacking fault calculation parameters -SFE_110_STEPS = 63 -SFE_112_STEPS = 100 -SFE_STEP_SIZE = 0.04 # Angstroms +SFE_STEPS = 16 # Number of steps to cover one Burgers vector # Traction-separation parameters TS_MAX_SEPARATION = 5.0 # Angstroms @@ -454,10 +452,10 @@ def run_surface_calculations(calc: Any, lattice_parameter: float) -> dict[str, A # Stacking Fault Energy Calculations # ============================================================================= -# SFE configuration: create_fn, number of steps, displacement axis +# SFE configuration: create_fn, displacement axis SFE_CONFIG = { - "110": {"create_fn": create_sfe_110_structure, "steps": SFE_110_STEPS, "axis": 1}, - "112": {"create_fn": create_sfe_112_structure, "steps": SFE_112_STEPS, "axis": 2}, + "110": {"create_fn": create_sfe_110_structure, "axis": 1}, + "112": {"create_fn": create_sfe_112_structure, "axis": 2}, } @@ -485,6 +483,10 @@ def run_sfe_calculation( atoms = config["create_fn"](lattice_parameter) atoms.calc = calc + # Calculate Burgers vector magnitude: b = a * sqrt(3) / 2 + burgers_vector = lattice_parameter * np.sqrt(3) / 2 + step_size = burgers_vector / SFE_STEPS + cell = atoms.get_cell() ly = cell[1, 1] lz = cell[2, 2] @@ -505,9 +507,9 @@ def run_sfe_calculation( constraints = [FixedLine(idx, direction=[1, 0, 0]) for idx in range(len(atoms))] displacement_axis = config["axis"] - for step in range(1, config["steps"] + 1): + for step in range(1, SFE_STEPS + 1): positions = atoms.get_positions() - positions[upper_indices, displacement_axis] += SFE_STEP_SIZE + positions[upper_indices, displacement_axis] += step_size atoms.set_positions(positions) atoms.set_constraint(constraints) @@ -522,7 +524,7 @@ def run_sfe_calculation( energy = atoms.get_potential_energy() sfe = (energy - e0) / (2 * area) * EV_PER_A2_TO_J_PER_M2 - displacements.append(step * SFE_STEP_SIZE) + displacements.append(step * step_size) sfe_j_per_m2.append(sfe) return { diff --git a/ml_peg/calcs/utils/iron_utils.py b/ml_peg/calcs/utils/iron_utils.py index 10d1f47bd..8a1d6e383 100644 --- a/ml_peg/calcs/utils/iron_utils.py +++ b/ml_peg/calcs/utils/iron_utils.py @@ -758,6 +758,10 @@ def load_dft_curve( y_min = np.min(y_values) y_values = y_values - y_min + # Scale x values if scale factor provided (for relative -> absolute conversion) + if dft_config.get("x_scale"): + x_values = x_values * dft_config["x_scale"] + return x_values, y_values except Exception: return None From d93cb9101e80f46fb68fbe6b9c5e70184efc7a53 Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Wed, 4 Feb 2026 23:01:06 +0000 Subject: [PATCH 10/12] move utils file for iron --- .../app/physicality/iron_properties/app_iron_properties.py | 2 +- .../physicality/iron_properties/calc_iron_properties.py | 5 +---- .../{utils => physicality/iron_properties}/iron_utils.py | 0 3 files changed, 2 insertions(+), 5 deletions(-) rename ml_peg/calcs/{utils => physicality/iron_properties}/iron_utils.py (100%) diff --git a/ml_peg/app/physicality/iron_properties/app_iron_properties.py b/ml_peg/app/physicality/iron_properties/app_iron_properties.py index 98f019206..57d8427ac 100644 --- a/ml_peg/app/physicality/iron_properties/app_iron_properties.py +++ b/ml_peg/app/physicality/iron_properties/app_iron_properties.py @@ -15,7 +15,7 @@ from ml_peg.app import APP_ROOT from ml_peg.app.base_app import BaseApp from ml_peg.calcs import CALCS_ROOT -from ml_peg.calcs.utils.iron_utils import load_dft_curve +from ml_peg.calcs.physicality.iron_properties.iron_utils import load_dft_curve from ml_peg.models.get_models import get_model_names from ml_peg.models.models import current_models diff --git a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py index f7f326f21..0913a37df 100644 --- a/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py +++ b/ml_peg/calcs/physicality/iron_properties/calc_iron_properties.py @@ -27,7 +27,7 @@ import pandas as pd import pytest -from ml_peg.calcs.utils.iron_utils import ( +from ml_peg.calcs.physicality.iron_properties.iron_utils import ( EV_PER_A2_TO_J_PER_M2, EV_PER_A3_TO_GPA, apply_voigt_strain, @@ -278,9 +278,6 @@ def run_bain_path_calculation(calc: Any, lattice_parameter: float) -> dict[str, atoms_relaxed = relax_volume_isotropic(atoms, calc) # Step 2: Atomic position relaxation at fixed cell - # This matches LAMMPS second minimize after 'unfix relaxB' - # For atoms at high-symmetry positions (0,0,0) and (½,½,½), - # this should be essentially a no-op, but included for completeness opt = BFGS(atoms_relaxed, logfile=None) try: opt.run(fmax=BFGS_FMAX, steps=BFGS_MAX_ITER) diff --git a/ml_peg/calcs/utils/iron_utils.py b/ml_peg/calcs/physicality/iron_properties/iron_utils.py similarity index 100% rename from ml_peg/calcs/utils/iron_utils.py rename to ml_peg/calcs/physicality/iron_properties/iron_utils.py From 4aa38bab0e6cb80a6b8f733eb3eb318452703eca Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Thu, 5 Feb 2026 19:25:45 +0000 Subject: [PATCH 11/12] save DFT values into analysis file to simplify things --- .../analyse_iron_properties.py | 73 +++++++++++++++++++ .../iron_properties/app_iron_properties.py | 64 ++-------------- .../physicality/iron_properties/iron_utils.py | 70 ------------------ 3 files changed, 80 insertions(+), 127 deletions(-) diff --git a/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py index 639a3cabe..76ae8dc47 100644 --- a/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py +++ b/ml_peg/analysis/physicality/iron_properties/analyse_iron_properties.py @@ -58,6 +58,79 @@ "max_traction_110": 30.0, # Max traction for (110) cleavage (GPa) } +# DFT reference curves: (x_array, y_array) for plotting against MLIP results. +# fmt: off +DFT_CURVES = { + # Bain path: c/a ratio vs energy relative to BCC minimum (meV/atom) + "bain": ( + np.array([ + 0.698931, 0.746072, 0.788498, 0.815211, 0.878064, 0.923633, + 0.969202, 0.999057, 1.058768, 1.102766, 1.148334, 1.192332, + 1.237901, 1.283470, 1.327467, 1.371464, 1.413891, 1.462602, + 1.506600, 1.550597, 1.596166, 1.632307, 1.685732, 1.731301, + 1.776870, 1.820867, 1.864865, 1.910434, 1.956003, 2.000000, + ]), + np.array([ + 318.348, 225.434, 168.930, 132.529, 58.100, 21.163, + 3.248, 0.000, 9.265, 25.045, 45.717, 70.736, + 97.387, 123.493, 144.164, 156.140, 162.137, 157.267, + 144.242, 127.414, 116.020, 110.058, 111.168, 118.253, + 135.121, 157.423, 186.246, 221.048, 261.829, 308.044, + ]), + ), + # SFE {110}<111>: displacement (Å) vs SFE (J/m²) + "sfe_110": ( + np.array([0.24948, 0.49491, 0.73639, 0.97793, 1.22288, + 1.47228, 1.71376, 1.96316, 2.21261]), + np.array([0.13725, 0.44608, 0.74265, 0.91912, 0.97549, + 0.91422, 0.74510, 0.44608, 0.13480]), + ), + # SFE {112}<111>: displacement (Å) vs SFE (J/m²) + "sfe_112": ( + np.array([0.24948, 0.48691, 0.73639, 0.97793, 1.22343, + 1.46893, 1.71031, 1.95971, 2.20911]), + np.array([0.16137, 0.51100, 0.83130, 1.00733, 1.09780, + 1.11980, 0.93399, 0.55990, 0.17604]), + ), + # Traction-separation (100): separation (Å) vs traction (GPa) + "ts_100": ( + np.array([ + 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, + 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, + 2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9, + 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 4.0, + ]), + np.array([ + 0.022, 12.350, 21.600, 28.048, 32.251, 34.000, + 35.045, 34.788, 33.604, 31.294, 27.635, 23.440, + 20.571, 18.231, 16.387, 14.923, 13.783, 12.695, + 11.477, 10.608, 9.565, 8.949, 8.095, 7.273, + 6.499, 5.864, 5.176, 4.694, 4.101, 3.488, + 3.372, 2.903, 2.691, 2.228, 1.831, 1.291, + 1.270, 1.492, 0.971, 1.142, 0.629, + ]), + ), + # Traction-separation (110): separation (Å) vs traction (GPa) + "ts_110": ( + np.array([ + 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, + 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, + 2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9, + 3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 4.0, + ]), + np.array([ + 0.043, 13.291, 22.902, 29.408, 33.134, 34.666, + 34.930, 33.885, 32.144, 30.322, 28.024, 25.722, + 22.988, 20.383, 18.169, 16.304, 14.363, 12.924, + 11.271, 10.082, 8.839, 7.597, 6.480, 5.937, + 5.520, 4.540, 3.986, 3.310, 3.177, 2.730, + 2.518, 2.336, 1.620, 1.796, 1.625, 1.307, + 1.171, 1.283, 0.892, 0.916, 0.320, + ]), + ), +} +# fmt: on + # Curve file mapping for CSV loading CURVE_FILES = { diff --git a/ml_peg/app/physicality/iron_properties/app_iron_properties.py b/ml_peg/app/physicality/iron_properties/app_iron_properties.py index 57d8427ac..0766f5462 100644 --- a/ml_peg/app/physicality/iron_properties/app_iron_properties.py +++ b/ml_peg/app/physicality/iron_properties/app_iron_properties.py @@ -2,8 +2,6 @@ from __future__ import annotations -from pathlib import Path - from dash import Dash, Input, Output, callback, dcc from dash.dcc import Loading from dash.exceptions import PreventUpdate @@ -12,10 +10,12 @@ import pandas as pd import plotly.graph_objects as go +from ml_peg.analysis.physicality.iron_properties.analyse_iron_properties import ( + DFT_CURVES, +) from ml_peg.app import APP_ROOT from ml_peg.app.base_app import BaseApp from ml_peg.calcs import CALCS_ROOT -from ml_peg.calcs.physicality.iron_properties.iron_utils import load_dft_curve from ml_peg.models.get_models import get_model_names from ml_peg.models.models import current_models @@ -26,10 +26,6 @@ CALC_PATH = CALCS_ROOT / "physicality" / "iron_properties" / "outputs" DOCS_URL = "https://ddmms.github.io/ml-peg/user_guide/benchmarks/physicality.html#iron-properties" -# Path to DFT reference data -DFT_DATA_PATH = Path(__file__).parent.parent.parent.parent / "data" / "iron_properties" - - # Curve configuration: file name, x column, y column, title, x label, y label CURVE_CONFIG = { "eos": { @@ -82,53 +78,6 @@ }, } -# DFT reference curve configuration -DFT_CURVE_CONFIG = { - "bain": { - "file": "BainPath_DFT.csv", - "sep": ",", - "decimal": ".", - "header": None, - "x_col": 0, - "y_col": 1, - "normalize_energy_mev": True, - }, - "sfe_110": { - "file": "sfe_110_dft.csv", - "sep": ",", - "decimal": ".", - "header": None, - "x_col": 0, - "y_col": 1, - "x_scale": 2.831 * 1.7320508 / 2, # Burgers vector: a * sqrt(3) / 2 - }, - "sfe_112": { - "file": "sfe_112_dft.csv", - "sep": ",", - "decimal": ".", - "header": None, - "x_col": 0, - "y_col": 1, - "x_scale": 2.831 * 1.7320508 / 2, # Burgers vector: a * sqrt(3) / 2 - }, - "ts_100": { - "file": "ts_100_dft.csv", - "sep": r"\s+", - "decimal": ".", - "header": None, - "x_col": 0, - "y_col": 1, - }, - "ts_110": { - "file": "ts_110_dft.csv", - "sep": r"\s+", - "decimal": ".", - "header": None, - "x_col": 0, - "y_col": 1, - }, -} - def _load_curve_data(model_name: str, curve_type: str) -> pd.DataFrame | None: """ @@ -187,9 +136,9 @@ def _create_figure(df: pd.DataFrame, curve_type: str, model_name: str) -> go.Fig model_x_max = df[config["x"]].max() if config.get("x") else None # Add DFT reference curve if available - dft_data = load_dft_curve(curve_type, DFT_DATA_PATH, DFT_CURVE_CONFIG) + dft_data = DFT_CURVES.get(curve_type) if dft_data is not None: - x_dft, y_dft = dft_data + x_dft, y_dft = dft_data[0].copy(), dft_data[1].copy() # For SFE curves, limit DFT data to model x-range (data is periodic) if curve_type.startswith("sfe_") and model_x_max is not None: @@ -252,7 +201,8 @@ def register_callbacks(self) -> None: Input(model_dropdown_id, "value"), Input(curve_dropdown_id, "value"), ) - def update_figure(model_name: str, curve_type: str) -> go.Figure: + def update_figure(model_name: str, curve_type: str) -> go.Figure: # noqa: F811 + # Invoked by Dash's callback system, not called directly. """ Update figure based on model and curve selection. diff --git a/ml_peg/calcs/physicality/iron_properties/iron_utils.py b/ml_peg/calcs/physicality/iron_properties/iron_utils.py index 8a1d6e383..fd1c15c58 100644 --- a/ml_peg/calcs/physicality/iron_properties/iron_utils.py +++ b/ml_peg/calcs/physicality/iron_properties/iron_utils.py @@ -6,7 +6,6 @@ from __future__ import annotations -from pathlib import Path from typing import Any from ase import Atoms @@ -696,72 +695,3 @@ def calculate_surface_energy( """ delta_E = E_slab - E_bulk # noqa: N806 return delta_E * EV_TO_J / (2 * area * ANGSTROM_TO_M**2) - - -# ============================================================================= -# DFT Reference Curve Loading -# ============================================================================= - - -def load_dft_curve( - curve_type: str, - dft_data_path: Path, - dft_curve_config: dict, -) -> tuple[np.ndarray, np.ndarray] | None: - """ - Load DFT reference curve data. - - Parameters - ---------- - curve_type : str - Type of curve to load (e.g., 'bain', 'sfe_110', 'ts_100', 'ts_110'). - dft_data_path : Path - Path to the directory containing DFT data files. - dft_curve_config : dict - Configuration dict mapping curve types to file info. - - Returns - ------- - tuple[np.ndarray, np.ndarray] or None - Tuple of (x_values, y_values) arrays, or None if not available. - """ - import pandas as pd - - dft_config = dft_curve_config.get(curve_type) - if not dft_config: - return None - - dft_path = dft_data_path / dft_config["file"] - if not dft_path.exists(): - return None - - try: - df = pd.read_csv( - dft_path, - sep=dft_config["sep"], - decimal=dft_config["decimal"], - header=dft_config["header"], - ) - - x_col = dft_config["x_col"] - y_col = dft_config["y_col"] - - x_values = ( - df.iloc[:, x_col].values if isinstance(x_col, int) else df[x_col].values - ) - y_values = ( - df.iloc[:, y_col].values if isinstance(y_col, int) else df[y_col].values - ) - - # Normalize energy relative to minimum (data already in meV) - if dft_config.get("normalize_energy_mev"): - y_min = np.min(y_values) - y_values = y_values - y_min - - # Scale x values if scale factor provided (for relative -> absolute conversion) - if dft_config.get("x_scale"): - x_values = x_values * dft_config["x_scale"] - - return x_values, y_values - except Exception: - return None From aa70e4d11d9ae8247d58bdaa7ca31115276f194c Mon Sep 17 00:00:00 2001 From: ttompa <01_buck_jubilee@icloud.com> Date: Thu, 5 Feb 2026 21:04:53 +0000 Subject: [PATCH 12/12] add docs for iron benchmark --- .../user_guide/benchmarks/iron_properties.rst | 158 ++++++++++++++++++ .../user_guide/benchmarks/physicality.rst | 5 + 2 files changed, 163 insertions(+) create mode 100644 docs/source/user_guide/benchmarks/iron_properties.rst diff --git a/docs/source/user_guide/benchmarks/iron_properties.rst b/docs/source/user_guide/benchmarks/iron_properties.rst new file mode 100644 index 000000000..4cf54be93 --- /dev/null +++ b/docs/source/user_guide/benchmarks/iron_properties.rst @@ -0,0 +1,158 @@ +=============== +Iron Properties +=============== + +Summary +------- + +This benchmark evaluates MLIP performance on a comprehensive set of BCC iron +properties relevant to plasticity and fracture. The benchmark is based on +`Zhang et al. (2023) `_, which assessed the +efficiency, accuracy, and transferability of machine learning potentials for +dislocations and cracks in iron. + +Seven groups of properties are computed and compared against DFT (PBE) reference +values: equation of state, elastic constants, the Bain path, vacancy formation +energy, surface energies, generalised stacking fault energies, and +traction-separation curves. + +Metrics +------- + +EOS properties +^^^^^^^^^^^^^^ + +1. Lattice parameter error (%) + +The equilibrium BCC lattice parameter :math:`a_0` is obtained by fitting a +third-order Birch-Murnaghan equation of state to 30 energy-volume points +sampled around 2.834 Å. The percentage error relative to the DFT reference +value (2.831 Å) is reported. + +2. Bulk modulus error (%) + +The bulk modulus :math:`B_0` is extracted from the same EOS fit. The +percentage error relative to the DFT reference value (178.0 GPa) is reported. + + +Elastic constants +^^^^^^^^^^^^^^^^^ + +3. :math:`C_{11}` error (%) + +The elastic constant :math:`C_{11}` is computed using a stress-strain approach +on a 4x4x4 BCC supercell. Small positive and negative strains +(:math:`\pm 10^{-5}`) are applied along each Voigt direction, and the elastic +constants are extracted from the resulting stress differences. The percentage +error relative to the DFT reference value (296.7 GPa) is reported. + +4. :math:`C_{12}` error (%) + +Same as (3), for the elastic constant :math:`C_{12}`. Reference: 151.4 GPa. + +5. :math:`C_{44}` error (%) + +Same as (3), for the elastic constant :math:`C_{44}`. Reference: 104.7 GPa. + + +Vacancy formation energy +^^^^^^^^^^^^^^^^^^^^^^^^^ + +6. :math:`E_{\mathrm{vac}}` error (%) + +A single vacancy is created in a 4x4x4 BCC supercell by removing one atom. +Atomic positions are relaxed at fixed cell volume. The vacancy formation energy +is calculated as +:math:`E_{\mathrm{vac}} = E_{\mathrm{defect}} - E_{\mathrm{perfect}} + E_{\mathrm{coh}}`, +where :math:`E_{\mathrm{coh}}` is the cohesive energy per atom. The percentage +error relative to the DFT reference value (2.02 eV) is reported. + + +Bain path +^^^^^^^^^ + +7. BCC-FCC energy difference error (meV) + +The Bain path maps the continuous tetragonal distortion from BCC +(:math:`c/a = 1`) to FCC (:math:`c/a = \sqrt{2}`). For each of 65 target +:math:`c/a` ratios between 0.72 and 2.0, a tetragonally distorted cell is +created and its volume is relaxed isotropically (uniform scaling preserving the +:math:`c/a` ratio). The absolute error in the BCC-FCC energy difference +relative to the DFT reference (83.5 meV/atom) is reported. + + +Surface energies +^^^^^^^^^^^^^^^^ + +8. Surface energy MAE (J/m²) + +Surface energies are computed for the (100), (110), (111), and (112) cleavage +planes. For each surface, a slab is created with vacuum and the surface energy +is calculated as +:math:`\gamma = (E_{\mathrm{slab}} - E_{\mathrm{bulk}}) / 2A`. +Atomic positions are relaxed at fixed cell shape. The mean absolute error +across all four surfaces, relative to DFT reference values +(:math:`\gamma_{100}` = 2.41, :math:`\gamma_{110}` = 2.37, +:math:`\gamma_{111}` = 2.58, :math:`\gamma_{112}` = 2.48 J/m²), is reported. + + +Stacking fault energies +^^^^^^^^^^^^^^^^^^^^^^^ + +9. Max SFE :math:`\{110\}\langle111\rangle` error (%) + +The generalised stacking fault energy (GSFE) curve for the +:math:`\{110\}\langle111\rangle` slip system is computed by incrementally +displacing the upper half of a crystallographically oriented supercell along +the slip direction. The displacement covers one full Burgers vector +(:math:`b = a\sqrt{3}/2`) in 16 steps. Atoms are constrained to relax only +perpendicular to the fault plane. The percentage error in the maximum +(unstable) SFE relative to the DFT reference (0.75 J/m²) is reported. + +10. Max SFE :math:`\{112\}\langle111\rangle` error (%) + +Same as (9), for the :math:`\{112\}\langle111\rangle` slip system. +Reference: 1.12 J/m². + + +Traction-separation curves +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +11. Max traction (100) error (%) + +A traction-separation curve is computed for the (100) cleavage plane by +incrementally separating crystal halves in 0.05 Å steps up to 5.0 Å, without +atomic relaxation, and measuring forces at each step. The traction (tensile +stress) is obtained from the sum of z-forces on the upper region divided by +the cross-sectional area. The percentage error in the maximum traction relative +to the DFT reference (35.0 GPa) is reported. + +12. Max traction (110) error (%) + +Same as (11), for the (110) cleavage plane. Reference: 30.0 GPa. + + +Computational cost +------------------ + +Medium: tests are likely to take minutes to run on GPU, or hours on CPU for each model. +The benchmark is marked as slow and excluded from default test runs. + + +Data availability +----------------- + +Input structures: + +* All structures are generated programmatically using ASE. BCC iron unit cells and + supercells are constructed from the equilibrium lattice parameter obtained via EOS + fitting. + +Reference data: + +* DFT (PBE) reference values from: + + * Zhang, L., Csányi, G., van der Giessen, E., & Maresca, F. (2023). + "Efficiency, Accuracy, and Transferability of Machine Learning Potentials: + Application to Dislocations and Cracks in Iron." + `arXiv:2307.10072 `_ diff --git a/docs/source/user_guide/benchmarks/physicality.rst b/docs/source/user_guide/benchmarks/physicality.rst index 0d5b55d25..7d392a6e7 100644 --- a/docs/source/user_guide/benchmarks/physicality.rst +++ b/docs/source/user_guide/benchmarks/physicality.rst @@ -135,3 +135,8 @@ Data availability ----------------- None required; diatomics are generated in ASE. + +.. toctree:: + :maxdepth: 2 + + iron_properties