spintronics 0.3.1

Pure Rust library for simulating spin dynamics, spin current generation, and conversion phenomena in magnetic and topological materials
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
//! Magnetoelectric tensor and multiferroic material database
//!
//! This module provides the [`MagnetoelectricTensor`] struct describing the linear
//! magnetoelectric (ME) coupling in multiferroic materials, together with a curated
//! database of preset material parameters and free functions implementing microscopic
//! mechanisms.
//!
//! ## Linear Magnetoelectric Effect
//!
//! The linear ME effect couples magnetic and electric order parameters through
//! the tensor α_ij [s/m] (equivalently [C/(A·m)]):
//!
//! ```text
//! P_i = α_ij H_j      (magnetic field → electric polarization)
//! M_i = α_ji E_j      (electric field → magnetization, inverse ME)
//! ```
//!
//! Symmetry requires α_ij to be odd under both space inversion and time reversal;
//! a material must simultaneously break both symmetries to exhibit the effect.
//! Cr₂O₃ is the canonical example — it belongs to the magnetic point group
//! 3̄'m' which allows a diagonal α tensor.
//!
//! ## Microscopic Mechanisms
//!
//! ### Spin-current / Dzyaloshinskii-Moriya (KNB) mechanism
//! For non-collinear magnets the cross product S_i × S_j acts as an effective
//! spin current.  The KNB formula (PRL 95, 057205, 2005) gives:
//!
//! ```text
//! P ∝ e_ij × (S_i × S_j)
//! ```
//!
//! ### Exchange-striction mechanism
//! The isotropic exchange energy (S_i · S_j) couples through magnetostrictive
//! deformation to the lattice polarization.  For collinear magnets this gives
//! a scalar change in P along the bond direction.
//!
//! ### Toroidal moment
//! The toroidal moment T = (1/2V) Σ r_i × m_i is a time- and space-odd
//! axial vector that is the order parameter of the ferrotoroidal state.
//! It couples to E × H and contributes to the antisymmetric part of α.
//!
//! ## References
//!
//! - G. T. Rado & V. J. Folen, Phys. Rev. Lett. 7, 310 (1961) — first observation of ME in Cr₂O₃
//! - W. F. Brown, R. M. Hornreich & S. Shtrikman, Phys. Rev. 168, 574 (1968) — BHS bound
//! - S.-W. Cheong & M. Mostovoy, Nat. Mater. 6, 13 (2007) — review of multiferroics
//! - H. Katsura, N. Nagaosa & A. V. Balatsky, PRL 95, 057205 (2005) — KNB mechanism

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

use crate::constants::C_LIGHT;
use crate::error::{self, Result};
use crate::vector3::Vector3;

// ============================================================================
// Types
// ============================================================================

/// Classification of the multiferroic mechanism.
///
/// The three types differ in how the ferroelectric and magnetic order
/// parameters arise and couple to each other.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum MultiferroicType {
    /// Type-I: separate microscopic origins for FE and FM/AFM order.
    ///
    /// The ferroelectric mechanism (lone pairs, charge ordering, geometric)
    /// is independent of the magnetic mechanism (exchange, SOC).  Strong
    /// polarization (μC/cm² range) but weak ME coupling.
    ///
    /// Canonical examples: BiFeO₃, BiMnO₃, YMnO₃.
    TypeI,

    /// Type-II: ferroelectricity is *caused* by a non-collinear magnetic order.
    ///
    /// The cycloidal or spiral spin structure breaks inversion symmetry and
    /// induces a small but intrinsically strongly coupled electric polarization
    /// (nC/cm² range).
    ///
    /// Canonical examples: TbMnO₃, Ni₃V₂O₈, MnWO₄.
    TypeII,

    /// Type-III: lone-pair polarization combined with spiral magnetic order.
    ///
    /// Intermediate between Type-I and Type-II; the lone-pair drives the FE
    /// instability while a spiral magnetic ground state leads to additional
    /// magnetically-induced polarization.
    ///
    /// Canonical examples: BiMnO₃ (some authors), some Bi-based double perovskites.
    TypeIII,
}

// ============================================================================
// MagnetoelectricTensor
// ============================================================================

