use crate::error::{Error, Result};
use crate::math::{CMatrix, Complex};
use crate::texture::hopfion::{Hopfion, HopfionEnergy, HopfionEnergyParams};
use crate::vector3::Vector3;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum CollectiveMode {
Radius,
TranslationX,
TranslationY,
TranslationZ,
}
impl CollectiveMode {
pub const STANDARD_BASIS: [CollectiveMode; 4] = [
CollectiveMode::Radius,
CollectiveMode::TranslationX,
CollectiveMode::TranslationY,
CollectiveMode::TranslationZ,
];
pub fn label(&self) -> &'static str {
match self {
CollectiveMode::Radius => "radius (breathing)",
CollectiveMode::TranslationX => "translation x",
CollectiveMode::TranslationY => "translation y",
CollectiveMode::TranslationZ => "translation z",
}
}
pub fn is_translation(&self) -> bool {
matches!(
self,
CollectiveMode::TranslationX
| CollectiveMode::TranslationY
| CollectiveMode::TranslationZ
)
}
fn displacement(&self, amount: f64) -> (f64, Vector3<f64>) {
match self {
CollectiveMode::Radius => (amount, Vector3::zero()),
CollectiveMode::TranslationX => (0.0, Vector3::new(amount, 0.0, 0.0)),
CollectiveMode::TranslationY => (0.0, Vector3::new(0.0, amount, 0.0)),
CollectiveMode::TranslationZ => (0.0, Vector3::new(0.0, 0.0, amount)),
}
}
fn step_size(&self, steps: &CollectiveStepSizes) -> f64 {
match self {
CollectiveMode::Radius => steps.radius_step,
CollectiveMode::TranslationX
| CollectiveMode::TranslationY
| CollectiveMode::TranslationZ => steps.translation_step,
}
}
}
const DEFAULT_RADIUS_STEP_FRACTION: f64 = 0.03;
const DEFAULT_TRANSLATION_STEP_CELL_FRACTION: f64 = 0.5;
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CollectiveStepSizes {
pub radius_step: f64,
pub translation_step: f64,
}
impl CollectiveStepSizes {
pub fn new(radius_step: f64, translation_step: f64) -> Result<Self> {
let out = Self {
radius_step,
translation_step,
};
out.validate()?;
Ok(out)
}
pub fn scaled_to(radius: f64, cell_size: f64) -> Result<Self> {
if radius <= 0.0 || !radius.is_finite() {
return Err(Error::InvalidParameter {
param: "radius".to_string(),
reason: "Must be positive and finite".to_string(),
});
}
if cell_size <= 0.0 || !cell_size.is_finite() {
return Err(Error::InvalidParameter {
param: "cell_size".to_string(),
reason: "Must be positive and finite".to_string(),
});
}
Self::new(
DEFAULT_RADIUS_STEP_FRACTION * radius,
DEFAULT_TRANSLATION_STEP_CELL_FRACTION * cell_size,
)
}
pub fn for_hopfion(hopfion: &Hopfion) -> Result<Self> {
Self::scaled_to(hopfion.radius, hopfion.cell_size)
}
pub fn validate(&self) -> Result<()> {
if self.radius_step <= 0.0 || !self.radius_step.is_finite() {
return Err(Error::InvalidParameter {
param: "radius_step".to_string(),
reason: "Must be positive and finite".to_string(),
});
}
if self.translation_step <= 0.0 || !self.translation_step.is_finite() {
return Err(Error::InvalidParameter {
param: "translation_step".to_string(),
reason: "Must be positive and finite".to_string(),
});
}
Ok(())
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct EigenmodeSpectrum {
pub eigenvalues: Vec<f64>,
pub eigenvectors: Vec<Vec<f64>>,
pub modes: Vec<CollectiveMode>,
}
impl EigenmodeSpectrum {
pub fn unstable_mode_count(&self, tol: f64) -> usize {
let tol = tol.abs();
self.eigenvalues.iter().filter(|&&ev| ev < -tol).count()
}
pub fn is_linearly_stable(&self, tol: f64) -> bool {
self.unstable_mode_count(tol) == 0
}
pub fn soft_mode_indices(&self, tol: f64) -> Vec<usize> {
let tol = tol.abs();
self.eigenvalues
.iter()
.enumerate()
.filter(|(_, &ev)| ev.abs() < tol)
.map(|(i, _)| i)
.collect()
}
pub fn max_abs_eigenvalue(&self) -> f64 {
self.eigenvalues
.iter()
.fold(0.0_f64, |acc, &ev| acc.max(ev.abs()))
}
pub fn dominant_mode(&self, eigenvector_index: usize) -> Option<CollectiveMode> {
let v = self.eigenvectors.get(eigenvector_index)?;
let mut best_idx = 0usize;
let mut best_val = -1.0_f64;
for (i, &val) in v.iter().enumerate() {
if val.abs() > best_val {
best_val = val.abs();
best_idx = i;
}
}
self.modes.get(best_idx).copied()
}
}
pub struct HopfionEigenmodeStability;
impl HopfionEigenmodeStability {
pub fn analyze_standard_modes(
hopfion: &Hopfion,
energy_params: &HopfionEnergyParams,
steps: &CollectiveStepSizes,
) -> Result<EigenmodeSpectrum> {
Self::analyze(
hopfion,
energy_params,
steps,
&CollectiveMode::STANDARD_BASIS,
)
}
pub fn analyze(
hopfion: &Hopfion,
energy_params: &HopfionEnergyParams,
steps: &CollectiveStepSizes,
modes: &[CollectiveMode],
) -> Result<EigenmodeSpectrum> {
energy_params.validate()?;
steps.validate()?;
if modes.is_empty() {
return Err(Error::InvalidParameter {
param: "modes".to_string(),
reason: "At least one collective coordinate is required".to_string(),
});
}
if modes.len() > CMatrix::MAX_DIM {
return Err(Error::InvalidParameter {
param: "modes".to_string(),
reason: format!(
"{} collective modes exceed CMatrix::MAX_DIM ({})",
modes.len(),
CMatrix::MAX_DIM
),
});
}
let grid_size = hopfion.grid_size;
let radius = hopfion.radius;
let hopf_charge = hopfion.hopf_charge;
let profile = hopfion.profile;
if radius <= 0.0 || !radius.is_finite() {
return Err(Error::InvalidParameter {
param: "hopfion.radius".to_string(),
reason: "Eigenmode analysis requires a positive, finite radius".to_string(),
});
}
let n = modes.len();
let energy_at = |d_radius: f64, offset: Vector3<f64>| -> Result<f64> {
let r = radius + d_radius;
if r <= 0.0 || !r.is_finite() {
return Err(Error::InvalidParameter {
param: "radius_step".to_string(),
reason: format!(
"Perturbed radius {r:e} is non-positive or non-finite; \
reduce CollectiveStepSizes::radius_step"
),
});
}
let cfg = Hopfion::with_profile_and_offset(grid_size, r, hopf_charge, profile, offset)?;
HopfionEnergy::total_energy(&cfg, energy_params)
};
let e0 = energy_at(0.0, Vector3::zero())?;
let mut hess = vec![0.0_f64; n * n];
let mut e_plus = vec![0.0_f64; n];
let mut e_minus = vec![0.0_f64; n];
for (k, mode) in modes.iter().enumerate() {
let h_k = mode.step_size(steps);
let (dr_p, off_p) = mode.displacement(h_k);
let (dr_m, off_m) = mode.displacement(-h_k);
e_plus[k] = energy_at(dr_p, off_p)?;
e_minus[k] = energy_at(dr_m, off_m)?;
hess[k * n + k] = (e_plus[k] - 2.0 * e0 + e_minus[k]) / (h_k * h_k);
}
for i in 0..n {
let h_i = modes[i].step_size(steps);
for j in (i + 1)..n {
let h_j = modes[j].step_size(steps);
let (dri_p, offi_p) = modes[i].displacement(h_i);
let (dri_m, offi_m) = modes[i].displacement(-h_i);
let (drj_p, offj_p) = modes[j].displacement(h_j);
let (drj_m, offj_m) = modes[j].displacement(-h_j);
let e_pp = energy_at(dri_p + drj_p, offi_p + offj_p)?;
let e_pm = energy_at(dri_p + drj_m, offi_p + offj_m)?;
let e_mp = energy_at(dri_m + drj_p, offi_m + offj_p)?;
let e_mm = energy_at(dri_m + drj_m, offi_m + offj_m)?;
let h_ij = (e_pp - e_pm - e_mp + e_mm) / (4.0 * h_i * h_j);
hess[i * n + j] = h_ij;
hess[j * n + i] = h_ij; }
}
let mut rows: Vec<Vec<Complex>> = Vec::with_capacity(n);
for i in 0..n {
let mut row = Vec::with_capacity(n);
for j in 0..n {
row.push(Complex::from_real(hess[i * n + j]));
}
rows.push(row);
}
let hessian_matrix = CMatrix::from_rows(rows)?;
let (eigenvalues, eigenvectors_mat) = hessian_matrix.hermitian_eigendecomposition()?;
let mut eigenvectors = Vec::with_capacity(n);
for k in 0..n {
let mut v = Vec::with_capacity(n);
for i in 0..n {
v.push(eigenvectors_mat.get(i, k).re);
}
eigenvectors.push(v);
}
Ok(EigenmodeSpectrum {
eigenvalues,
eigenvectors,
modes: modes.to_vec(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::texture::hopfion::HopfionStability;
use crate::texture::hopfion::ProfileFunction;
fn well_forming_energy_params() -> HopfionEnergyParams {
HopfionEnergyParams {
exchange_stiffness: 1.0e-11,
dmi_constant: 3.0e-3,
external_field: Vector3::new(0.0, 0.0, -5.0),
saturation_magnetization: 5.8e5,
frustrated_exchange_j2: 0.0,
}
}
const TEST_GRID: (usize, usize, usize) = (10, 10, 10);
fn wide_log_radii() -> Vec<f64> {
(0..140)
.map(|i| 1.0e-9 * 10f64.powf(i as f64 * 0.05))
.collect()
}
fn find_boundaries() -> (f64, f64, f64) {
let params = well_forming_energy_params();
let radii = wide_log_radii();
let landscape = HopfionStability::energy_vs_radius(TEST_GRID, &radii, 1, ¶ms)
.expect("energy_vs_radius should succeed for valid parameters");
HopfionStability::find_stability_boundaries(&landscape)
.expect("well_forming_energy_params must produce a genuine local minimum")
}
#[test]
fn test_stability_boundaries_are_genuinely_found() {
let (r_min, r_eq, r_max) = find_boundaries();
assert!(r_min > 0.0 && r_min.is_finite());
assert!(
r_eq > r_min,
"equilibrium radius must exceed the collapse boundary"
);
assert!(
r_max >= r_eq,
"expansion boundary must not be below equilibrium"
);
}
fn find_mode_dominated_by(spectrum: &EigenmodeSpectrum, target: CollectiveMode) -> usize {
(0..spectrum.eigenvalues.len())
.find(|&k| spectrum.dominant_mode(k) == Some(target))
.unwrap_or_else(|| panic!("no normal mode dominated by {target:?} found in spectrum"))
}
#[test]
fn test_eigenmode_spectrum_stable_at_equilibrium_radius() {
let (_, r_eq, _) = find_boundaries();
let params = well_forming_energy_params();
let hopfion = Hopfion::with_profile(TEST_GRID, r_eq, 1, ProfileFunction::Linear)
.expect("failed to build equilibrium-radius hopfion");
let steps = CollectiveStepSizes::for_hopfion(&hopfion).expect("failed to build step sizes");
let spectrum = HopfionEigenmodeStability::analyze_standard_modes(&hopfion, ¶ms, &steps)
.expect("eigenmode analysis should succeed at a genuine equilibrium");
assert_eq!(spectrum.eigenvalues.len(), 4);
let scale = spectrum.max_abs_eigenvalue().max(1e-300);
let tol = 1e-3 * scale;
assert!(
spectrum.is_linearly_stable(tol),
"expected no significantly-unstable mode at the equilibrium radius, got {:?}",
spectrum.eigenvalues
);
let radius_idx = find_mode_dominated_by(&spectrum, CollectiveMode::Radius);
assert!(
spectrum.eigenvalues[radius_idx] > 0.0,
"radius/breathing mode must be a stable, positive-curvature mode at equilibrium, got {}",
spectrum.eigenvalues[radius_idx]
);
}
#[test]
fn test_eigenmode_spectrum_unstable_past_collapse_boundary() {
let (r_min, _, _) = find_boundaries();
let params = well_forming_energy_params();
let r_collapse = 0.4 * r_min;
let hopfion = Hopfion::with_profile(TEST_GRID, r_collapse, 1, ProfileFunction::Linear)
.expect("failed to build sub-collapse-boundary hopfion");
let steps = CollectiveStepSizes::for_hopfion(&hopfion).expect("failed to build step sizes");
let spectrum = HopfionEigenmodeStability::analyze_standard_modes(&hopfion, ¶ms, &steps)
.expect("eigenmode analysis should succeed past the collapse boundary");
assert!(
spectrum.eigenvalues.iter().any(|&ev| ev < 0.0),
"expected at least one negative (unstable) eigenvalue past the collapse \
boundary r_min={:e} (evaluated at r={:e}), got {:?}",
r_min,
r_collapse,
spectrum.eigenvalues
);
let radius_idx = find_mode_dominated_by(&spectrum, CollectiveMode::Radius);
assert!(
spectrum.eigenvalues[radius_idx] < 0.0,
"radius/breathing mode must be unstable (negative curvature) past the collapse \
boundary r_min={:e} (evaluated at r={:e}), got {}",
r_min,
r_collapse,
spectrum.eigenvalues[radius_idx]
);
}
#[test]
fn test_eigenmode_translation_soft_modes_in_well_separated_configuration() {
let sigma = 2.0e-9;
let radius = 8.0e-9;
let grid_size = (32, 32, 32);
let profile = ProfileFunction::Gaussian { sigma };
let params = well_forming_energy_params();
let hopfion = Hopfion::with_profile(grid_size, radius, 1, profile)
.expect("failed to build well-separated gaussian hopfion");
let steps = CollectiveStepSizes::for_hopfion(&hopfion).expect("failed to build step sizes");
let translation_modes = [
CollectiveMode::TranslationX,
CollectiveMode::TranslationY,
CollectiveMode::TranslationZ,
];
let spectrum =
HopfionEigenmodeStability::analyze(&hopfion, ¶ms, &steps, &translation_modes)
.expect("translation-only eigenmode analysis should succeed");
assert_eq!(spectrum.eigenvalues.len(), 3);
let e_base =
HopfionEnergy::total_energy(&hopfion, ¶ms).expect("failed to compute base energy");
let step_t = steps.translation_step;
for (k, &ev) in spectrum.eigenvalues.iter().enumerate() {
let relative = (ev * step_t * step_t / e_base).abs();
assert!(
relative < 1e-3,
"translation normal mode {k} should be an approximate soft (Goldstone) mode \
in this well-separated configuration, got relative quadratic energy cost \
{relative:e} (eigenvalue {ev:e})"
);
}
let shifted_plus = Hopfion::with_profile_and_offset(
grid_size,
radius,
1,
profile,
Vector3::new(step_t, 0.0, 0.0),
)
.expect("failed to build +x shifted hopfion");
let shifted_minus = Hopfion::with_profile_and_offset(
grid_size,
radius,
1,
profile,
Vector3::new(-step_t, 0.0, 0.0),
)
.expect("failed to build -x shifted hopfion");
let e_plus = HopfionEnergy::total_energy(&shifted_plus, ¶ms)
.expect("failed to compute +x shifted energy");
let e_minus = HopfionEnergy::total_energy(&shifted_minus, ¶ms)
.expect("failed to compute -x shifted energy");
let direct_curvature = (e_plus - 2.0 * e_base + e_minus) / (step_t * step_t);
let direct_relative = (direct_curvature * step_t * step_t / e_base).abs();
assert!(
direct_relative < 1e-3,
"direct translation-shift relative quadratic energy cost should be small: {direct_relative:e}"
);
}
#[test]
fn test_analyze_rejects_non_positive_radius() {
let uniform = Hopfion::uniform(TEST_GRID, 1.0e-9).expect("failed to build uniform state");
let params = well_forming_energy_params();
let steps = CollectiveStepSizes::new(1.0e-10, 1.0e-10).expect("valid step sizes");
let result = HopfionEigenmodeStability::analyze_standard_modes(&uniform, ¶ms, &steps);
assert!(
result.is_err(),
"a uniform (radius = 0) configuration has no meaningful breathing mode and must error"
);
}
#[test]
fn test_analyze_rejects_empty_mode_list() {
let hopfion =
Hopfion::new(TEST_GRID, 3.0e-9, 1).expect("failed to build reference hopfion");
let params = well_forming_energy_params();
let steps = CollectiveStepSizes::for_hopfion(&hopfion).expect("valid step sizes");
let result = HopfionEigenmodeStability::analyze(&hopfion, ¶ms, &steps, &[]);
assert!(
result.is_err(),
"an empty collective-mode list must be rejected"
);
}
#[test]
fn test_analyze_rejects_step_size_that_collapses_radius() {
let hopfion =
Hopfion::new(TEST_GRID, 3.0e-9, 1).expect("failed to build reference hopfion");
let params = well_forming_energy_params();
let steps = CollectiveStepSizes::new(10.0e-9, 1.0e-10).expect("valid step sizes");
let result = HopfionEigenmodeStability::analyze_standard_modes(&hopfion, ¶ms, &steps);
assert!(
result.is_err(),
"a radius step that drives the perturbed radius non-positive must error, not panic \
or silently clamp"
);
}
#[test]
fn test_collective_step_sizes_scaled_to_matches_hopfion() {
let hopfion =
Hopfion::new(TEST_GRID, 4.0e-9, 1).expect("failed to build reference hopfion");
let steps = CollectiveStepSizes::for_hopfion(&hopfion).expect("valid step sizes");
assert!((steps.radius_step - DEFAULT_RADIUS_STEP_FRACTION * hopfion.radius).abs() < 1e-30);
assert!(
(steps.translation_step - DEFAULT_TRANSLATION_STEP_CELL_FRACTION * hopfion.cell_size)
.abs()
< 1e-30
);
}
#[test]
fn test_collective_step_sizes_rejects_invalid_input() {
assert!(CollectiveStepSizes::new(0.0, 1.0e-10).is_err());
assert!(CollectiveStepSizes::new(1.0e-10, 0.0).is_err());
assert!(CollectiveStepSizes::new(-1.0e-10, 1.0e-10).is_err());
assert!(CollectiveStepSizes::new(f64::NAN, 1.0e-10).is_err());
assert!(CollectiveStepSizes::new(1.0e-10, f64::INFINITY).is_err());
assert!(CollectiveStepSizes::scaled_to(0.0, 1.0e-9).is_err());
assert!(CollectiveStepSizes::scaled_to(1.0e-9, 0.0).is_err());
}
#[test]
fn test_with_profile_and_offset_zero_matches_with_profile() {
let a = Hopfion::with_profile(TEST_GRID, 5.0e-9, 1, ProfileFunction::Linear)
.expect("with_profile failed");
let b = Hopfion::with_profile_and_offset(
TEST_GRID,
5.0e-9,
1,
ProfileFunction::Linear,
Vector3::zero(),
)
.expect("with_profile_and_offset failed");
assert_eq!(a.magnetization.len(), b.magnetization.len());
for (ma, mb) in a.magnetization.iter().zip(b.magnetization.iter()) {
assert!((ma.x - mb.x).abs() < 1e-15);
assert!((ma.y - mb.y).abs() < 1e-15);
assert!((ma.z - mb.z).abs() < 1e-15);
}
}
#[test]
fn test_with_profile_and_offset_rejects_non_finite_offset() {
let result = Hopfion::with_profile_and_offset(
TEST_GRID,
5.0e-9,
1,
ProfileFunction::Linear,
Vector3::new(f64::NAN, 0.0, 0.0),
);
assert!(result.is_err(), "non-finite center offset must be rejected");
}
#[test]
fn test_dominant_mode_out_of_range_returns_none() {
let hopfion =
Hopfion::new(TEST_GRID, 3.0e-9, 1).expect("failed to build reference hopfion");
let params = well_forming_energy_params();
let steps = CollectiveStepSizes::for_hopfion(&hopfion).expect("valid step sizes");
let spectrum = HopfionEigenmodeStability::analyze_standard_modes(&hopfion, ¶ms, &steps)
.expect("analysis should succeed");
assert!(spectrum
.dominant_mode(spectrum.eigenvalues.len() + 10)
.is_none());
}
#[test]
fn test_single_mode_radius_only_analysis() {
let (_, r_eq, _) = find_boundaries();
let params = well_forming_energy_params();
let hopfion = Hopfion::with_profile(TEST_GRID, r_eq, 1, ProfileFunction::Linear)
.expect("failed to build equilibrium-radius hopfion");
let steps = CollectiveStepSizes::for_hopfion(&hopfion).expect("failed to build step sizes");
let spectrum = HopfionEigenmodeStability::analyze(
&hopfion,
¶ms,
&steps,
&[CollectiveMode::Radius],
)
.expect("single-mode analysis should succeed");
assert_eq!(spectrum.eigenvalues.len(), 1);
assert_eq!(spectrum.modes, vec![CollectiveMode::Radius]);
assert!(
spectrum.eigenvalues[0] > 0.0,
"radius-only curvature at the equilibrium radius must be positive, got {}",
spectrum.eigenvalues[0]
);
}
}