integral/operator.rs
1//! One-electron operator-DSL driver (L3).
2//!
3//! [`Basis::int1e`] evaluates any one-electron [`Operator`] — a polynomial in the
4//! position `r` and momentum `p = −i∇` operators — over the basis, returning the
5//! complex matrix as separate real and imaginary parts ([`OperatorMatrix`]). The
6//! decomposition into base overlap integrals lives in [`integral_core::operator`];
7//! this driver loops shell pairs, contracts primitives, applies the
8//! Cartesian→spherical transform to **each** part, and places the blocks.
9//!
10//! ## Additive layer
11//!
12//! This is purely additive: the bespoke fast paths ([`Basis::overlap`],
13//! [`Basis::kinetic`], [`Basis::dipole`]) stay as-is. The DSL exists for
14//! generality (a new integral type is one [`Operator`] declaration, not an engine
15//! change) and is validated *against* those bespoke paths (`tests/operator_dsl`).
16//!
17//! ## Hermiticity / the imaginary unit
18//!
19//! `r`, `r_i r_j`, and `p·p` (kinetic) are real-symmetric → [`OperatorMatrix::imag`]
20//! is ~0; `p` and `r × p` are imaginary/antisymmetric → [`OperatorMatrix::real`]
21//! is ~0. The `i`/sign convention is documented in the module docs.
22
23use integral_core::operator::operator_into;
24use integral_core::os::MAX_L;
25
26pub use integral_core::operator::{Factor, Operator, Term};
27
28use crate::integrals::{place_block, to_func_1e};
29use crate::shell::Basis;
30use crate::IntegralError;
31
32/// The complex matrix of a one-electron operator, stored as separate real and
33/// imaginary parts. Both are dense row-major `nao × nao` (same ordering/strides
34/// as the value builders).
35///
36/// A Hermitian operator with a real Gaussian representation has
37/// [`OperatorMatrix::imag`] ≈ 0 (e.g. `r`, kinetic); an anti-Hermitian one (e.g.
38/// `p`, `r × p`) has [`OperatorMatrix::real`] ≈ 0 and an antisymmetric imaginary
39/// part.
40#[derive(Debug, Clone, PartialEq)]
41pub struct OperatorMatrix {
42 nao: usize,
43 re: Vec<f64>,
44 im: Vec<f64>,
45}
46
47impl OperatorMatrix {
48 /// Matrix dimension `nao`.
49 #[must_use]
50 pub fn nao(&self) -> usize {
51 self.nao
52 }
53
54 /// The real part `Re⟨μ|O|ν⟩` (row-major `nao × nao`).
55 #[must_use]
56 pub fn real(&self) -> &[f64] {
57 &self.re
58 }
59
60 /// The imaginary part `Im⟨μ|O|ν⟩` (row-major `nao × nao`).
61 #[must_use]
62 pub fn imag(&self) -> &[f64] {
63 &self.im
64 }
65
66 /// Largest `|Im⟨μ|O|ν⟩|` — ~0 for a real (Hermitian, real-symmetric)
67 /// operator. A diagnostic for the operator's Hermiticity character.
68 #[must_use]
69 pub fn max_abs_imag(&self) -> f64 {
70 self.im.iter().fold(0.0_f64, |m, &v| m.max(v.abs()))
71 }
72
73 /// Largest `|Re⟨μ|O|ν⟩|` — ~0 for a purely imaginary (anti-Hermitian)
74 /// operator.
75 #[must_use]
76 pub fn max_abs_real(&self) -> f64 {
77 self.re.iter().fold(0.0_f64, |m, &v| m.max(v.abs()))
78 }
79}
80
81impl Basis {
82 /// Evaluate a one-electron [`Operator`] over the basis, returning its complex
83 /// matrix as real + imaginary parts ([`OperatorMatrix`]).
84 ///
85 /// The operator is decomposed into base overlap integrals
86 /// ([`integral_core::operator`]); spherical shells are transformed to their
87 /// `2l+1` components (the transform is applied to each part). This is the
88 /// generic operator-DSL path; the bespoke [`Basis::overlap`] /
89 /// [`Basis::kinetic`] / [`Basis::dipole`] builders remain the fast paths.
90 ///
91 /// # Errors
92 /// [`IntegralError::OperatorMomentumTooHigh`] if any shell has
93 /// `l + op.degree() > MAX_L` (the DSL raises the ket to `l + degree`).
94 pub fn int1e(&self, op: &Operator) -> Result<OperatorMatrix, IntegralError> {
95 let deg = op.degree();
96 for s in self.shells() {
97 if s.l() + deg > MAX_L {
98 return Err(IntegralError::OperatorMomentumTooHigh {
99 l: s.l(),
100 degree: deg,
101 max: MAX_L,
102 });
103 }
104 }
105
106 let n = self.nao();
107 let offs = self.offsets();
108 let mut re = vec![0.0; n * n];
109 let mut im = vec![0.0; n * n];
110 let shells = self.shells();
111
112 for (si, sa) in shells.iter().enumerate() {
113 for (sj, sb) in shells.iter().enumerate() {
114 let (na, nb) = (sa.n_cart(), sb.n_cart());
115 let mut br = vec![0.0; na * nb];
116 let mut bi = vec![0.0; na * nb];
117 for pi in 0..sa.n_prim() {
118 for pj in 0..sb.n_prim() {
119 let scale = sa.primitive_coeff(pi) * sb.primitive_coeff(pj);
120 operator_into(op, sa.prim(pi), sb.prim(pj), scale, &mut br, &mut bi);
121 }
122 }
123 // c2s each part independently (the transform is real & linear).
124 let br = to_func_1e(br, sa, sb);
125 let bi = to_func_1e(bi, sa, sb);
126 let nbf = sb.n_func();
127 place_block(&mut re, n, offs[si], offs[sj], &br, nbf);
128 place_block(&mut im, n, offs[si], offs[sj], &bi, nbf);
129 }
130 }
131 Ok(OperatorMatrix { nao: n, re, im })
132 }
133}