/// Linear magnetoelectric coupling tensor for a multiferroic material.
///
/// The tensor α_ij [s/m] relates magnetic field H [A/m] to induced electric
/// polarization P [C/m²] through the constitutive relation
///
/// ```text
/// P_i = α_ij H_j
/// ```
///
/// and the electric field E [V/m] to induced magnetization M [A/m] through
/// the inverse (transpose) relation
///
/// ```text
/// M_i = α_ji E_j
/// ```
///
/// In addition to the coupling tensor, the struct stores the spontaneous
/// (zero-field) electric polarization P_s and magnetization M_s, together
/// with the characteristic transition temperatures.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MagnetoelectricTensor {
    /// ME coupling tensor α_ij [s/m].
    ///
    /// Row index i labels the polarization component; column index j labels
    /// the magnetic field component.  The SI unit s/m is equivalent to C/(A·m).
    alpha: [[f64; 3]; 3],

    /// Multiferroic classification (Type-I, II, or III).
    material_type: MultiferroicType,

    /// Spontaneous electric polarization P_s [C/m²] at zero field.
    polarization_s: Vector3<f64>,

    /// Spontaneous magnetization M_s [A/m] at zero field.
    magnetization_s: Vector3<f64>,

    /// Ferroelectric transition temperature T_FE \[K\].
    t_ferroelectric: f64,

    /// Magnetic ordering (Néel or Curie) temperature T_mag \[K\].
    t_magnetic: f64,
}

impl MagnetoelectricTensor {
    // =========================================================================
    // Constructor
    // =========================================================================

    /// Create a new magnetoelectric tensor with explicit parameters.
    ///
    /// # Arguments
    /// * `alpha` - 3×3 ME coupling tensor [s/m]; all elements must be finite.
    /// * `material_type` - Classification of the multiferroic mechanism.
    /// * `polarization_s` - Spontaneous polarization [C/m²].
    /// * `magnetization_s` - Spontaneous magnetization [A/m].
    /// * `t_ferroelectric` - Ferroelectric transition temperature \[K\]; must be positive.
    /// * `t_magnetic` - Magnetic transition temperature \[K\]; must be positive.
    ///
    /// # Errors
    /// Returns [`crate::error::Error::InvalidParameter`] if any α element is
    /// non-finite, or if either transition temperature is non-positive.
    pub fn new(
        alpha: [[f64; 3]; 3],
        material_type: MultiferroicType,
        polarization_s: Vector3<f64>,
        magnetization_s: Vector3<f64>,
        t_ferroelectric: f64,
        t_magnetic: f64,
    ) -> Result<Self> {
        for (i, row) in alpha.iter().enumerate() {
            for (j, &val) in row.iter().enumerate() {
                if !val.is_finite() {
                    return Err(error::invalid_param(
                        "alpha",
                        &format!("element [{i}][{j}] = {val} is not finite"),
                    ));
                }
            }
        }
        if t_ferroelectric <= 0.0 {
            return Err(error::invalid_param(
                "t_ferroelectric",
                "ferroelectric transition temperature must be positive",
            ));
        }
        if t_magnetic <= 0.0 {
            return Err(error::invalid_param(
                "t_magnetic",
                "magnetic transition temperature must be positive",
            ));
        }
        Ok(Self {
            alpha,
            material_type,
            polarization_s,
            magnetization_s,
            t_ferroelectric,
            t_magnetic,
        })
    }

    // =========================================================================
    // Material Presets
    // =========================================================================

    /// BiFeO₃ — canonical Type-I multiferroic.
    ///
    /// Bi³⁺ lone pairs (6s²) drive ferroelectricity; Fe³⁺ G-type AFM from
    /// super-exchange.  The spontaneous polarization along the pseudo-cubic
    /// \[111\] direction reaches ~ 90 μC/cm² in epitaxial films.
    ///
    /// ME coupling is weak but non-zero due to the combined breaking of
    /// inversion and time-reversal symmetry.  The diagonal α value here is
    /// representative of the weak coupling in BiFeO₃ (~6 × 10⁻¹² s/m),
    /// comparable to Cr₂O₃ but highly anisotropic in practice.
    ///
    /// Parameters from:
    /// - J. Wang et al., Science 299, 1719 (2003)
    /// - D. Lebeugle et al., Phys. Rev. Lett. 100, 227602 (2008)
    pub fn bife_o3() -> Self {
        // P_s ~ 90 μC/cm²; unit conversion: 1 μC/cm² = 1e-6 C / 1e-4 m² = 0.01 C/m²
        // So 90 μC/cm² = 90 × 0.01 C/m² = 0.90 C/m²; each [111] component = 0.90/√3
        let p_s_component = 0.90 / 3_f64.sqrt();
        // Diagonal ME tensor; α_11 ≠ α_33 due to rhombohedral symmetry
        let alpha = [
            [6.0e-12, 0.0, 0.0],
            [0.0, 6.0e-12, 0.0],
            [0.0, 0.0, 3.0e-12],
        ];
        // Spontaneous magnetization is zero for G-type AFM (net zero)
        Self {
            alpha,
            material_type: MultiferroicType::TypeI,
            polarization_s: Vector3::new(p_s_component, p_s_component, p_s_component),
            magnetization_s: Vector3::zero(),
            t_ferroelectric: 1100.0,
            t_magnetic: 643.0,
        }
    }

