Skip to content

Commit d0c9cb2

Browse files
committed
Added a Pythonic version of the local diabatization (projector matrix) calculation, extended to enable state pruning, added corresponding unit tests
1 parent 7bd092f commit d0c9cb2

3 files changed

Lines changed: 347 additions & 0 deletions

File tree

src/libra_py/dyn/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
# ***********************************************************/
99

1010
__all__ = ["control_params",
11+
"local_diabatization"
1112
]
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# *********************************************************************************
2+
# * Copyright (C) 2026 Alexey V. Akimov
3+
# *
4+
# * This file is distributed under the terms of the GNU General Public License
5+
# * as published by the Free Software Foundation, either version 3 of
6+
# * the License, or (at your option) any later version.
7+
# * See the file LICENSE in the root directory of this distribution
8+
# * or <http://www.gnu.org/licenses/>.
9+
# *
10+
# *********************************************************************************/
11+
12+
"""
13+
.. module:: local_diabatization
14+
:platform: Unix, Windows
15+
:synopsis: Implements various functions needed for local diabatization
16+
.. moduleauthor:: Alexey V. Akimov
17+
18+
"""
19+
import numpy as np
20+
21+
def orthogonalized_T(T, tol=1e-10, drop=False):
22+
"""
23+
Orthonormalize a matrix T via (right) polar decomposition.
24+
25+
Parameters
26+
----------
27+
T : (N, N) complex ndarray
28+
Typically T = P^{-1}(t, t+dt), where P is an adiabatic overlap matrix.
29+
tol : float
30+
Eigenvalue cutoff for detecting ill-conditioning.
31+
drop : bool, optional
32+
If False (default), require T to be full rank and return a unitary matrix.
33+
If True, drop ill-conditioned states and return a partial isometry.
34+
35+
Returns
36+
-------
37+
U : (N, N) complex ndarray
38+
Orthonormalized transformation matrix.
39+
- If drop=False: U is unitary (U† U = I)
40+
- If drop=True: U† U is a projector
41+
42+
info : dict (only if drop=True)
43+
Dictionary with diagnostic information:
44+
- 'rank' : number of retained states
45+
- 'projector' : U† U
46+
- 'eigvals' : eigenvalues of T† T
47+
48+
Raises
49+
------
50+
ValueError
51+
If T is ill-conditioned and drop=False.
52+
53+
54+
Notes
55+
-----
56+
Is meant to replace: `CMATRIX orthogonalized_T(CMATRIX& T)` from
57+
dyn/dyn_variables_electronic.cpp
58+
59+
60+
Related theory
61+
--------------
62+
63+
We are given a generally non-unitary matrix
64+
65+
X = P^{-1}(t, t+dt)
66+
67+
where:
68+
P(t, t+dt) = ⟨ψ_adi(t) | ψ_adi(t+dt)⟩
69+
70+
is the time-overlap matrix between adiabatic states at consecutive time steps.
71+
72+
We want to define a transformed adiabatic basis:
73+
74+
|ψ̃_adi(t+dt)⟩ = |ψ_adi(t+dt)⟩ T
75+
76+
such that the overlap with the previous basis is exactly the identity:
77+
78+
⟨ψ̃_adi(t) | ψ̃_adi(t+dt)⟩ = I
79+
80+
This formally suggests T ≈ P^{-1}, but P^{-1} is not guaranteed to be unitary.
81+
Therefore, we seek a *unitary matrix unitarily equivalent to P^{-1}*.
82+
83+
### Polar decomposition
84+
Let:
85+
86+
S = X† X
87+
88+
which is Hermitian and positive-definite.
89+
90+
Define:
91+
92+
U = X S^{-1/2}
93+
94+
Then:
95+
96+
U† U = S^{-1/2} X† X S^{-1/2} = I
97+
98+
Thus, U is unitary.
99+
100+
This construction corresponds to the right polar decomposition:
101+
102+
X = U H
103+
H = (X† X)^{1/2}
104+
105+
Among all unitary matrices, U is the closest to X
106+
in the Frobenius norm sense.
107+
108+
### Final result
109+
110+
The function returns:
111+
112+
T_orth = P^{-1} (P^{-1†} P^{-1})^{-1/2}
113+
114+
which is unitary and preserves the adiabatic overlap structure
115+
while eliminating numerical drift.
116+
117+
### Handling ill-conditioned or rank-deficient inputs
118+
119+
In practical nonadiabatic dynamics, the overlap matrix P(t, t+dt) may
120+
become nearly singular due to:
121+
- large nuclear time steps,
122+
- near-degeneracies or avoided crossings,
123+
- numerical noise in the electronic structure.
124+
125+
As a result, T = P^{-1} may be ill-conditioned or rank-deficient, and
126+
the inverse square root (T† T)^{-1/2} may not exist.
127+
128+
This function supports two distinct behaviors controlled by `drop`:
129+
130+
1. drop = False (default)
131+
----------------------
132+
The function *requires* T to be full rank.
133+
134+
If one or more eigenvalues of T† T are smaller than `tol`, a
135+
ValueError is raised. This corresponds to a physically fatal loss
136+
of linear independence in the adiabatic basis and mirrors the
137+
strict behavior of the original C++ implementation.
138+
139+
In this mode, the returned matrix U is strictly unitary:
140+
141+
U† U = I.
142+
143+
2. drop = True
144+
-------------
145+
Ill-conditioned states with eigenvalues λ < tol of T† T are removed
146+
from the orthonormalization.
147+
148+
The transformation is constructed only in the well-conditioned
149+
subspace, leading to a partial isometry:
150+
151+
U† U = Π,
152+
153+
where Π is the projector onto the retained (well-conditioned)
154+
subspace.
155+
156+
This mode explicitly reduces the effective electronic subspace and
157+
should only be used when state pruning or adaptive basis reduction
158+
is intended.
159+
160+
"""
161+
162+
# Hermitian overlap
163+
S = T.conj().T @ T
164+
165+
eigvals, eigvecs = np.linalg.eigh(S)
166+
167+
# Identify well-conditioned subspace
168+
mask = eigvals > tol
169+
rank = int(np.sum(mask))
170+
171+
if rank < T.shape[0] and not drop:
172+
raise ValueError(
173+
"orthogonalized_T: T is rank-deficient or ill-conditioned; "
174+
"set drop=True to allow state reduction"
175+
)
176+
177+
# Construct S^{-1/2} (full or reduced)
178+
if drop:
179+
V = eigvecs[:, mask]
180+
D_inv_sqrt = np.diag(eigvals[mask] ** (-0.5))
181+
S_inv_sqrt = V @ D_inv_sqrt @ V.conj().T
182+
else:
183+
S_inv_sqrt = eigvecs @ np.diag(eigvals ** (-0.5)) @ eigvecs.conj().T
184+
185+
U = T @ S_inv_sqrt
186+
187+
# Diagnostics
188+
if drop:
189+
info = {
190+
"rank": rank,
191+
"projector": U.conj().T @ U,
192+
"eigvals": eigvals,
193+
}
194+
return U, info
195+
196+
return U
197+
198+
199+

