#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::constants::MU_0;
pub const J_EB_IRMN_CO: f64 = 0.12e-3;
pub const J_EB_COO_CO: f64 = 0.20e-3;
pub const J_EB_FEMN_NIFE: f64 = 0.15e-3;
pub const J_EB_BFCO_COFE: f64 = 0.40e-3;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ExchangeBias {
pub j_eb: f64,
pub m_fm: f64,
pub t_fm: f64,
pub k_fm: f64,
pub t_b: f64,
pub t_n: f64,
}
impl ExchangeBias {
pub fn new(j_eb: f64, m_fm: f64, t_fm: f64, k_fm: f64, t_b: f64, t_n: f64) -> Self {
Self {
j_eb,
m_fm,
t_fm,
k_fm,
t_b,
t_n,
}
}
pub fn irmn_co(t_co_nm: f64) -> Self {
Self {
j_eb: J_EB_IRMN_CO,
m_fm: 1.4e6, t_fm: t_co_nm * 1e-9,
k_fm: 4.5e5, t_b: 500.0, t_n: 690.0, }
}
pub fn coo_co() -> Self {
Self {
j_eb: J_EB_COO_CO,
m_fm: 1.4e6, t_fm: 5e-9, k_fm: 4.5e5, t_b: 200.0, t_n: 291.0, }
}
pub fn femn_nife() -> Self {
Self {
j_eb: J_EB_FEMN_NIFE,
m_fm: 8.0e5, t_fm: 8e-9, k_fm: 2.5e3, t_b: 430.0, t_n: 490.0, }
}
pub fn loop_shift_field(&self) -> f64 {
-self.j_eb / (MU_0 * self.m_fm * self.t_fm)
}
pub fn coercive_enhancement(&self) -> f64 {
self.j_eb.abs() / (MU_0 * self.m_fm * self.t_fm)
}
fn anisotropy_field(&self) -> f64 {
2.0 * self.k_fm / (MU_0 * self.m_fm)
}
pub fn training_field(&self, n: u32) -> f64 {
assert!(n >= 1, "cycle number must be ≥ 1");
let h_eb_1 = self.loop_shift_field();
let h_eb_inf = 0.80 * h_eb_1;
h_eb_inf + (h_eb_1 - h_eb_inf) / (n as f64).sqrt()
}
pub fn loop_shift_at_temperature(&self, temperature: f64) -> f64 {
if temperature >= self.t_b {
return 0.0;
}
let t_reduced = temperature / self.t_b;
let h_eb_0 = self.loop_shift_field();
h_eb_0 * (1.0 - t_reduced).powf(1.5)
}
pub fn compute_hysteresis_loop(&self, h_max: f64, n_steps: usize) -> Vec<(f64, f64)> {
assert!(n_steps >= 2, "n_steps must be ≥ 2");
let h_k = self.anisotropy_field();
let h_eb_field = self.loop_shift_field();
let h_sw_desc = -(h_k + h_eb_field.abs());
let h_sw_asc = h_k - h_eb_field.abs();
let mut loop_data: Vec<(f64, f64)> = Vec::with_capacity(2 * n_steps);
let mut m_desc = 1.0_f64; for i in 0..n_steps {
let h = h_max - (2.0 * h_max / (n_steps - 1) as f64) * i as f64;
if h < h_sw_desc && m_desc > 0.0 {
m_desc = -1.0; }
loop_data.push((h, m_desc));
}
let mut m_asc = -1.0_f64; for i in 0..n_steps {
let h = -h_max + (2.0 * h_max / (n_steps - 1) as f64) * i as f64;
if h > h_sw_asc && m_asc < 0.0 {
m_asc = 1.0; }
loop_data.push((h, m_asc));
}
loop_data
}
pub fn extract_loop_parameters(&self, loop_data: &[(f64, f64)]) -> (f64, f64) {
let n = loop_data.len();
if n < 4 {
return (0.0, 0.0);
}
let half = n / 2;
let descending = &loop_data[..half];
let ascending = &loop_data[half..];
let h_c_desc = Self::zero_crossing_field(descending);
let h_c_asc = Self::zero_crossing_field(ascending);
let h_eb = 0.5 * (h_c_asc + h_c_desc);
let h_c = 0.5 * (h_c_asc.abs() + h_c_desc.abs());
(h_eb, h_c)
}
fn zero_crossing_field(branch: &[(f64, f64)]) -> f64 {
for window in branch.windows(2) {
let (h0, m0) = window[0];
let (h1, m1) = window[1];
if m0 * m1 <= 0.0 && (m1 - m0).abs() > 1e-30 {
let t = -m0 / (m1 - m0);
return h0 + t * (h1 - h0);
}
}
0.5 * (branch.first().map(|p| p.0).unwrap_or(0.0)
+ branch.last().map(|p| p.0).unwrap_or(0.0))
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct LoopShiftResult {
pub h_eb: f64,
pub h_c: f64,
pub relative_loop_shift: f64,
}
impl LoopShiftResult {
pub fn from_exchange_bias(eb: &ExchangeBias) -> Self {
let h_eb = eb.loop_shift_field();
let h_c = eb.anisotropy_field();
let relative_loop_shift = if h_c.abs() > 1e-30 { h_eb / h_c } else { 0.0 };
Self {
h_eb,
h_c,
relative_loop_shift,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOL: f64 = 1e-10;
#[test]
fn test_loop_shift_field_sign() {
let eb = ExchangeBias::irmn_co(5.0);
let h_eb = eb.loop_shift_field();
assert!(
h_eb < 0.0,
"H_EB should be negative for positive J_EB, got {h_eb}"
);
}
#[test]
fn test_loop_shift_field_magnitude() {
let eb = ExchangeBias::irmn_co(5.0);
let expected = -J_EB_IRMN_CO / (MU_0 * 1.4e6 * 5e-9);
let got = eb.loop_shift_field();
assert!(
(got - expected).abs() < TOL * expected.abs(),
"H_EB magnitude mismatch: got {got}, expected {expected}"
);
}
#[test]
fn test_loop_shift_scales_inversely_with_thickness() {
let eb5 = ExchangeBias::irmn_co(5.0);
let eb10 = ExchangeBias::irmn_co(10.0);
let ratio = eb5.loop_shift_field() / eb10.loop_shift_field();
assert!(
(ratio - 2.0).abs() < 1e-6,
"H_EB should scale as 1/t_FM; ratio = {ratio}"
);
}
#[test]
fn test_coercive_enhancement_positive() {
let eb = ExchangeBias::coo_co();
assert!(eb.coercive_enhancement() > 0.0);
}
#[test]
fn test_coercive_enhancement_equals_abs_h_eb() {
let eb = ExchangeBias::femn_nife();
let delta_hc = eb.coercive_enhancement();
let h_eb_abs = eb.loop_shift_field().abs();
assert!(
(delta_hc - h_eb_abs).abs() < TOL * h_eb_abs,
"coercive enhancement should equal |H_EB|: Δhc={delta_hc}, |H_EB|={h_eb_abs}"
);
}
#[test]
fn test_training_first_cycle() {
let eb = ExchangeBias::irmn_co(5.0);
let h_eb_1 = eb.loop_shift_field();
let h_train_1 = eb.training_field(1);
assert!(
(h_train_1 - h_eb_1).abs() < TOL * h_eb_1.abs(),
"training_field(1) should equal loop_shift_field(); got {h_train_1} vs {h_eb_1}"
);
}
#[test]
fn test_training_monotone_decay() {
let eb = ExchangeBias::irmn_co(5.0);
let vals: Vec<f64> = (1u32..=10).map(|n| eb.training_field(n).abs()).collect();
for w in vals.windows(2) {
assert!(
w[1] <= w[0] + 1e-30,
"training effect should be monotonically decreasing: {vals:?}"
);
}
}
#[test]
fn test_training_approaches_asymptote() {
let eb = ExchangeBias::irmn_co(5.0);
let h_eb_1 = eb.loop_shift_field();
let h_eb_inf = 0.80 * h_eb_1;
let h_train_1000 = eb.training_field(1000);
assert!(
(h_train_1000 - h_eb_inf).abs() < 0.01 * h_eb_inf.abs(),
"training_field(1000) should be ≈ 0.80×H_EB_1; got {h_train_1000} vs {h_eb_inf}"
);
}
#[test]
fn test_temperature_zero() {
let eb = ExchangeBias::irmn_co(5.0);
let h_0 = eb.loop_shift_field();
let h_at_0 = eb.loop_shift_at_temperature(0.0);
assert!(
(h_at_0 - h_0).abs() < TOL * h_0.abs(),
"T=0 should give full H_EB(0); got {h_at_0} vs {h_0}"
);
}
#[test]
fn test_temperature_above_blocking() {
let eb = ExchangeBias::irmn_co(5.0);
assert_eq!(eb.loop_shift_at_temperature(eb.t_b), 0.0);
assert_eq!(eb.loop_shift_at_temperature(eb.t_b + 100.0), 0.0);
}
#[test]
fn test_temperature_monotone_decrease() {
let eb = ExchangeBias::irmn_co(5.0);
let temps: Vec<f64> = (0..10).map(|i| i as f64 * eb.t_b / 10.0).collect();
let h_vals: Vec<f64> = temps
.iter()
.map(|&t| eb.loop_shift_at_temperature(t).abs())
.collect();
for w in h_vals.windows(2) {
assert!(
w[1] <= w[0] + 1e-30,
"|H_EB(T)| should decrease with T: {h_vals:?}"
);
}
}
#[test]
fn test_temperature_power_law_exponent() {
let eb = ExchangeBias::irmn_co(5.0);
let h_0 = eb.loop_shift_field();
let h_half = eb.loop_shift_at_temperature(eb.t_b / 2.0);
let expected = h_0 * (0.5_f64).powf(1.5);
assert!(
(h_half - expected).abs() < 1e-10 * expected.abs(),
"T=T_B/2 power law: got {h_half}, expected {expected}"
);
}
#[test]
fn test_hysteresis_loop_length() {
let eb = ExchangeBias::irmn_co(5.0);
let h_max = 3.0e6_f64;
let n_steps = 200_usize;
let loop_data = eb.compute_hysteresis_loop(h_max, n_steps);
assert_eq!(loop_data.len(), 2 * n_steps);
}
#[test]
fn test_hysteresis_loop_saturation_ends() {
let eb = ExchangeBias::irmn_co(5.0);
let h_max = 5.0e6_f64;
let loop_data = eb.compute_hysteresis_loop(h_max, 500);
assert!(
(loop_data[0].1 - 1.0).abs() < 1e-9,
"First point m should be +1; got {}",
loop_data[0].1
);
let asc_start = loop_data[500].1;
assert!(
(asc_start + 1.0).abs() < 1e-9,
"Ascending start m should be -1; got {asc_start}"
);
}
#[test]
fn test_hysteresis_loop_shift_direction() {
let eb = ExchangeBias::irmn_co(5.0);
let h_max = 5.0e6_f64;
let loop_data = eb.compute_hysteresis_loop(h_max, 1000);
let (h_eb_extracted, _h_c) = eb.extract_loop_parameters(&loop_data);
assert!(
h_eb_extracted < 0.0,
"Extracted H_EB should be negative (left shift); got {h_eb_extracted}"
);
}
#[test]
fn test_hysteresis_extract_consistency_with_analytical() {
let eb = ExchangeBias::irmn_co(5.0);
let h_max = 5.0e6_f64;
let n_steps = 2000_usize;
let loop_data = eb.compute_hysteresis_loop(h_max, n_steps);
let (h_eb_extracted, _h_c) = eb.extract_loop_parameters(&loop_data);
let h_eb_analytical = eb.loop_shift_field();
let rel_err = (h_eb_extracted - h_eb_analytical).abs() / h_eb_analytical.abs();
assert!(
rel_err < 0.15,
"Extracted H_EB should match analytical to 15% (discretisation bound); rel_err = {rel_err:.4}"
);
}
#[test]
fn test_loop_shift_result_from_exchange_bias() {
let eb = ExchangeBias::irmn_co(5.0);
let result = LoopShiftResult::from_exchange_bias(&eb);
assert!(result.h_eb < 0.0, "H_EB should be negative");
assert!(result.h_c > 0.0, "H_c should be positive");
assert!(
result.relative_loop_shift < 0.0,
"relative loop shift should be negative"
);
assert!(
result.relative_loop_shift.abs() < 1.0,
"relative loop shift magnitude should be < 1; got {}",
result.relative_loop_shift
);
}
#[test]
fn test_loop_shift_result_relative_shift_formula() {
let eb = ExchangeBias::irmn_co(5.0);
let result = LoopShiftResult::from_exchange_bias(&eb);
let expected_ratio = eb.loop_shift_field() / eb.anisotropy_field();
assert!(
(result.relative_loop_shift - expected_ratio).abs() < 1e-12,
"relative_loop_shift mismatch: got {}, expected {expected_ratio}",
result.relative_loop_shift
);
}
#[test]
fn test_presets_physical_range() {
for eb in [
ExchangeBias::coo_co(),
ExchangeBias::femn_nife(),
ExchangeBias::irmn_co(5.0),
] {
let h_eb_abs = eb.loop_shift_field().abs();
assert!(
h_eb_abs > 1e3 && h_eb_abs < 1e7,
"H_EB = {h_eb_abs} A/m is outside plausible range [1 kA/m, 10 MA/m]"
);
}
}
#[test]
fn test_neel_above_blocking_for_all_presets() {
for eb in [
ExchangeBias::coo_co(),
ExchangeBias::femn_nife(),
ExchangeBias::irmn_co(5.0),
] {
assert!(
eb.t_n > eb.t_b,
"T_N must exceed T_B: T_N={}, T_B={}",
eb.t_n,
eb.t_b
);
}
}
}