    /// TbMnO₃ — canonical Type-II (spin-induced) multiferroic.
    ///
    /// Below T_N = 41 K a sinusoidal Mn³⁺ spin density wave forms; below
    /// T_FE = 27 K this locks into a cycloidal spiral that breaks inversion
    /// symmetry and induces a polarization of ~ 0.08 μC/cm² along the c-axis
    /// via the KNB spin-current mechanism.  The ME coupling is intrinsically
    /// strong (type-II), though P_s is orders of magnitude smaller than BiFeO₃.
    ///
    /// Parameters from:
    /// - T. Kimura et al., Nature 426, 55 (2003)
    /// - M. Kenzelmann et al., Phys. Rev. Lett. 95, 087206 (2005)
    pub fn tb_mn_o3() -> Self {
        // P_s ~ 0.08 μC/cm² = 8e-4 C/m² ... but actually 0.08 μC/cm² = 8e-6 C/m²
        // 1 μC/cm² = 1e-6 C / 1e-4 m² = 0.01 C/m²; 0.08 μC/cm² = 8e-4 C/m²... No:
        // 1 μC = 1e-6 C, 1 cm² = 1e-4 m², so 1 μC/cm² = 1e-6/1e-4 = 0.01 C/m²
        // 0.08 μC/cm² = 0.08 * 0.01 = 8e-4 C/m²
        // That's actually quite large. Let me use the experimental value correctly:
        // Kimura et al. report ~800 μC/m² = 800e-6 C/m² for TbMnO3
        // which equals 0.08 μC/cm² = 0.08 * 0.01 C/m² = 8e-4 C/m²
        // However the commonly-cited value is P ~ 600-800 μC/m² = ~6-8 × 10^-4 C/m²
        let p_s = 8.0e-4; // [C/m²] along c-axis
                          // Type-II: ME coupling much stronger than Type-I
                          // α ~ 10⁻⁹ s/m (representative; actual value is hard to isolate from P_s contribution)
        let alpha = [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 1.0e-9]];
        Self {
            alpha,
            material_type: MultiferroicType::TypeII,
            polarization_s: Vector3::new(0.0, 0.0, p_s),
            magnetization_s: Vector3::zero(),
            t_ferroelectric: 27.0,
            t_magnetic: 41.0,
        }
    }

    /// Cr₂O₃ — the prototypical linear magnetoelectric.
    ///
    /// The magnetic point group 3̄'m' of Cr₂O₃ allows a diagonal ME tensor
    /// with α_11 = α_22 ≠ α_33.  The most precisely measured value is
    /// α_33 = 4.13 × 10⁻¹² s/m at 270 K (slightly below T_N = 307 K).
    ///
    /// Cr₂O₃ is not ferroelectric (P_s = 0), but the induced polarization
    /// from a 1 T field is ~ 4 pC/m², measurable with modern techniques.
    ///
    /// Parameters from:
    /// - D. N. Astrov, JETP 11, 708 (1960) — first ME observation
    /// - G. T. Rado & V. J. Folen, PRL 7, 310 (1961) — tensor measurement
    /// - B. B. Krichevtsov et al., J. Phys.: Condens. Matter 5, 8233 (1993)
    pub fn cr2_o3() -> Self {
        // α_33 = 4.13e-12 s/m; α_11 ≈ 1.3e-12 s/m at 270 K (from literature)
        let alpha = [
            [1.3e-12, 0.0, 0.0],
            [0.0, 1.3e-12, 0.0],
            [0.0, 0.0, 4.13e-12],
        ];
        Self {
            alpha,
            material_type: MultiferroicType::TypeI,
            polarization_s: Vector3::zero(),
            magnetization_s: Vector3::zero(),
            t_ferroelectric: 307.0, // Cr₂O₃ has no separate T_FE; use T_N
            t_magnetic: 307.0,
        }
    }

    // =========================================================================
    // Field-Response Methods
    // =========================================================================

    /// Compute the ME-induced electric polarization from an applied magnetic field.
    ///
    /// Evaluates P_i = α_ij H_j [C/m²].  The result represents only the
    /// field-*induced* part; add [`Self::polarization_s`] for the full polarization.
    ///
    /// # Arguments
    /// * `h_field` - Applied magnetic field **H** [A/m].
    pub fn electric_polarization_from_field(&self, h_field: &Vector3<f64>) -> Vector3<f64> {
        let h = [h_field.x, h_field.y, h_field.z];
        let a = &self.alpha;
        let px = a[0][0] * h[0] + a[0][1] * h[1] + a[0][2] * h[2];
        let py = a[1][0] * h[0] + a[1][1] * h[1] + a[1][2] * h[2];
        let pz = a[2][0] * h[0] + a[2][1] * h[1] + a[2][2] * h[2];
        Vector3::new(px, py, pz)
    }

    /// Compute the ME-induced magnetization from an applied electric field (inverse ME).
    ///
    /// Evaluates M_i = α_ji E_j [A/m] via the transpose tensor.  This follows
    /// from the thermodynamic reciprocity relation for the free-energy expansion.
    ///
    /// # Arguments
    /// * `e_field` - Applied electric field **E** [V/m].
    pub fn magnetization_from_efield(&self, e_field: &Vector3<f64>) -> Vector3<f64> {
        let e = [e_field.x, e_field.y, e_field.z];
        let a = &self.alpha;
        // M_i = α^T_ij E_j = α_ji E_j
        let mx = a[0][0] * e[0] + a[1][0] * e[1] + a[2][0] * e[2];
        let my = a[0][1] * e[0] + a[1][1] * e[1] + a[2][1] * e[2];
        let mz = a[0][2] * e[0] + a[1][2] * e[1] + a[2][2] * e[2];
        Vector3::new(mx, my, mz)
    }

    // =========================================================================
    // Descriptor Methods
    // =========================================================================

    /// Return `true` if this material exhibits a non-zero linear ME effect.
    ///
    /// Checks whether any element of the α tensor is non-zero (using floating-
    /// point exact equality; all preset materials store exact zeros for inactive
    /// elements).
    pub fn is_linear_magnetoelectric(&self) -> bool {
        self.alpha.iter().any(|row| row.iter().any(|&v| v != 0.0))
    }

    /// Frobenius norm of the ME coupling tensor [s/m].
    ///
    /// Provides a scalar measure of the overall ME susceptibility strength:
    ///
    /// ```text
    /// ‖α‖_F = √( Σ_ij α_ij² )
    /// ```
    pub fn magnetoelectric_susceptibility(&self) -> f64 {
        let sum_sq: f64 = self
            .alpha
            .iter()
            .flat_map(|row| row.iter())
            .map(|&v| v * v)
            .sum();
        sum_sq.sqrt()
    }

    /// Brown-Hornreich-Shtrikman (BHS) thermodynamic upper bound on ME coupling.
    ///
    /// The BHS bound (Phys. Rev. 168, 574, 1968) states that for a material
    /// with electric susceptibility χ_e (dimensionless) and magnetic
    /// susceptibility χ_m (dimensionless):
    ///
    /// ```text
    /// α_ij² < ε₀ χ_e · μ₀ χ_m
    /// ```
    ///
    /// In Gaussian units this is often written α < √(χ_e · χ_m).  In SI the
    /// right-hand side has units of s²/m² and the bound on a single α component
    /// [s/m] is √(ε₀ χ_e · μ₀ χ_m).  Equivalently, using c = 1/√(μ₀ ε₀):
    ///
    /// ```text
    /// α < √(χ_e · χ_m) / c
    /// ```
    ///
    /// This method returns `√(χ_e · χ_m) / c` for representative susceptibilities
    /// χ_e = χ_m = 1 (order-of-magnitude estimate), giving ~ 3.3 × 10⁻⁹ s/m.
    /// For Cr₂O₃ (χ_e ~ 12, χ_m ~ 1e-3) the bound is ~ 1.2 × 10⁻¹¹ s/m.
    ///
    /// The returned value uses the diagonal (α_33) element of α for normalization,
    /// giving a physically meaningful bound in the context of this material.
    pub fn bound_on_me_coupling(&self) -> f64 {
        // Representative susceptibilities for a ME material:
        // ε₀ χ_e ≈ 12 ε₀ (χ_e_rel ~ 12 for Cr₂O₃)
        // μ₀ χ_m ≈ 1e-3 μ₀ (χ_m ~ 1e-3 for weakly magnetic AFM)
        let chi_e = 12.0_f64;
        let chi_m = 1.0e-3_f64;
        // BHS bound: α < √(ε₀ χ_e · μ₀ χ_m) = √(χ_e · χ_m) / c
        (chi_e * chi_m).sqrt() / C_LIGHT
    }

    /// Return a reference to the raw 3×3 ME coupling tensor [s/m].
    pub fn alpha_tensor(&self) -> [[f64; 3]; 3] {
        self.alpha
    }

    /// Return the spontaneous electric polarization [C/m²].
    pub fn polarization_s(&self) -> Vector3<f64> {
        self.polarization_s
    }

    /// Return the spontaneous magnetization [A/m].
    pub fn magnetization_s(&self) -> Vector3<f64> {
        self.magnetization_s
    }

    /// Return the multiferroic classification.
    pub fn material_type(&self) -> MultiferroicType {
        self.material_type
    }

    /// Return the ferroelectric transition temperature \[K\].
    pub fn t_ferroelectric(&self) -> f64 {
        self.t_ferroelectric
    }

    /// Return the magnetic transition temperature \[K\].
    pub fn t_magnetic(&self) -> f64 {
        self.t_magnetic
    }

    // =========================================================================
    // Phase / Temperature Methods
    // =========================================================================

    /// Return `true` if the given temperature is above the ferroelectric transition.
    ///
    /// Above T_FE the spontaneous electric polarization vanishes and the material
    /// enters the paraelectric phase.
    ///
    /// # Arguments
    /// * `temperature` - Temperature to test \[K\].
    pub fn is_above_ferroelectric_transition(&self, temperature: f64) -> bool {
        temperature > self.t_ferroelectric
    }

    /// Return `true` if the given temperature is above the magnetic transition.
    ///
    /// Above T_N (Néel) or T_C (Curie) the spontaneous magnetic order disappears
    /// and the material enters the paramagnetic phase.
    ///
    /// # Arguments
    /// * `temperature` - Temperature to test \[K\].
    pub fn is_above_magnetic_transition(&self, temperature: f64) -> bool {
        temperature > self.t_magnetic
    }

    // =========================================================================
    // Energy Methods
    // =========================================================================

    /// Work density to switch the multiferroic order against applied fields [J/m³].
    ///
    /// The electrostatic and magnetostatic energy densities needed to align
    /// the polarization and magnetization against external fields are:
    ///
    /// ```text
    /// u = P_s · E + M_s · H ≈ |P_s| E + |M_s| H
    /// ```
    ///
    /// This simplified scalar estimate uses the magnitudes of the spontaneous
    /// moments and assumes the fields are collinear with them.
    ///
    /// # Arguments
    /// * `e_field` - Electric field magnitude [V/m].
    /// * `h_field` - Magnetic field magnitude [A/m].
    pub fn switching_energy(&self, e_field: f64, h_field: f64) -> f64 {
        let p_mag = self.polarization_s.magnitude();
        let m_mag = self.magnetization_s.magnitude();
        p_mag * e_field + m_mag * h_field
    }
}