unittests/test_dyn_ld.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# *********************************************************************************
2+
# * Copyright (C) 2026 Alexey V. Akimov
3+
# *
4+
# * This file is distributed under the terms of the GNU General Public License
5+
# * as published by the Free Software Foundation, either version 3 of
6+
# * the License, or (at your option) any later version.
7+
# * See the file LICENSE in the root directory of this distribution
8+
# * or <http://www.gnu.org/licenses/>.
9+
# *
10+
# *********************************************************************************/
11+
12+
import pytest
13+
import numpy as np
14+
from libra_py.dyn.local_diabatization import orthogonalized_T
15+
16+
17+
# 1. Helper utilities (used in multiple tests)
18+
def is_unitary(U, tol=1e-10):
19+
I = np.eye(U.shape[0], dtype=U.dtype)
20+
return np.linalg.norm(U.conj().T @ U - I) < tol
21+
22+
23+
# 2. Identity input (sanity check)
24+
# If T = I, the result must be exactly I.
25+
def test_identity_matrix():
26+
n = 5
27+
T = np.eye(n, dtype=np.complex128)
28+
29+
U = orthogonalized_T(T)
30+
31+
assert np.allclose(U, np.eye(n))
32+
assert is_unitary(U)
33+
34+
35+
# 3. Already-unitary matrix (should be unchanged)
36+
# If T is unitary, orthogonalization should not modify it (up to numerical noise).
37+
def test_unitary_input():
38+
rng = np.random.default_rng(0)
39+
n = 4
40+
41+
# Random unitary via QR
42+
A = rng.normal(size=(n, n)) + 1j * rng.normal(size=(n, n))
43+
Q, _ = np.linalg.qr(A)
44+
45+
U = orthogonalized_T(Q)
46+
47+
assert is_unitary(U)
48+
assert np.allclose(U, Q, atol=1e-10)
49+
50+
51+
# 4. Random non-unitary matrix
52+
# This is the main expected use case.
53+
def test_random_nonunitary_matrix():
54+
rng = np.random.default_rng(1)
55+
n = 6
56+
57+
T = rng.normal(size=(n, n)) + 1j * rng.normal(size=(n, n))
58+
U = orthogonalized_T(T)
59+
60+
# Must be unitary
61+
assert is_unitary(U)
62+
63+
# U should span the same column space as T
64+
# i.e., T = U H for some Hermitian H
65+
H = U.conj().T @ T
66+
assert np.allclose(H, H.conj().T, atol=1e-10)
67+
68+
69+
# 5. Phase consistency (diagonal matrix)
70+
# For diagonal matrices, the result should be pure phases.
71+
def test_diagonal_matrix():
72+
phases = np.exp(1j * np.array([0.1, 1.3, -2.0]))
73+
scales = np.array([2.0, 0.5, 3.0])
74+
75+
T = np.diag(scales * phases)
76+
77+
U = orthogonalized_T(T)
78+
79+
# Result must be diagonal unitary with same phases
80+
assert is_unitary(U)
81+
assert np.allclose(np.diag(U), phases)
82+
83+
84+
85+
# 6. Gauge optimality (closest unitary)
86+
# Checks Frobenius optimality property
87+
def test_closest_unitary_property():
88+
rng = np.random.default_rng(3)
89+
n = 4
90+
91+
T = rng.normal(size=(n, n)) + 1j * rng.normal(size=(n, n))
92+
U = orthogonalized_T(T)
93+
94+
# Compare against a random unitary
95+
A = rng.normal(size=(n, n)) + 1j * rng.normal(size=(n, n))
96+
Q, _ = np.linalg.qr(A)
97+
98+
dist_U = np.linalg.norm(T - U)
99+
dist_Q = np.linalg.norm(T - Q)
100+
101+
assert dist_U <= dist_Q + 1e-10
102+
103+
104+
105+
# 7. Full-rank → unitary
106+
def test_full_rank_unitary():
107+
rng = np.random.default_rng(0)
108+
n = 5
109+
T = rng.normal(size=(n, n)) + 1j * rng.normal(size=(n, n))
110+
111+
U = orthogonalized_T(T)
112+
assert np.allclose(U.conj().T @ U, np.eye(n), atol=1e-10)
113+
114+
115+
# 8. Near-singular → raises by default
116+
def test_near_singular_raises():
117+
rng = np.random.default_rng(1)
118+
n = 5
119+
120+
U0, _ = np.linalg.qr(rng.normal(size=(n, n)))
121+
V0, _ = np.linalg.qr(rng.normal(size=(n, n)))
122+
s = np.ones(n)
123+
s[2] = 1e-8
124+
125+
T = U0 @ np.diag(s) @ V0.conj().T
126+
127+
with pytest.raises(ValueError):
128+
orthogonalized_T(T, tol=1e-6)
129+
130+
# 9. Near-singular → reduced subspace
131+
def test_near_singular_drop():
132+
rng = np.random.default_rng(2)
133+
n = 5
134+
135+
U0, _ = np.linalg.qr(rng.normal(size=(n, n)))
136+
V0, _ = np.linalg.qr(rng.normal(size=(n, n)))
137+
s = np.ones(n)
138+
s[-1] = 1e-8
139+
140+
T = U0 @ np.diag(s) @ V0.conj().T
141+
142+
U, info = orthogonalized_T(T, tol=1e-6, drop=True)
143+
144+
P = info["projector"]
145+
assert info["rank"] == 4
146+
assert np.allclose(P @ P, P, atol=1e-10)
147+

0 commit comments

Comments
 (0)