use crate::constants::GAMMA;
use crate::error::{Error, Result};
use crate::vector3::Vector3;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LlbMaterial {
pub curie_temp: f64,
pub alpha: f64,
pub spin_s: f64,
pub ms_0: f64,
}
impl LlbMaterial {
pub fn iron() -> Self {
Self {
curie_temp: 1043.0,
alpha: 0.01,
spin_s: 1.0,
ms_0: 1.71e6,
}
}
pub fn nickel() -> Self {
Self {
curie_temp: 631.0,
alpha: 0.064,
spin_s: 0.3,
ms_0: 4.84e5,
}
}
pub fn cofeb() -> Self {
Self {
curie_temp: 1000.0,
alpha: 0.005,
spin_s: 0.5,
ms_0: 1.2e6,
}
}
fn brillouin(j: f64, x: f64) -> f64 {
let two_j = 2.0 * j;
let arg_hi = (two_j + 1.0) * x / two_j; let arg_lo = x / two_j;
let coth = |u: f64| -> f64 {
if u.abs() < 1e-10 {
1.0 / u.signum().max(1e-300) * 1e300 } else {
let e2u = (2.0 * u).exp();
(e2u + 1.0) / (e2u - 1.0)
}
};
if x.abs() < 1e-8 {
(j + 1.0) / (3.0 * j) * x
} else {
let term_hi = ((two_j + 1.0) / two_j) * coth(arg_hi);
let term_lo = (1.0 / two_j) * coth(arg_lo);
term_hi - term_lo
}
}
pub fn equilibrium_magnetization(&self, temperature: f64) -> f64 {
if temperature >= self.curie_temp || temperature <= 0.0 {
return 0.0;
}
let j = self.spin_s;
let tc = self.curie_temp;
let t = temperature;
let coeff = 3.0 * tc / (t * (2.0 * j + 1.0) / (2.0 * j) * 2.0 * j);
let mut m = 0.5_f64;
for _ in 0..60 {
let x = coeff * m;
let m_new = Self::brillouin(j, x);
if (m_new - m).abs() < 1e-12 {
return m_new.max(0.0);
}
m = m_new;
}
m.max(0.0)
}
pub fn alpha_parallel(&self, temperature: f64) -> f64 {
let t_ratio = temperature / self.curie_temp;
if temperature < self.curie_temp {
self.alpha * (2.0 / 5.0 + 3.0 * t_ratio / 5.0)
} else {
2.0 * self.alpha * t_ratio / 5.0
}
}
pub fn alpha_perp(&self, temperature: f64) -> f64 {
let t_ratio = temperature / self.curie_temp;
if temperature < self.curie_temp {
(self.alpha * t_ratio).max(1e-10)
} else {
2.0 * self.alpha * t_ratio / 5.0
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LlbResult {
pub trajectory: Vec<Vector3<f64>>,
pub m_magnitude: Vec<f64>,
pub time: Vec<f64>,
pub equilibrium_m: f64,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LlbSolver {
pub material: LlbMaterial,
pub gamma: f64,
pub dt: f64,
pub temperature: f64,
pub h_ext: Vector3<f64>,
}
impl LlbSolver {
pub fn new(material: LlbMaterial, dt: f64, temperature: f64, h_ext: Vector3<f64>) -> Self {
Self {
material,
gamma: GAMMA,
dt,
temperature,
h_ext,
}
}
fn dm_dt(&self, m: &Vector3<f64>, h_eff: &Vector3<f64>, temp: f64) -> Result<Vector3<f64>> {
let m_e = self.material.equilibrium_magnetization(temp);
let alpha_par = self.material.alpha_parallel(temp);
let alpha_perp = self.material.alpha_perp(temp);
let m_sq = m.dot(m);
let precession = m.cross(h_eff) * self.gamma;
let m_cross_h = m.cross(h_eff);
let transverse_coeff = -self.gamma * alpha_perp / (1.0 + alpha_perp * alpha_perp);
let transverse = m.cross(&m_cross_h) * transverse_coeff;
let longitudinal = if m_e.abs() < 1e-15 {
*m * (-self.gamma * alpha_par)
} else {
*m * (-self.gamma * alpha_par * (1.0 - m_sq / (m_e * m_e)))
};
Ok(precession + transverse + longitudinal)
}
pub fn step(&self, m: &Vector3<f64>) -> Result<Vector3<f64>> {
let h_eff = &self.h_ext;
let temp = self.temperature;
let dt = self.dt;
let k1 = self.dm_dt(m, h_eff, temp)?;
let m2 = *m + k1 * (dt * 0.5);
let k2 = self.dm_dt(&m2, h_eff, temp)?;
let m3 = *m + k2 * (dt * 0.5);
let k3 = self.dm_dt(&m3, h_eff, temp)?;
let m4 = *m + k3 * dt;
let k4 = self.dm_dt(&m4, h_eff, temp)?;
let dm = (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (dt / 6.0);
let m_new = *m + dm;
if !m_new.x.is_finite() || !m_new.y.is_finite() || !m_new.z.is_finite() {
return Err(Error::NumericalError {
description:
"LLB step produced non-finite magnetization; reduce dt or check parameters"
.to_string(),
});
}
Ok(m_new)
}
pub fn run(
&self,
m0: Vector3<f64>,
num_steps: usize,
record_every: usize,
) -> Result<LlbResult> {
if record_every == 0 {
return Err(Error::InvalidParameter {
param: "record_every".to_string(),
reason: "must be at least 1".to_string(),
});
}
if num_steps == 0 {
return Err(Error::InvalidParameter {
param: "num_steps".to_string(),
reason: "must be at least 1".to_string(),
});
}
let capacity = num_steps / record_every + 1;
let mut trajectory: Vec<Vector3<f64>> = Vec::with_capacity(capacity);
let mut m_magnitude: Vec<f64> = Vec::with_capacity(capacity);
let mut time: Vec<f64> = Vec::with_capacity(capacity);
let equilibrium_m = self.material.equilibrium_magnetization(self.temperature);
trajectory.push(m0);
m_magnitude.push(m0.magnitude());
time.push(0.0);
let mut m = m0;
for step_idx in 0..num_steps {
m = self.step(&m)?;
let t_now = (step_idx as f64 + 1.0) * self.dt;
if (step_idx + 1) % record_every == 0 {
trajectory.push(m);
m_magnitude.push(m.magnitude());
time.push(t_now);
}
}
Ok(LlbResult {
trajectory,
m_magnitude,
time,
equilibrium_m,
})
}
}
#[cfg(test)]
mod tests {
use std::f64::consts::PI;
use super::*;
#[test]
fn test_brillouin_x_zero_limit() {
let j = 0.5_f64;
let val = LlbMaterial::brillouin(j, 1e-9);
assert!(val.abs() < 1e-6, "B_J(0.5, ~0) should be ≈ 0, got {val}");
let j2 = 1.0_f64;
let val2 = LlbMaterial::brillouin(j2, 1e-9);
assert!(val2.abs() < 1e-6, "B_J(1.0, ~0) should be ≈ 0, got {val2}");
}
#[test]
fn test_brillouin_x_large() {
let j = 0.5_f64;
let val = LlbMaterial::brillouin(j, 100.0);
assert!(
(val - 1.0).abs() < 1e-6,
"B_(1/2)(100) should be ≈ 1, got {val}"
);
let j2 = 2.0_f64;
let val2 = LlbMaterial::brillouin(j2, 100.0);
assert!(
(val2 - 1.0).abs() < 1e-6,
"B_2(100) should be ≈ 1, got {val2}"
);
}
#[test]
fn test_brillouin_j_half() {
let j = 0.5_f64;
let test_points = [0.1, 0.5, 1.0, 2.0, 5.0];
for &x in &test_points {
let b_val = LlbMaterial::brillouin(j, x);
let tanh_val = x.tanh();
assert!(
(b_val - tanh_val).abs() < 1e-10,
"B_(1/2)({x}) = {b_val}, tanh({x}) = {tanh_val}, diff = {}",
(b_val - tanh_val).abs()
);
}
}
#[test]
fn test_equilibrium_m_below_tc() {
let mat = LlbMaterial::iron();
let m_eq = mat.equilibrium_magnetization(300.0);
assert!(
m_eq > 0.5,
"Iron at 300 K should have m_e > 0.5, got {m_eq}"
);
}
#[test]
fn test_equilibrium_m_at_tc() {
let mat = LlbMaterial::iron();
let m_eq = mat.equilibrium_magnetization(mat.curie_temp);
assert!(m_eq.abs() < 1e-10, "m_e at T_C should be ≈ 0, got {m_eq}");
}
#[test]
fn test_equilibrium_m_above_tc() {
let mat = LlbMaterial::iron();
let m_eq = mat.equilibrium_magnetization(mat.curie_temp + 50.0);
assert_eq!(m_eq, 0.0, "m_e above T_C must be 0");
}
#[test]
fn test_alpha_parallel_below_tc() {
let mat = LlbMaterial::iron();
let t = 0.5 * mat.curie_temp; let alpha_par = mat.alpha_parallel(t);
let alpha_perp = mat.alpha_perp(t);
assert!(
alpha_par > alpha_perp,
"Below T_C: α_∥ ({alpha_par}) should > α_⊥ ({alpha_perp})"
);
}
#[test]
fn test_alpha_equal_above_tc() {
let mat = LlbMaterial::iron();
let t = mat.curie_temp * 1.2;
let alpha_par = mat.alpha_parallel(t);
let alpha_perp = mat.alpha_perp(t);
assert!(
(alpha_par - alpha_perp).abs() < 1e-15,
"Above T_C: α_∥ ({alpha_par}) should equal α_⊥ ({alpha_perp})"
);
}
#[test]
fn test_longitudinal_relaxation_below_tc() {
let mat = LlbMaterial::iron();
let temp = 300.0;
let m_e = mat.equilibrium_magnetization(temp);
let m0 = Vector3::new(0.0, 0.0, m_e * 0.5);
let h_ext = Vector3::new(0.0, 0.0, 1.0);
let solver = LlbSolver::new(mat.clone(), 1.0e-14, temp, h_ext);
let dm = solver.dm_dt(&m0, &h_ext, temp).unwrap();
assert!(dm.z.is_finite(), "dm_z/dt must be finite");
assert!(
dm.x.abs() < 1e-25,
"dm_x/dt should be ~0 when m || H, got {}",
dm.x
);
assert!(
dm.y.abs() < 1e-25,
"dm_y/dt should be ~0 when m || H, got {}",
dm.y
);
let alpha_par = mat.alpha_parallel(temp);
let m_sq = m0.z * m0.z;
let expected = -GAMMA * alpha_par * (1.0 - m_sq / (m_e * m_e)) * m0.z;
assert!(
(dm.z - expected).abs() < 1e-3,
"dm_z should match longitudinal formula; expected {expected:.6e}, got {:.6e}",
dm.z
);
}
#[test]
fn test_decay_above_tc() {
let mat = LlbMaterial::iron();
let temp = mat.curie_temp + 100.0; let m0 = Vector3::new(0.5, 0.0, 0.0);
let solver = LlbSolver::new(mat, 1.0e-14, temp, Vector3::zero());
let result = solver.run(m0, 500, 500).unwrap();
let m_final = result.m_magnitude.last().copied().unwrap_or(1.0);
let m_init = m0.magnitude();
assert!(
m_final < m_init,
"Above T_C: |m| should decrease; init={m_init}, final={m_final}"
);
}
#[test]
fn test_critical_slowing() {
let mat = LlbMaterial::iron();
let tc = mat.curie_temp;
let dt = 1.0e-14;
let n = 50;
let m0 = Vector3::new(0.1, 0.0, 0.0);
let solver_near = LlbSolver::new(mat.clone(), dt, 0.9 * tc, Vector3::zero());
let solver_far = LlbSolver::new(mat.clone(), dt, 0.5 * tc, Vector3::zero());
let res_near = solver_near.run(m0, n, n).unwrap();
let res_far = solver_far.run(m0, n, n).unwrap();
let dm_near = (res_near.m_magnitude.last().copied().unwrap_or(0.0) - m0.magnitude()).abs();
let dm_far = (res_far.m_magnitude.last().copied().unwrap_or(0.0) - m0.magnitude()).abs();
assert!(dm_near.is_finite(), "Near-T_C trajectory should be finite");
assert!(
dm_far.is_finite(),
"Far-from-T_C trajectory should be finite"
);
}
#[test]
fn test_material_iron_preset() {
let mat = LlbMaterial::iron();
assert_eq!(
mat.curie_temp, 1043.0,
"Iron Curie temperature should be 1043 K"
);
}
#[test]
fn test_material_nickel_preset() {
let mat = LlbMaterial::nickel();
assert_eq!(
mat.curie_temp, 631.0,
"Nickel Curie temperature should be 631 K"
);
}
#[test]
fn test_material_cofeb_preset() {
let mat = LlbMaterial::cofeb();
assert!(
(mat.curie_temp - 1000.0).abs() < 1.0,
"CoFeB Curie temperature should be ≈ 1000 K, got {}",
mat.curie_temp
);
}
#[test]
fn test_run_produces_trajectory() {
let mat = LlbMaterial::nickel();
let solver = LlbSolver::new(mat, 1.0e-14, 300.0, Vector3::new(0.0, 0.0, 0.1));
let m0 = Vector3::new(0.7, 0.0, 0.0);
let result = solver.run(m0, 100, 10).unwrap();
assert!(
!result.trajectory.is_empty(),
"Trajectory should not be empty"
);
}
#[test]
fn test_result_lengths_consistent() {
let mat = LlbMaterial::cofeb();
let solver = LlbSolver::new(mat, 1.0e-14, 500.0, Vector3::zero());
let m0 = Vector3::new(0.9, 0.0, 0.1);
let result = solver.run(m0, 50, 5).unwrap();
let n_traj = result.trajectory.len();
let n_mag = result.m_magnitude.len();
let n_time = result.time.len();
assert_eq!(
n_traj, n_mag,
"trajectory.len()={n_traj} must equal m_magnitude.len()={n_mag}"
);
assert_eq!(
n_traj, n_time,
"trajectory.len()={n_traj} must equal time.len()={n_time}"
);
}
#[test]
fn test_precession_zero_damping() {
let mat = LlbMaterial {
curie_temp: 1043.0,
alpha: 0.0,
spin_s: 1.0,
ms_0: 1.71e6,
};
let temp = 300.0;
let m_e = mat.equilibrium_magnetization(temp);
let m0 = Vector3::new(m_e, 0.0, 0.0);
let solver = LlbSolver::new(mat, 1.0e-14, temp, Vector3::new(0.0, 0.0, 1.0));
let result = solver.run(m0, 100, 100).unwrap();
let m_final = result.m_magnitude.last().copied().unwrap_or(0.0);
let m_init = m0.magnitude();
assert!(
(m_final - m_init).abs() < 1e-6,
"Zero-damping at m_e: |m| should be conserved; init={m_init}, final={m_final}"
);
}
#[test]
fn test_equilibrium_m_nonnegative() {
let mat = LlbMaterial::iron();
let temperatures = [0.0, 100.0, 300.0, 500.0, 800.0, 1043.0, 1100.0, 2000.0];
for &t in &temperatures {
let m_eq = mat.equilibrium_magnetization(t);
assert!(m_eq >= 0.0, "m_e({t} K) = {m_eq} must be non-negative");
}
}
#[allow(dead_code)]
fn _use_pi() -> f64 {
PI
}
}