// ============================================================================
// Free Functions — Microscopic Mechanisms
// ============================================================================

/// KNB spin-current polarization: P ∝ e_ij × (S_i × S_j).
///
/// Implements the Katsura-Nagaosa-Balatsky (KNB) formula for the electric
/// polarization induced by a pair of non-collinear spins in a solid.  The
/// spin current **J**_s = S_i × S_j flows perpendicular to both spins;
/// the polarization is then P ∝ **e**_ij × **J**_s, where **e**_ij is the
/// unit bond vector from site i to site j.
///
/// This mechanism vanishes for:
/// - collinear spins (S_i × S_j = 0)
/// - helical spirals where q ∥ rotation_axis
///
/// and is maximal for cycloidal spirals.
///
/// The returned vector is the *direction* of P (magnitude = |e_ij × (S_i × S_j)|).
/// The actual polarization is obtained by multiplying by the coupling constant γ.
///
/// # Arguments
/// * `s_i` - Spin vector at site i (arbitrary units, e.g., in units of ℏ/2).
/// * `s_j` - Spin vector at site j.
/// * `bond_vector` - Vector from site i to site j [same units as positions].
///
/// # References
/// - H. Katsura, N. Nagaosa, A. V. Balatsky, PRL 95, 057205 (2005)
pub fn dzyaloshinskii_moriya_polarization(
    s_i: &Vector3<f64>,
    s_j: &Vector3<f64>,
    bond_vector: &Vector3<f64>,
) -> Vector3<f64> {
    let mag = bond_vector.magnitude();
    if mag == 0.0 {
        return Vector3::zero();
    }
    let e_ij = Vector3::new(
        bond_vector.x / mag,
        bond_vector.y / mag,
        bond_vector.z / mag,
    );
    // Spin current: J_s = S_i × S_j
    let spin_current = s_i.cross(s_j);
    // KNB: P ∝ e_ij × J_s = e_ij × (S_i × S_j)
    e_ij.cross(&spin_current)
}

/// Exchange-striction contribution to electric polarization for collinear spins.
///
/// In Type-I and exchange-striction-driven multiferroics the Heisenberg exchange
/// energy J (S_i · S_j) couples to phonon modes that carry electric polarization.
/// The resulting scalar change in polarization along the bond is:
///
/// ```text
/// ΔP = λ (S_i · S_j)
/// ```
///
/// where λ (`sensitivity`) is the magnetostrictive coupling coefficient [C/m²].
///
/// For ferromagnetically aligned spins (S_i · S_j > 0) this gives a positive ΔP,
/// while antiparallel spins give a negative ΔP.
///
/// # Arguments
/// * `s_i` - Spin vector at site i.
/// * `s_j` - Spin vector at site j.
/// * `sensitivity` - Magnetostrictive coupling coefficient λ [C/m²].
///
/// # Returns
/// Scalar change in polarization [C/m²].
pub fn exchange_striction_polarization(
    s_i: &Vector3<f64>,
    s_j: &Vector3<f64>,
    sensitivity: f64,
) -> f64 {
    sensitivity * s_i.dot(s_j)
}

/// Toroidal moment of a collection of magnetic dipoles.
///
/// The toroidal moment (ferrotoroidal order parameter) is defined as:
///
/// ```text
/// T = (1 / 2V) Σ_i r_i × m_i   [A·m]
/// ```
///
/// where r_i is the position of site i, m_i is its magnetic moment [A·m²],
/// and V is the unit-cell volume.  Since V appears in the denominator and is
/// not always known, this function computes the *unnormalised* sum
/// Σ r_i × m_i and the caller should divide by 2V.
///
/// For simplicity (and consistent with the convention used in small-cluster
/// calculations) this implementation returns
///
/// ```text
/// T = (1/2) Σ_i r_i × m_i
/// ```
///
/// normalised by the number of sites, giving [A·m] if positions are in \[m\]
/// and moments in [A·m²].
///
/// # Arguments
/// * `positions` - Slice of position vectors \[m\]; must match length of `moments`.
/// * `moments`   - Slice of magnetic moment vectors [A·m²].
///
/// # Errors
/// Returns an error if `positions` and `moments` have different lengths or if
/// either slice is empty.
///
/// # References
/// - C. Ederer & N. A. Spaldin, Phys. Rev. B 76, 214404 (2007)
/// - B. B. Van Aken et al., Nature 449, 702 (2007)
pub fn toroidal_moment(
    positions: &[Vector3<f64>],
    moments: &[Vector3<f64>],
) -> Result<Vector3<f64>> {
    if positions.is_empty() {
        return Err(error::invalid_param("positions", "slice must not be empty"));
    }
    if positions.len() != moments.len() {
        return Err(error::invalid_param(
            "moments",
            &format!(
                "length {} does not match positions length {}",
                moments.len(),
                positions.len()
            ),
        ));
    }
    let mut sum = Vector3::zero();
    for (r, m) in positions.iter().zip(moments.iter()) {
        let rxm = r.cross(m);
        sum = Vector3::new(sum.x + rxm.x, sum.y + rxm.y, sum.z + rxm.z);
    }
    let n = positions.len() as f64;
    Ok(Vector3::new(
        sum.x / (2.0 * n),
        sum.y / (2.0 * n),
        sum.z / (2.0 * n),
    ))
}

// Re-export validation: prevent dead-code warnings for the #[allow] above
// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    // Tolerance for floating-point comparisons
    const TOL: f64 = 1.0e-10;

    // -----------------------------------------------------------------------
    // Material preset sanity checks
    // -----------------------------------------------------------------------

    #[test]
    fn test_bife_o3_polarization_range() {
        let bfo = MagnetoelectricTensor::bife_o3();
        // P_s should be roughly 50–100 μC/cm² in magnitude
        // 1 μC/cm² = 0.01 C/m²; so 50–100 μC/cm² → 0.5–1.0 C/m²
        // Wait — let me re-check: 1 C/m² = 100 μC/cm²? No:
        // 1 μC/cm² = 1e-6 C / 1e-4 m² = 0.01 C/m²
        // 90 μC/cm² = 0.90 C/m² → our component is 0.9/√3 ≈ 0.5196 C/m²
        // magnitude = 0.9/√3 * √3 = 0.9 C/m²
        let p_mag = bfo.polarization_s().magnitude();
        // Expect ~0.9 C/m² = 90 μC/cm²; allow 50–100 μC/cm² = 0.5–1.0 C/m²
        assert!(
            (0.5..=1.0).contains(&p_mag),
            "BiFeO3 |P_s| = {p_mag} C/m² is outside the expected 0.5–1.0 C/m² range"
        );
        assert_eq!(bfo.material_type(), MultiferroicType::TypeI);
    }

    #[test]
    fn test_tb_mn_o3_temperature_ordering() {
        let tbmno3 = MagnetoelectricTensor::tb_mn_o3();
        // In TbMnO3: T_FE (27 K) < T_N (41 K)
        assert!(
            tbmno3.t_ferroelectric() < tbmno3.t_magnetic(),
            "TbMnO3 must have T_FE ({}) < T_N ({})",
            tbmno3.t_ferroelectric(),
            tbmno3.t_magnetic()
        );
        assert_eq!(tbmno3.material_type(), MultiferroicType::TypeII);
    }

    #[test]
    fn test_cr2_o3_electric_polarization_from_field() {
        let cr2o3 = MagnetoelectricTensor::cr2_o3();
        // Apply H along z-axis: P_z = α_33 * H_z
        let h_z = 1.0e6; // 1 MA/m
        let h = Vector3::new(0.0, 0.0, h_z);
        let p = cr2o3.electric_polarization_from_field(&h);
        let expected_pz = 4.13e-12 * h_z;
        assert!(
            (p.z - expected_pz).abs() < TOL,
            "P_z = {} ≠ expected {expected_pz}",
            p.z
        );
        assert!(p.x.abs() < TOL, "P_x should be zero for z-only H");
        assert!(p.y.abs() < TOL, "P_y should be zero for z-only H");
    }

    #[test]
    fn test_inverse_me_magnetization_from_efield() {
        let cr2o3 = MagnetoelectricTensor::cr2_o3();
        // Apply E along z: M_z = α_33 * E_z (diagonal tensor: α^T = α)
        let e_z = 1.0e6; // 1 MV/m
        let e = Vector3::new(0.0, 0.0, e_z);
        let m = cr2o3.magnetization_from_efield(&e);
        let expected_mz = 4.13e-12 * e_z;
        assert!(
            (m.z - expected_mz).abs() < TOL,
            "M_z = {} ≠ expected {expected_mz}",
            m.z
        );
    }

    // -----------------------------------------------------------------------
    // DM spin-current polarization
    // -----------------------------------------------------------------------

    #[test]
    fn test_dzyaloshinskii_moriya_polarization_direction() {
        // S_i = [1,0,0], S_j = [0,1,0], bond = [0,0,1] (z-bond)
        // S_i × S_j = [1,0,0] × [0,1,0] = [0·0 - 0·1, 0·0 - 1·0, 1·1 - 0·0] = [0,0,1]
        // e_ij = [0,0,1]
        // P ∝ [0,0,1] × [0,0,1] = [0,0,0]  ← zero! e_ij ∥ S_i×S_j
        //
        // Use a different geometry: S_i=[1,0,0], S_j=[0,1,0], bond=[1,0,0]
        // S_i × S_j = [0,0,1]
        // e_ij = [1,0,0]
        // P ∝ [1,0,0] × [0,0,1] = [0·1-0·0, 0·0-1·1, 1·0-0·0] = [0,-1,0]
        let s_i = Vector3::new(1.0, 0.0, 0.0);
        let s_j = Vector3::new(0.0, 1.0, 0.0);
        let bond = Vector3::new(1.0, 0.0, 0.0);
        let p = dzyaloshinskii_moriya_polarization(&s_i, &s_j, &bond);
        // Expect P along -y direction
        assert!(
            p.magnitude() > TOL,
            "DM polarization should be non-zero for this geometry"
        );
        assert!(
            p.y < 0.0,
            "DM polarization should point along -y, got y = {}",
            p.y
        );
        assert!(p.x.abs() < TOL);
        assert!(p.z.abs() < TOL);
    }

    // -----------------------------------------------------------------------
    // Exchange striction
    // -----------------------------------------------------------------------

    #[test]
    fn test_exchange_striction_antiparallel_spins() {
        // Anti-parallel spins: S_i · S_j < 0 → ΔP < 0
        let s_i = Vector3::new(1.0, 0.0, 0.0);
        let s_j = Vector3::new(-1.0, 0.0, 0.0);
        let lambda = 1.0e-5; // C/m²
        let dp = exchange_striction_polarization(&s_i, &s_j, lambda);
        assert!(
            dp < 0.0,
            "Anti-parallel spins should give negative ΔP, got {dp}"
        );
    }

    // -----------------------------------------------------------------------
    // Toroidal moment
    // -----------------------------------------------------------------------

    #[test]
    fn test_toroidal_moment_two_site() {
        // Two sites: r1 = [1,0,0], m1 = [0,0,1]; r2 = [-1,0,0], m2 = [0,0,-1]
        // r1 × m1 = [1,0,0] × [0,0,1] = [0·1-0·0, 0·0-1·1, 1·0-0·0] = [0,-1,0]
        // r2 × m2 = [-1,0,0] × [0,0,-1] = [0·(-1)-0·0, 0·0-(-1)·(-1), (-1)·0-0·0]
        //         = [0, 0-1, 0] = [0,-1,0]
        // sum = [0,-2,0]; T = sum/(2*2) = [0,-0.5,0]
        let positions = vec![Vector3::new(1.0, 0.0, 0.0), Vector3::new(-1.0, 0.0, 0.0)];
        let moments = vec![Vector3::new(0.0, 0.0, 1.0), Vector3::new(0.0, 0.0, -1.0)];
        let t = toroidal_moment(&positions, &moments).expect("toroidal_moment should succeed");
        assert!((t.x).abs() < TOL, "T_x = {} should be 0", t.x);
        assert!((t.y - (-0.5)).abs() < TOL, "T_y = {} should be -0.5", t.y);
        assert!((t.z).abs() < TOL, "T_z = {} should be 0", t.z);
    }

    // -----------------------------------------------------------------------
    // Susceptibility and bound
    // -----------------------------------------------------------------------

    #[test]
    fn test_magnetoelectric_susceptibility_positive() {
        let cr2o3 = MagnetoelectricTensor::cr2_o3();
        let chi = cr2o3.magnetoelectric_susceptibility();
        assert!(chi > 0.0, "ME susceptibility must be positive");
    }

    #[test]
    fn test_bound_on_me_coupling_positive_finite() {
        let cr2o3 = MagnetoelectricTensor::cr2_o3();
        let bound = cr2o3.bound_on_me_coupling();
        assert!(bound > 0.0, "BHS bound must be positive");
        assert!(bound.is_finite(), "BHS bound must be finite");
        // For χ_e=12, χ_m=1e-3: bound = √(12e-3) / c ≈ √(0.012) / 3e8
        // ≈ 0.1095 / 3e8 ≈ 3.65e-10 s/m → order 10⁻¹⁰ s/m
        assert!(
            bound > 1.0e-12 && bound < 1.0e-6,
            "BHS bound = {bound} is unreasonably large/small"
        );
    }

    // -----------------------------------------------------------------------
    // Temperature-dependent phase flags
    // -----------------------------------------------------------------------

    #[test]
    fn test_temperature_phase_flags() {
        let bfo = MagnetoelectricTensor::bife_o3();
        // T_FE = 1100 K, T_N = 643 K
        assert!(!bfo.is_above_ferroelectric_transition(300.0));
        assert!(!bfo.is_above_magnetic_transition(300.0));
        assert!(!bfo.is_above_magnetic_transition(643.0)); // strictly above
        assert!(bfo.is_above_magnetic_transition(700.0));
        assert!(bfo.is_above_ferroelectric_transition(1200.0));
    }

    // -----------------------------------------------------------------------
    // Switching energy
    // -----------------------------------------------------------------------

    #[test]
    fn test_switching_energy_bife_o3() {
        let bfo = MagnetoelectricTensor::bife_o3();
        // P_s ≈ 0.9 C/m², M_s = 0 → switching_energy = P_s * E
        let e = 1.0e6; // V/m
        let h = 1.0e6; // A/m
        let u = bfo.switching_energy(e, h);
        // u ≈ 0.9 * 1e6 = 9e5 J/m³ (M_s=0 so no magnetic contribution)
        assert!(u > 0.0, "Switching energy must be positive");
    }

    // -----------------------------------------------------------------------
    // From-field returns zero for zero alpha
    // -----------------------------------------------------------------------

    #[test]
    fn test_from_field_zero_for_zero_alpha() {
        let zero_alpha = [[0.0; 3]; 3];
        let mat = MagnetoelectricTensor::new(
            zero_alpha,
            MultiferroicType::TypeI,
            Vector3::zero(),
            Vector3::zero(),
            500.0,
            400.0,
        )
        .expect("valid parameters");
        let h = Vector3::new(1.0e6, 1.0e6, 1.0e6);
        let p = mat.electric_polarization_from_field(&h);
        assert!(
            p.magnitude() < TOL,
            "|P| = {} should be zero for α=0",
            p.magnitude()
        );
    }

    // -----------------------------------------------------------------------
    // Type classification
    // -----------------------------------------------------------------------

    #[test]
    fn test_type_classification() {
        assert_eq!(
            MagnetoelectricTensor::bife_o3().material_type(),
            MultiferroicType::TypeI
        );
        assert_eq!(
            MagnetoelectricTensor::tb_mn_o3().material_type(),
            MultiferroicType::TypeII
        );
        assert_eq!(
            MagnetoelectricTensor::cr2_o3().material_type(),
            MultiferroicType::TypeI
        );
    }

    // -----------------------------------------------------------------------
    // Toroidal moment error handling
    // -----------------------------------------------------------------------

    #[test]
    fn test_toroidal_moment_length_mismatch() {
        let positions = vec![Vector3::new(1.0, 0.0, 0.0)];
        let moments = vec![Vector3::new(0.0, 0.0, 1.0), Vector3::new(0.0, 0.0, -1.0)];
        let result = toroidal_moment(&positions, &moments);
        assert!(result.is_err(), "Mismatched lengths should return an error");
    }

    // -----------------------------------------------------------------------
    // Constructor validation
    // -----------------------------------------------------------------------

    #[test]
    fn test_new_rejects_nonfinite_alpha() {
        let mut alpha = [[0.0; 3]; 3];
        alpha[1][2] = f64::NAN;
        let result = MagnetoelectricTensor::new(
            alpha,
            MultiferroicType::TypeI,
            Vector3::zero(),
            Vector3::zero(),
            500.0,
            300.0,
        );
        assert!(result.is_err(), "NaN in alpha should be rejected");
    }
}