diff --git a/.github/workflows/python-testsuite.yml b/.github/workflows/python-testsuite.yml index 89555b84..6da8cf8b 100644 --- a/.github/workflows/python-testsuite.yml +++ b/.github/workflows/python-testsuite.yml @@ -9,6 +9,16 @@ on: pull_request: branches: [ main ] +env: + # juliacall (PythonCall.jl) is the backend both python-sscha and tdscha + # prefer: it has no libpython coupling, so it works in any interpreter -- + # including the ones the MPI tests launch through mpirun, which is where + # the PyJulia/PyCall backend fails to initialize. Pin it explicitly so a + # transitively installed pyjulia can never silently take over. + SSCHA_JULIA_BACKEND: juliacall + OMP_NUM_THREADS: 1 + JULIA_NUM_THREADS: 1 + jobs: build: @@ -18,26 +28,18 @@ jobs: matrix: python-version: [3.9] - services: - rabbitmq: - image: rabbitmq:latest - ports: - - 5672:5672 - steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest~=6.0 pgtest~=1.3 aiida-core~=2.3 aiida-quantumespresso~=4.3 - - aiida-pseudo install + pip install flake8 pytest~=6.0 - name: Lint with flake8 run: | @@ -55,7 +57,19 @@ jobs: git clone https://github.com/SSCHAcode/python-sscha.git pip install meson meson-python ninja - pip install numpy scipy mpi4py spglib julia + # matplotlib is imported at module level by some collected tests. + pip install numpy scipy spglib matplotlib juliacall + + # mpi4py MUST be built against the same MPI that owns mpirun. Its + # binary wheel bundles its own runtime, and next to the mpich + # installed above that makes every process initialise a singleton + # COMM_WORLD: "mpirun -np 2" silently becomes two unrelated serial + # jobs, and the MPI tests pass while testing nothing. + pip install --no-binary=mpi4py mpi4py + mpirun -np 2 python -c " + from mpi4py import MPI + assert MPI.COMM_WORLD.Get_size() == 2, 'mpirun and mpi4py disagree' + " cd CellConstructor pip install --no-build-isolation . @@ -69,18 +83,27 @@ jobs: # Install the tdscha package pip install --no-build-isolation . - # Install julia requirements - python -c 'import julia; julia.install()' + - name: Warm up the Julia environment + run: | + # Resolve, install and precompile the Julia side ONCE, serially. + # Several tests launch interpreters through mpirun; if those were the + # first to touch ~/.julia they would race each other on the same + # depot, and a corrupted precompile cache then breaks every later + # subprocess -- including the single-rank ones. + python -c " + import sscha.JuliaExt, tdscha.JuliaExt + sscha.JuliaExt.get_main() + tdscha.JuliaExt.get_main() + print('Julia backend ready') + " + - name: Test with pytest - env: - OMP_NUM_THREADS: 1 - JULIA_NUM_THREADS: 1 run: | cd tests rm -rf __pycache__ # Test excluding very long running tests pytest -v -m "not release" - + - name: Validate documentation run: | # Validate code snippets in documentation diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..21c76e9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.py[cod] +/build/ + +# Local interpolation benchmarks and generated reports are intentionally +# outside the pull-request branch. +/report/interpolation/ +/FastHessianPaper.pdf + +# Runtime output produced by the q-space integration tests. +/tests/test_julia/julia/ +/tests/test_julia/wigner_nowigner_julia/ +/tests/test_lanczos_fast/julia/ +/tests/test_lanczos_fast/normal/ +/tests/test_lanczos_fast/test_c_code_lanczos/mode10.* diff --git a/Modules/DynamicalLanczos.py b/Modules/DynamicalLanczos.py index 015d7968..710fafc1 100644 --- a/Modules/DynamicalLanczos.py +++ b/Modules/DynamicalLanczos.py @@ -33,6 +33,7 @@ from tdscha.Parallel import pprint as print from tdscha.Parallel import * import tdscha.Perturbations as perturbations +import tdscha.Spectroscopy as Spectroscopy # The Julia runtime is booted lazily by JuliaExt at the first actual use @@ -49,6 +50,18 @@ __EPSILON__ = 1e-12 N_REP_ORTH = 1 +_TWO_PHONON_RAMAN_DISABLED = ( + "Two-phonon Raman preparation is disabled because the previous " + "configuration-dependent implementation is unvalidated and can produce " + "incorrect perturbations. Only equilibrium one-phonon Raman is supported." +) + +_CONFIGURATION_DEPENDENT_IR_DISABLED = ( + "Configuration-dependent and two-phonon IR preparation is disabled " + "because the previous implementation is unvalidated. Use prepare_ir " + "or the Spectroscopy class for equilibrium one-phonon IR." +) + try: from ase.units import create_units units = create_units("2006")#Rydberg, Bohr @@ -228,6 +241,13 @@ def __init__(self, ensemble = None, mode = None, unwrap_symmetries = False, sele self.trans_operators = None # list of (n_modes, n_modes) T_R^mode matrices self.trans_cart_perms = None # list of Cartesian permutation index arrays for fast projection + # Optional observable-stabilizer reduction configured by the public + # Spectroscopy workflow. Indices are 1-based for the Julia kernels. + self._spectroscopy_symmetry_rotations = () + self._spectroscopy_coset_indices = None + self._spectroscopy_stabilizer_indices = None + self._spectroscopy_characters = None + # Set to True if we want to use the Wigner equations self.use_wigner = use_wigner @@ -485,6 +505,7 @@ def reset(self): self.s_norm = [] self.krilov_basis = [] # The basis of the krilov subspace self.arnoldi_matrix = [] # If requested, the upper triangular arnoldi matrix + self._clear_spectroscopy_symmetry() def init(self, use_symmetries = True): @@ -637,6 +658,10 @@ def prepare_symmetrization(self, no_sym = False, verbose = True, symmetries = No """ self.initialized = True + # A reduction is tied to one prepared perturbation and one exact + # symmetry basis. Reinitializing either must never retain stale + # coset indices from an earlier calculation. + self._clear_spectroscopy_symmetry() # All the rest is deprecated in the Fast Lanczos implementation # As the symmetrization is performed by unwrapping the ensemble @@ -655,6 +680,16 @@ def prepare_symmetrization(self, no_sym = False, verbose = True, symmetries = No self.N_degeneracy = np.ones(self.n_modes, dtype = np.intc) self.degenerate_space = [np.array([i], dtype = np.intc) for i in range(self.n_modes)] self.sym_block_id = np.arange(self.n_modes).astype(np.intc) + self.n_syms = 1 + self._spectroscopy_symmetry_rotations = (np.eye(3),) + if self.mode == MODE_FAST_JULIA: + self.sym_julia = np.ones((self.n_modes, 1, 1, 1), + dtype=TYPE_DP) + self.deg_julia = np.arange( + self.n_modes, dtype=np.int32)[:, None] + JuliaExt.get_main().init_sparse_symmetries( + self.sym_julia, self.N_degeneracy, self.deg_julia, + self.sym_block_id) return t1 = time.time() @@ -716,6 +751,13 @@ def prepare_symmetrization(self, no_sym = False, verbose = True, symmetries = No # Replace super_symmetries with PG-only for the rest of the function super_symmetries = pg_symmetries + super_lattice = np.asarray(super_structure.unit_cell, dtype=float).T + inverse_super_lattice = np.linalg.inv(super_lattice) + self._spectroscopy_symmetry_rotations = tuple( + super_lattice @ np.asarray(symmetry[:, :3], dtype=float) + @ inverse_super_lattice + for symmetry in super_symmetries) + # Get the symmetry matrix in the polarization space # Translations are needed, as this method needs a complete basis. pol_symmetries, basis = CC.symmetries.GetSymmetriesOnModesDeg(super_symmetries, super_structure, self.pols, self.w) @@ -766,6 +808,69 @@ def prepare_symmetrization(self, no_sym = False, verbose = True, symmetries = No if verbose: print("Time to create the block_id array: {} s".format(t2-t1)) + def configure_spectroscopy_symmetry( + self, group_rotations, stabilizer, characters, cosets, + tolerance=1e-7): + """Configure the stabilizer/coset average for one optical run. + + The public workflow plans symmetries in the unit-cell Cartesian + representation. This method maps those rotations onto the exact + ordering used by the backend, so no assumption about spglib ordering + leaks into the execution layer. + """ + if self.mode != MODE_FAST_JULIA or len(cosets) >= self.n_syms: + self._clear_spectroscopy_symmetry() + return + + planned = tuple(np.asarray(item, dtype=float) + for item in group_rotations) + actual = tuple(np.asarray(item, dtype=float) + for item in self._spectroscopy_symmetry_rotations) + + def map_index(planned_index): + matches = [index for index, rotation in enumerate(actual) + if np.allclose(planned[planned_index], rotation, + atol=tolerance, rtol=0)] + if len(matches) != 1: + raise ValueError( + "Spectroscopy symmetry cannot be mapped uniquely onto " + "the Lanczos backend") + return matches[0] + 1 + + self._spectroscopy_coset_indices = np.asarray( + [map_index(coset[0]) for coset in cosets], dtype=np.int32) + self._spectroscopy_stabilizer_indices = np.asarray( + [map_index(index) for index in stabilizer], dtype=np.int32) + self._spectroscopy_characters = np.asarray( + characters, dtype=TYPE_DP) + + def _clear_spectroscopy_symmetry(self): + """Clear reduction metadata whenever the engine basis is reset.""" + self._spectroscopy_coset_indices = None + self._spectroscopy_stabilizer_indices = None + self._spectroscopy_characters = None + + def _spectroscopy_reduction_arguments(self): + """Return Julia-ready optional coset/projector arrays.""" + empty_indices = np.empty(0, dtype=np.int32) + empty_characters = np.empty(0, dtype=TYPE_DP) + return ( + self._spectroscopy_coset_indices + if self._spectroscopy_coset_indices is not None + else empty_indices, + self._spectroscopy_stabilizer_indices + if self._spectroscopy_stabilizer_indices is not None + else empty_indices, + self._spectroscopy_characters + if self._spectroscopy_characters is not None + else empty_characters, + ) + + def _spectroscopy_symmetry_count(self, full_count): + if self._spectroscopy_coset_indices is None: + return int(full_count) + return len(self._spectroscopy_coset_indices) + # Ns, dumb, dump = np.shape(pol_symmetries) # # Now we can pull out the translations @@ -985,6 +1090,51 @@ def load_from_input_files(self, root_name = "tdscha", directory="."): + def _build_raman_vector(self, pol_vec_in = np.array([1,0,0]), + pol_vec_out = np.array([1,0,0]), mixed = False, + pol_in_2 = None, pol_out_2 = None, + unpolarized = None, normalized = True): + """Build a unit-cell Raman perturbation vector. + + ``unpolarized=None`` returns the contraction with the supplied light + polarizations. When ``mixed`` is true, the second contraction is + added coherently. + + Unpolarized indices 0--6 return, in order, the combinations + ``trace``, ``xx-yy``, ``xx-zz``, ``yy-zz``, ``xy``, ``xz``, and + ``yz``. With ``normalized=True`` these are multiplied by ``1/3``, + ``1/sqrt(2)`` (indices 1--3), and ``sqrt(3)`` (indices 4--6), as used + by :meth:`prepare_raman`. ``normalized=False`` retains the legacy + raw-component convention of :meth:`prepare_unpolarized_raman`. + """ + if self.dyn.raman_tensor is None: + raise ValueError( + "No Raman tensor found; cannot initialize the Raman response") + + if unpolarized is None: + coefficients = Spectroscopy.raman_coefficients_from_polarizations( + pol_vec_in, pol_vec_out, symmetric=False) + if mixed: + if pol_in_2 is None or pol_out_2 is None: + raise ValueError( + "mixed=True requires pol_in_2 and pol_out_2") + coefficients += ( + Spectroscopy.raman_coefficients_from_polarizations( + pol_in_2, pol_out_2, symmetric=False)) + else: + convention = "normalized" if normalized else "raw" + coefficients = Spectroscopy.get_raman_component( + unpolarized).coefficients(convention) + + return np.array(Spectroscopy.build_raman_vector( + self.dyn.raman_tensor, coefficients), copy=True) + + def _prepare_gamma_cartesian_perturbation(self, vector): + """Prepare a unit-cell Cartesian Gamma perturbation in real space.""" + n_supercell = np.prod(self.dyn.GetSupercell()) + supercell_vector = np.tile(np.asarray(vector).ravel(), n_supercell) + self.prepare_perturbation(supercell_vector, masses_exp=-1) + def prepare_raman(self, pol_vec_in = np.array([1,0,0]), pol_vec_out = np.array([1,0,0]), mixed = False, pol_in_2 = None, pol_out_2 = None, unpolarized: int = None): """ PREPARE LANCZOS FOR RAMAN SPECTRUM @@ -1000,6 +1150,11 @@ def prepare_raman(self, pol_vec_in = np.array([1,0,0]), pol_vec_out = np.array([ The polarization vector of the incoming light pol_vec_out : ndarray (size = 3) The polarization vector for the outcoming light + mixed : bool + If True, coherently add the contraction specified by + pol_in_2 and pol_out_2. + pol_in_2, pol_out_2 : ndarray (size = 3) or None + The second pair of light polarizations when mixed=True. unpolarized : int or None The perturbation for unpolarized raman (if different from None, overrides the behaviour of pol_vec_in and pol_vec_out). Indices goes from 0 to 6 (included). @@ -1015,98 +1170,16 @@ def prepare_raman(self, pol_vec_in = np.array([1,0,0]), pol_vec_out = np.array([ The total unpolarized raman intensity is 45 alpha^2 + 7 beta^2 """ - - # Check if the raman tensor is present - assert not self.dyn.raman_tensor is None, "Error, no Raman tensor found. Cannot initialize the Raman responce" - - # Get the raman vector (apply the ASR and contract the raman tensor with the polarization vectors) - raman_v = self.dyn.GetRamanVector(pol_vec_in, pol_vec_out) - if mixed: print('Prepare Raman') print('Adding other component of the Raman tensor') - raman_v += self.dyn.GetRamanVector(pol_in_2, pol_out_2) - - # Get the raman vector in the supercelld - n_supercell = np.prod(self.dyn.GetSupercell()) - - if unpolarized is None: - # Get the raman vector - raman_v = self.dyn.GetRamanVector(pol_vec_in, pol_vec_out) - - # Get the raman vector in the supercelld - new_raman_v = np.tile(raman_v.ravel(), n_supercell) - - # Convert in the polarization basis and store the intensity - self.prepare_perturbation(new_raman_v, masses_exp=-1) - else: - px = np.array([1,0,0]) - py = np.array([0,1,0]) - pz = np.array([0,0,1]) - - if unpolarized == 0: - # Alpha - raman_v = self.dyn.GetRamanVector(px, px) - new_raman_v = np.tile(raman_v.ravel(), n_supercell) / 3 - self.prepare_perturbation(new_raman_v, masses_exp=-1) - - raman_v = self.dyn.GetRamanVector(py, py) - new_raman_v = np.tile(raman_v.ravel(), n_supercell) / 3 - self.prepare_perturbation(new_raman_v, masses_exp=-1, add = True) - - raman_v = self.dyn.GetRamanVector(pz, pz) - new_raman_v = np.tile(raman_v.ravel(), n_supercell) / 3 - self.prepare_perturbation(new_raman_v, masses_exp=-1, add = True) - elif unpolarized == 1: - # (xx -yy)^2 / 2 - raman_v = self.dyn.GetRamanVector(px, px) - new_raman_v = np.tile(raman_v.ravel(), n_supercell) / np.sqrt(2) - self.prepare_perturbation(new_raman_v, masses_exp=-1) - - raman_v = self.dyn.GetRamanVector(py, py) - new_raman_v = - np.tile(raman_v.ravel(), n_supercell) / np.sqrt(2) - self.prepare_perturbation(new_raman_v, masses_exp=-1, add = True) - elif unpolarized == 2: - # beta_2 = (xx -zz)^2 / 2 - raman_v = self.dyn.GetRamanVector(px, px) - new_raman_v = np.tile(raman_v.ravel(), n_supercell) / np.sqrt(2) - self.prepare_perturbation(new_raman_v, masses_exp=-1) - - raman_v = self.dyn.GetRamanVector(pz, pz) - new_raman_v = - np.tile(raman_v.ravel(), n_supercell) / np.sqrt(2) - self.prepare_perturbation(new_raman_v, masses_exp=-1, add = True) - elif unpolarized == 3: - # beta_2 = (yy -zz)^2 / 2 - raman_v = self.dyn.GetRamanVector(py, py) - new_raman_v = np.tile(raman_v.ravel(), n_supercell) / np.sqrt(2) - self.prepare_perturbation(new_raman_v, masses_exp=-1) - - raman_v = self.dyn.GetRamanVector(pz, pz) - new_raman_v = - np.tile(raman_v.ravel(), n_supercell) / np.sqrt(2) - self.prepare_perturbation(new_raman_v, masses_exp=-1, add = True) - elif unpolarized == 4: - # beta_2 = 3 xy^2 - raman_v = self.dyn.GetRamanVector(px, py) - new_raman_v = np.tile(raman_v.ravel(), n_supercell) * np.sqrt(3) - self.prepare_perturbation(new_raman_v, masses_exp=-1) - elif unpolarized == 5: - # beta_2 = 3 yz^2 - raman_v = self.dyn.GetRamanVector(py, pz) - new_raman_v = np.tile(raman_v.ravel(), n_supercell) * np.sqrt(3) - self.prepare_perturbation(new_raman_v, masses_exp=-1) - elif unpolarized == 6: - # beta_2 = 3 xz^2 - raman_v = self.dyn.GetRamanVector(px, pz) - new_raman_v = np.tile(raman_v.ravel(), n_supercell) * np.sqrt(3) - self.prepare_perturbation(new_raman_v, masses_exp=-1) - else: - raise ValueError("Error, unpolarized must be between [0, ... ,6] got invalid {}.".format(unpolarized)) + raman_v = self._build_raman_vector( + pol_vec_in=pol_vec_in, pol_vec_out=pol_vec_out, mixed=mixed, + pol_in_2=pol_in_2, pol_out_2=pol_out_2, + unpolarized=unpolarized, normalized=True) - - - # Convert in the polarization basis and store the intensity - self.prepare_perturbation(new_raman_v, masses_exp=-1) + self._prepare_gamma_cartesian_perturbation(raman_v) def get_prefactors_unpolarized_raman(self, index): """ @@ -1117,22 +1190,11 @@ def get_prefactors_unpolarized_raman(self, index): The prefactors corresponds to the components of the unpolarized raman signal """ - labels = [i for i in range(7)] - if not(index in labels): - raise ValueError('{} should be in {}'.format(index, labels)) - - dictionary = {'(xx+yy+zz)^2' : 45/9,\ - '(xx-yy)^2' : 7/2,\ - '(xx-zz)^2' : 7/2,\ - '(yy-zz)^2' : 7/2,\ - '(xy)^2' : 7*3,\ - '(xz)^2' : 7*3,\ - '(yz)^2' : 7*3} - - keys = list(dictionary.keys()) - - - return dictionary[keys[index]] + return Spectroscopy.get_raman_component(index).weight("raw") + + def get_unpolarized_raman_weights(self, convention="normalized"): + """Return the seven weights for a named Raman component convention.""" + return Spectroscopy.get_unpolarized_raman_weights(convention) def prepare_unpolarized_raman(self, index = 0, debug = False): """ @@ -1149,53 +1211,13 @@ def prepare_unpolarized_raman(self, index = 0, debug = False): + 7/2 [(xx-yy)^2 + (xx-zz)^2 + (yy-zz)^2] + 7 * 3 [(xy)^2 + (yz)^2 + (xz)^2] """ - # Check if the raman tensor is present - assert not self.dyn.raman_tensor is None, "Error, no Raman tensor found. Cannot initialize the Raman responce" - - labels = [i for i in range(7)] - if not(index in labels): - raise ValueError('{} should be in {}'.format(index, labels)) - - epols = {'x' : np.array([1,0,0]),\ - 'y' : np.array([0,1,0]),\ - 'z' : np.array([0,0,1])} - - # (xx + yy + zz)^2 - if index == 0: - raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) - raman_v += self.dyn.GetRamanVector(epols['y'], epols['y']) - raman_v += self.dyn.GetRamanVector(epols['z'], epols['z']) - # (xx - yy)^2 - elif index == 1: - raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) - raman_v -= self.dyn.GetRamanVector(epols['y'], epols['y']) - # (xx - zz)^2 - elif index == 2: - raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) - raman_v -= self.dyn.GetRamanVector(epols['z'], epols['z']) - # (yy - zz)^2 - elif index == 3: - raman_v = self.dyn.GetRamanVector(epols['y'], epols['y']) - raman_v -= self.dyn.GetRamanVector(epols['z'], epols['z']) - # (xy)^2 - elif index == 4: - raman_v = self.dyn.GetRamanVector(epols['x'], epols['y']) - # (xz)^2 - elif index == 5: - raman_v = self.dyn.GetRamanVector(epols['x'], epols['z']) - # (yz)^2 - elif index == 6: - raman_v = self.dyn.GetRamanVector(epols['y'], epols['z']) + raman_v = self._build_raman_vector( + unpolarized=index, normalized=False) if debug: np.save('raman_v_{}'.format(index), raman_v) - # Get the raman vector in the supercelld - n_supercell = np.prod(self.dyn.GetSupercell()) - new_raman_v = np.tile(raman_v.ravel(), n_supercell) - - # Convert in the polarization basis and store the intensity - self.prepare_perturbation(new_raman_v, masses_exp=-1) + self._prepare_gamma_cartesian_perturbation(raman_v) if debug: print('[NEW] Pertubation modulus with eq Raman tensors = {}'.format(self.perturbation_modulus)) @@ -1204,431 +1226,29 @@ def prepare_unpolarized_raman(self, index = 0, debug = False): return - def prepare_unpolarized_raman_FT(self, index = 0, debug = False, eq_raman_tns = None, use_symm = True,\ - ens_av_raman = None, raman_tns_ens = None, add_2ph = True): - """ - PREPARE UNPOLARIZED RAMAN SIGNAL CONSIDERING FLUCTUATIONS OF THE RAMAN TENSOR - ============================================================================= - - The raman tensor is read from the dynamical matrix provided by the original ensemble. - - The perturbations are prepared accordin to the formula (see https://doi.org/10.1021/jp5125266) - - ..math: - - I_unpol = 45/9 (xx + yy + zz)^2 - + 7/2 [(xx-yy)^2 + (xx-zz)^2 + (yy-zz)^2] - + 7 * 3 [(xy)^2 + (yz)^2 + (xz)^2] - - Parameters: - ----------- - -index: the pol component of the unpolarized signal - -debug: if true we save the second order Raman tensor - -eq_raman_tns: np.array with shape (3, 3, 3 * N_at_uc), the equilibirum raman tensor - -use_symm: bool, if True symmetries are enforced - -ens_av_raman: the ensemble on which we compute the averages of the Raman tensors - -raman_tns_ens: np.array with shape (N_conf, 3, 3, 3 * N_at_sc), the raman tensors on the displaced configruations - """ - # Check if the raman tensor is present - assert not self.dyn.raman_tensor is None, "Error, no Raman tensor found. Cannot initialize the Raman responce" - - labels = [i for i in range(7)] - if not(index in labels): - raise ValueError('{} should be in {}'.format(index, labels)) - - epols = {'x' : np.array([1,0,0]),\ - 'y' : np.array([0,1,0]),\ - 'z' : np.array([0,0,1])} - - # (xx + yy + zz)^2 - if index == 0: - # raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) - # raman_v += self.dyn.GetRamanVector(epols['y'], epols['y']) - # raman_v += self.dyn.GetRamanVector(epols['z'], epols['z']) - self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ - pol_in = epols['x'], pol_out = epols['x'],\ - mixed = True,\ - pol_in_2 = epols['y'], pol_out_2 = epols['y'],\ - pol_in_3 = epols['z'], pol_out_3 = epols['z'],\ - add_two_ph = add_2ph, symmetrize = use_symm,\ - ensemble = ens_av_raman,\ - save_raman_tensor2 = debug, file_raman_tensor2 = 'xx_plus_yy_plus_zz') - # (xx - yy)^2 - elif index == 1: - # raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) - # raman_v -= self.dyn.GetRamanVector(epols['y'], epols['y']) - # NB we put just one minus sign because the component is (xx - yy)^2 - self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ - pol_in = epols['x'], pol_out = epols['x'],\ - mixed = True,\ - pol_in_2 = -epols['y'], pol_out_2 = epols['y'],\ - pol_in_3 = np.zeros(3), pol_out_3 = np.zeros(3),\ - add_two_ph = add_2ph, symmetrize = use_symm,\ - ensemble = ens_av_raman,\ - save_raman_tensor2 = debug, file_raman_tensor2 = 'xx_minus_yy') - # (xx - zz)^2 - elif index == 2: - # raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) - # raman_v -= self.dyn.GetRamanVector(epols['z'], epols['z']) - self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ - pol_in = epols['x'], pol_out = epols['x'],\ - mixed = True,\ - pol_in_2 = -epols['z'], pol_out_2 = epols['z'],\ - pol_in_3 = np.zeros(3), pol_out_3 = np.zeros(3),\ - add_two_ph = add_2ph, symmetrize = use_symm,\ - ensemble = ens_av_raman,\ - save_raman_tensor2 = debug, file_raman_tensor2 = 'xx_minus_zz') - # (yy - zz)^2 - elif index == 3: - # raman_v = self.dyn.GetRamanVector(epols['y'], epols['y']) - # raman_v -= self.dyn.GetRamanVector(epols['z'], epols['z']) - self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ - pol_in = epols['y'], pol_out = epols['y'],\ - mixed = True,\ - pol_in_2 = -epols['z'], pol_out_2 = epols['z'],\ - pol_in_3 = np.zeros(3), pol_out_3 = np.zeros(3),\ - add_two_ph = add_2ph, symmetrize = use_symm,\ - ensemble = ens_av_raman,\ - save_raman_tensor2 = debug, file_raman_tensor2 = 'yy_minus_zz') - # (xy)^2 - elif index == 4: - # raman_v = self.dyn.GetRamanVector(epols['x'], epols['y']) - self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ - pol_in = epols['x'], pol_out = epols['y'],\ - mixed = False,\ - add_two_ph = add_2ph, symmetrize = use_symm,\ - ensemble = ens_av_raman,\ - save_raman_tensor2 = debug, file_raman_tensor2 = 'xy_square') - # (xz)^2 - elif index == 5: - # raman_v = self.dyn.GetRamanVector(epols['x'], epols['z']) - self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ - pol_in = epols['x'], pol_out = epols['z'],\ - mixed = False,\ - add_two_ph = add_2ph, symmetrize = use_symm,\ - ensemble = ens_av_raman,\ - save_raman_tensor2 = debug, file_raman_tensor2 = 'xz_square') - # (yz)^2 - elif index == 6: - # raman_v = self.dyn.GetRamanVector(epols['y'], epols['z']) - self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ - pol_in = epols['y'], pol_out = epols['z'],\ - mixed = False,\ - add_two_ph = add_2ph, symmetrize = use_symm,\ - ensemble = ens_av_raman,\ - save_raman_tensor2 = debug, file_raman_tensor2 = 'yz_square') - - return - - - - def prepare_anharmonic_raman_FT(self, raman = None, raman_eq = None,\ - pol_in = np.array([1.,0.,0.]), pol_out = np.array([1.,0.,0.]),\ - mixed = False, pol_in_2 = None, pol_out_2 = None,\ - pol_in_3 = None, pol_out_3 = None,\ - add_two_ph = False, symmetrize = False, ensemble = None,\ - save_raman_tensor2 = False, file_raman_tensor2 = None): - r""" - PREPARE THE PSI VECTOR FOR ANHARMONIC RAMAN SPECTRUM CALCULATION (NEW VERSION) - =========================================================================== - - This works only with the Wigner representation if we add the two phonons effect. - Prepare the psi vector for RAMAN spectrum considering position-dependent raman tensors. - - Parameters: - ----------- - -raman: nd.array (N_configs, E_comp, E_comp, 3 * N_at_sc), - the Raman tensor for all configurations. - Indices are: Number of configuration, electric field component, - electric field component, atomic coordinates in sc. - rama_eq: nd.array, (E_comp, E_comp, 3 * N_at_uc), the effective charges at equilibrium. - Indices are: electric field component, - electric field component, atomic coordinate in uc. - -pol_in: nd.array, the polarization of in-out light. default is x - -pol_out: nd.array, the polarization of in-out light. default is x - -mixed: if True we can study the one and two phonon response to - pol_in \cdto \Xi \cdot pol_in + pol_in_2 \cdto \Xi \cdot pol_in_2 + pol_in_3 \cdto \Xi \cdot pol_in_3 - (\Xi is the Raman tensor) - -pol_in_2: nd.array, the polarization of in-out light. default is None - -pol_out_2: nd.array, the polarization of in-out light. default is None - -pol_in_3: nd.array, the polarization of in-out light. default is None - -pol_out_3: nd.array, the polarization of in-out light. default is None - -add_two_ph: bool, if True two phonon processes are included in the calculation - -symmetrize: bool, if True the first/second order Raman tensors are symmetrized - -ensemble: a scha ensemble object for computing the averages - -save_raman_tensor2: bool if True we save the second order Raman tensor - """ - if not self.use_wigner and add_two_ph: - raise NotImplementedError('The two phonon processes are implemented only in Wigner') - - if raman is None: - raise ValueError('Must specify the raman tensors for all configurations!') - - if mixed: - #Check that we have the other polarization vectors - if (pol_in_2 is None) or (pol_out_2 is None): - raise ValueError('Must specify pol_in_2 pol_out_2 if mixed = True!') - - if (pol_in_3 is None) or (pol_out_3 is None): - raise ValueError('Must specify pol_in_3 pol_out_3 if mixed = True!') - - if len(pol_in_2) != 3 or len(pol_out_2) != 3: - raise ValueError('pol_in_2 pol_out_2 must be array of len 3') - - if len(pol_in_3) != 3 or len(pol_out_3) != 3: - raise ValueError('pol_in_3 pol_out_3 must be array of len 3') - - - print() - print('PREPARE THE RAMAN ANHARMONIC SPECTRUM CALCULATION') - print('=================================================') - print('Are we considering two ph effects? = {}'.format(add_two_ph)) - print('Are we using Wigner? = {}'.format(self.use_wigner)) - print('Are we symmetrizing the raman tensor? = {}'.format(symmetrize)) - print() - if ensemble is not None: - Nconf = ensemble.N - else: - Nconf = self.N - - required = 'N_conf - E_field - E_field - 3 * N_at_sc' - assert raman.shape[0] == Nconf, 'The raman tensor in input have the wrong shape. The required is {}'.format(required) - assert raman.shape[1] == 3, 'The raman tensor in input have the wrong shape. The required is {}'.format(required) - assert raman.shape[2] == 3, 'The raman tensor in input have the wrong shape. The required is {}'.format(required) - assert raman.shape[3] == self.nat * 3, 'The raman tensor in input have the wrong shape. The required is {}'.format(required) - - # alpha is the polarizability - - # Get the average of the raman tensor, np.array with shape = (3, 3, 3 * N_at_sc) - d1alpha_dR_av = perturbations.get_d1alpha_dR_av(ensemble, raman, symmetrize = symmetrize) - - # Get the supercell dyn then set the raman tensor euqal to d1alpha_dR_av - sc_dyn = self.dyn.GenerateSupercellDyn(self.dyn.GetSupercell()) - sc_dyn.raman_tensor = d1alpha_dR_av - - # Get the Raman vector np.array (3 * N_at_sc) - raman_vector_sc = sc_dyn.GetRamanVector(pol_in, pol_out) - - if mixed: - print('ONE PH SECTOR adding compoent pol_in_2 pol_out_2 of the Raman tensor') - raman_vector_sc += sc_dyn.GetRamanVector(pol_in_2, pol_out_2) - print('ONE PH SECTOR adding compoent pol_in_3 pol_out_3 of the Raman tensor') - raman_vector_sc += sc_dyn.GetRamanVector(pol_in_3, pol_out_3) - - - # Now rescale by the mass and go in polarizaiton basis - self.prepare_perturbation(raman_vector_sc, masses_exp = -1) - print('[NEW] Pertubation modulus with one ph effects only = {}'.format(self.perturbation_modulus)) - print() - - # NOW PREPARE THE SECOND RAMAN TENSOR - if add_two_ph: - if raman_eq is not None: - print('[NEW] Getting the equilibirum RAMAN tensor...') - print() - n_supercell = np.prod(self.dyn.GetSupercell()) - # raman_eq is np.array with shape = (E_field, E_field, N_at_uc * 3) - raman_eq_size = np.shape(raman_eq) - MSG = """ - Error, raman tns of the wrong shape: {} - """.format(raman_eq_size) - assert len(raman_eq_size) == 3, MSG - if not self.ignore_small_w: - assert raman_eq_size[2] * n_supercell == self.nat * 3 #self.n_modes + 3 - assert raman_eq_size[0] == raman_eq_size[1] == 3 - - # Get the raman tensor in the supercell (E_field, E_filed, 3 * N_at_sc) - raman_eq_gamma = np.zeros((3, 3, 3 * n_supercell * self.dyn.structure.N_atoms), dtype = type(raman_eq[0,0,0])) - raman_eq_gamma = np.tile(raman_eq, n_supercell) - - print('[NEW] Getting the two phonon contribution in RAMAN...') - - # d2M_dR np.array with shape = (3 * N_atoms, 3 * N_atoms, Efield) - if raman_eq is not None: - print('[NEW] Subtracting the equilibirum RAMAN tensor...') - # raman - raman_eq_gamma, np.array with shape = (N_configs, Efield, Efield, 3 * N_at_sc) - # THE RESULT HAS shape = (Efield, Efield, 3 * N_at_sc, 3 * N_at_sc) - d2alpha_dR = perturbations.get_d2alpha_dR_av(ensemble, raman - raman_eq_gamma, None, symmetrize = symmetrize) - else: - # THE RESULT HAS shape = (Efield, Efield, 3 * N_at_sc, 3 * N_at_sc) - d2alpha_dR = perturbations.get_d2alpha_dR_av(ensemble, raman, None, symmetrize = symmetrize) - - print('[NEW] Divide by the masses') - # Divide by the masses of the atoms in the supercell shape = (Efield, Efield, 3 * N_at_sc, 3 * N_at_sc) - d2alpha_dR = np.einsum('c, abcd, d -> abcd', np.sqrt(self.m)**-1, d2alpha_dR, np.sqrt(self.m)**-1) - - if save_raman_tensor2: - print('[NEW] Saving the second-order SCHA Raman tensor') - np.save('{}'.format(file_raman_tensor2), d2alpha_dR) - return - - print('[NEW] Go in polarization basis') - # Now go in polarization basis, np.array with shape = (E_field, E_field, n_modes, n_modes) - # d2alpha_dR_muspace = np.einsum('cm, abcd, dn -> abmn', self.pols, d2alpha_dR, self.pols) - # -> substitute - tmp = np.einsum('abcd, cm -> abmd', d2alpha_dR, self.pols) - d2alpha_dR_muspace = np.einsum('abmd, dn -> abmn', tmp, self.pols) - - # Project along the direction of the filed, np.array with shape = (n_modes, n_modes) - dXi_dR_muspace = np.einsum('abmn, a, b -> mn', d2alpha_dR_muspace, pol_in, pol_out) - - if mixed: - print('TWO PH SECTOR adding component pol_in_2 pol_out_2 of the Raman tensor') - dXi_dR_muspace += np.einsum('abmn, a, b -> mn', d2alpha_dR_muspace, pol_in_2, pol_out_2) - print('TWO PH SECTOR adding component pol_in_3 pol_out_3 of the Raman tensor') - dXi_dR_muspace += np.einsum('abmn, a, b -> mn', d2alpha_dR_muspace, pol_in_3, pol_out_3) - - # Symmetrize in mu space, np.array with shape = (n_modes, n_modes) - dXi_dR_muspace = 0.5 * (dXi_dR_muspace + dXi_dR_muspace.T) - - # Get chi_minus and chi_plus tensors, np.array with shape = (n_modes, n_modes) - chi_minus = self.get_chi_minus() - chi_plus = self.get_chi_plus() - - # Get the pertubations on a'^(1) b'^(1) - pert_a = -np.einsum('nm, nm -> nm', np.sqrt(-0.5 * chi_minus), dXi_dR_muspace) - pert_b = +np.einsum('nm, nm -> nm', np.sqrt(+0.5 * chi_plus) , dXi_dR_muspace) - - # Check if everything is symmetric - assert np.all(np.abs(dXi_dR_muspace - dXi_dR_muspace.T) < 1e-10), "Second derivative of the polarizability is not symmetric in pol basis" - assert np.all(np.abs(pert_a - pert_a.T) < 1e-10), "a'(1) pertubation is not symmetric in pol basis" - assert np.all(np.abs(pert_b - pert_b.T) < 1e-10), "b'(1) pertubation is not symmetric in pol basis" - - # Now get the perturbation for a'^(1) - current = self.n_modes - for i in range(self.n_modes): - self.psi[current : current + self.n_modes - i] = pert_a[i, i:] - current = current + self.n_modes - i - - # Now get the pertrubation for b'^(1) - for i in range(self.n_modes): - self.psi[current : current + self.n_modes - i] = pert_b[i, i:] - current = current + self.n_modes - i - - # Add the mask dot taking into account symmetric elements - mask_dot = self.mask_dot_wigner() - # OVERWRITE the pertubation modulus considering the two phonon sector - self.perturbation_modulus = self.psi.dot(self.psi * mask_dot) - - print('[NEW] Perturbation modulus after adding two ph contributions RAMAN = {}'.format(self.perturbation_modulus)) - print() - - return - - - - def prepare_anharmonic_raman_FT_2ph(self, d2alpha_dR = None, pol_in = np.array([1.,0.,0.]), pol_out = np.array([1.,0.,0.]),\ - mixed = False, pol_in_2 = None, pol_out_2 = None): - r""" - PREPARE THE PSI VECTOR FOR RAMAN SPECTRUM CALCULATION (NEW VERSION) DIRECTLY FROM 2nd ORDER RAMAN TENSOR - ======================================================================================================== - - This function is useful if we want to interpolate the 2nd Raman tensor on a bigger supercell. - - This works only with the Wigner representation if we add the two phonons effect. - Prepare the psi vector for RAMAN spectrum considering position-dependent raman tensors. - - NOTE: we completely neglect the frist order Raman scattering! - - Parameters: - ----------- - -d2alpha_dR: nd.array (E_comp, E_comp, 3 * N_at_sc, 3 * N_at_sc), - 2nd order Raman tensor. - Indices are: Number of configuration, electric field component, - electric field component, atomic coordinates in sc. - -pol_in: nd.array, the polarization of in-out light. default is x - -pol_out: nd.array, the polarization of in-out light. default is x - -mixed: if True we can study the one and two phonon response to - pol_in \cdot \Xi \cdot pol_out + pol_in_2 \cdot \Xi \cdot pol_out_2 - (\Xi is the Raman tensor) - -pol_in_2: nd.array, the polarization of in-out light. default is x - -pol_out_2: nd.array, the polarization of in-out light. default is x - """ - if not self.use_wigner: - raise NotImplementedError('The two phonon processes are implemented only in Wigner') - - if d2alpha_dR is None: - raise ValueError('Must specify the 2nd order Raman tensor!') - - exp_shape = (3, 3, self.nat * 3, self.nat * 3) - if d2alpha_dR.shape != exp_shape: - raise ValueError('The shape of the 2nd order Raman tensor is not correct, expected {}'.format(exp_shape)) - - if mixed: - if (pol_in_2 is None) or (pol_out_2 is None): - raise ValueError('Must specify pol_in_2 pol_out_2 if mixed = True!') - - if len(pol_in_2) != 3 or len(pol_out_2) != 3: - raise ValueError('pol_in_2 pol_out_2 must be array of len 3') - - - print() - print('PREPARE THE RAMAN ANHARMONIC SPECTRUM CALCULATION FROM 2nd ORDER RAMAN TENSOR') - print('=============================================================================') - # print('Are we considering two ph effects? = {}'.format(add_two_ph)) - print('Are we using Wigner? = {}'.format(self.use_wigner)) - # print('Are we symmetrizing the raman tensor? = {}'.format(symmetrize)) - print() - - print('TWO PH Going in polarization basis') - # Now go in polarization basis, np.array with shape = (E_field, E_field, n_modes, n_modes) - # d2alpha_dR_muspace = np.einsum('cm, abcd, dn -> abmn', self.pols, d2alpha_dR, self.pols) - # -> substitute - tmp = np.einsum('abcd, cm -> abmd', d2alpha_dR, self.pols) - d2alpha_dR_muspace = np.einsum('abmd, dn -> abmn', tmp, self.pols) - # print(d2alpha_dR_muspace.shape) - - print('TWO PH Selecting the polarizations') - # Project along the direction of the filed, np.array with shape = (n_modes, n_modes) - dXi_dR_muspace = np.einsum('abmn, a, b -> mn', d2alpha_dR_muspace, pol_in, pol_out) - # print(dXi_dR_muspace.shape) - - if mixed: - print('TWO PH SECTOR adding component pol_in_2 pol_out_2 of the Raman tensor') - dXi_dR_muspace += np.einsum('abmn, a, b -> mn', d2alpha_dR_muspace, pol_in_2, pol_out_2) - - # Symmetrize in mu space, np.array with shape = (n_modes, n_modes) - dXi_dR_muspace = 0.5 * (dXi_dR_muspace + dXi_dR_muspace.T) - - # Get chi_minus and chi_plus tensors, np.array with shape = (n_modes, n_modes) - chi_minus = self.get_chi_minus() - chi_plus = self.get_chi_plus() - - # Get the pertubations on a'^(1) b'^(1) - pert_a = -np.einsum('nm, nm -> nm', np.sqrt(-0.5 * chi_minus), dXi_dR_muspace) - pert_b = +np.einsum('nm, nm -> nm', np.sqrt(+0.5 * chi_plus) , dXi_dR_muspace) + def prepare_unpolarized_raman_FT( + self, index=0, debug=False, eq_raman_tns=None, use_symm=True, + ens_av_raman=None, raman_tns_ens=None, add_2ph=True): + """Disabled compatibility entry point for unvalidated two-phonon Raman.""" + raise NotImplementedError(_TWO_PHONON_RAMAN_DISABLED) + + def prepare_anharmonic_raman_FT( + self, raman=None, raman_eq=None, + pol_in=None, pol_out=None, + mixed=False, pol_in_2=None, pol_out_2=None, + pol_in_3=None, pol_out_3=None, add_two_ph=False, + symmetrize=False, ensemble=None, save_raman_tensor2=False, + file_raman_tensor2=None): + """Disabled compatibility entry point for unvalidated two-phonon Raman.""" + raise NotImplementedError(_TWO_PHONON_RAMAN_DISABLED) + + def prepare_anharmonic_raman_FT_2ph( + self, d2alpha_dR=None, pol_in=None, pol_out=None, + mixed=False, pol_in_2=None, + pol_out_2=None): + """Disabled compatibility entry point for unvalidated two-phonon Raman.""" + raise NotImplementedError(_TWO_PHONON_RAMAN_DISABLED) - # Check if everything is symmetric - assert np.all(np.abs(dXi_dR_muspace - dXi_dR_muspace.T) < 1e-10), "Second derivative of the polarizability is not symmetric in pol basis" - assert np.all(np.abs(pert_a - pert_a.T) < 1e-10), "a'(1) pertubation is not symmetric in pol basis" - assert np.all(np.abs(pert_b - pert_b.T) < 1e-10), "b'(1) pertubation is not symmetric in pol basis" - - print('[NEW] Perturbation modulus = {}'.format(self.perturbation_modulus)) - print() - - # Now get the perturbation for a'^(1) - current = self.n_modes - for i in range(self.n_modes): - self.psi[current : current + self.n_modes - i] = pert_a[i, i:] - current = current + self.n_modes - i - - # Now get the pertrubation for b'^(1) - for i in range(self.n_modes): - self.psi[current : current + self.n_modes - i] = pert_b[i, i:] - current = current + self.n_modes - i - - # Add the mask dot taking into account symmetric elements - mask_dot = self.mask_dot_wigner() - # OVERWRITE the pertubation modulus considering the two phonon sector - self.perturbation_modulus = self.psi.dot(self.psi * mask_dot) - - print('[NEW] Perturbation modulus adding two ph contributions RAMAN = {}'.format(self.perturbation_modulus)) - print() - - return - - - def prepare_ir(self, effective_charges = None, pol_vec = np.array([1,0,0])): """ PREPARE LANCZOS FOR INFRARED SPECTRUM COMPUTATION @@ -1651,302 +1271,28 @@ def prepare_ir(self, effective_charges = None, pol_vec = np.array([1,0,0])): if not effective_charges is None: ec = effective_charges - n_supercell = np.prod(self.dyn.GetSupercell()) - - # Check the effective charges - assert not ec is None, "Error, no effective charge found. Cannot initialize IR responce" - - ec_size = np.shape(ec) - MSG = """ - Error, effective charges of the wrong shape: {} - Number of modes : {} - Dimension of the supercell : {} - """.format(ec_size, self.n_modes, n_supercell) - assert len(ec_size) == 3, MSG - if not self.ignore_small_w: - assert ec_size[0] * ec_size[2] * n_supercell == self.n_modes + 3, MSG - assert ec_size[1] == ec_size[2] == 3, MSG - - # shape = (N_at_uc, 3) - z_eff = np.einsum("abc, b", ec, pol_vec) - - # Get the gamma effective charge - new_zeff = np.tile(z_eff.ravel(), n_supercell) - - self.prepare_perturbation(new_zeff, masses_exp = -1) - - - - def prepare_anharmonic_ir_FT(self, ec = None, ec_eq = None, pol_vec_light = np.array([1.,0.,0.]), add_two_ph = False, symmetrize = False, ensemble = None): - """ - PREPARE THE PSI VECTOR FOR ANHARMONIC IR SPECTRUM CALCULATION (NEW VERSION) - =========================================================================== - - This works only with the Wigner representation if we add the two phonons effect. - Prepare the psi vector for IR spectrum considering position-dependent effective charges. - - The one phonon scetor is symmetrized by default - - Parameters: - ----------- - -effective_charges: nd.array (N_configs, N_atoms_sc, E_comp, cart_comp), - the effective charges for all configurations. - Indices are: Number of configuration, number of atoms in the super cell, - electric field component, atomic coordinate. - -effective_charges_eq: nd.array, (N_atoms_uc, E_comp, cart_comp), the effective charges at equilibrium. - Indices are: number of atoms in the unit cell, - electric field component, atomic coordinate. - -pol_vec_light: nd.array, the polarization of in-out light. default is x - -add_two_ph: bool, if True two phonon processes are included in the calculation - -symmetrize: bool, if True the first/second order effective charges are symmetrized - -ensemble: a scha ensemble object for computing the averages - """ - if not self.use_wigner and add_two_ph: - raise NotImplementedError('The two phonon processes are implemented only in Wigner') - if ec is None: - raise ValueError('Must specify the effective charges for all configurations!') - - - print() - print('PREPARE THE IR ANHARMONIC SPECTRUM CALCULATION') - print('==============================================') - print('Are we considering two ph effects? = {}'.format(add_two_ph)) - print('Are we using Wigner? = {}'.format(self.use_wigner)) - print('Are we symmetrizing the effective charges? = {}'.format(symmetrize)) - print() - - required = 'N_conf N_at_sc E_field cart' - assert ec.shape[0] == ensemble.N, 'The effective charges in input have the wrong shape. The required is {}'.format(required) - assert ec.shape[1] == self.nat, 'The effective charges in input have the wrong shape. The required is {}'.format(required) - assert ec.shape[2] == ec.shape[3] == 3, 'The effective charges in input have the wrong shape. The required is {}'.format(required) - - # Get the average of the dipole moment, np.array with shape = (3 * N_at_sc, 3) - d1M_dR_av = perturbations.get_d1M_dR_av(ensemble, ec, symmetrize = symmetrize) - - # Project along the direction of light polarization, (3 * N_at_sc) - Z = np.einsum("ab, b -> a", d1M_dR_av, pol_vec_light) - - # Now rescale by the mass and go in polarizaiton basis - self.prepare_perturbation(Z.ravel(), masses_exp = -1) - print('Pertubation modulus with one ph effects only = {}'.format(self.perturbation_modulus)) - print() - - # NOW PREPARE THE SECOND ORDER DIPOLE MOMENT - if add_two_ph: - if ec_eq is not None: - print('[NEW] Getting the equilibirum effective charges...') - print() - n_supercell = np.prod(self.dyn.GetSupercell()) - # ec_eq is np.array with shape = (N_at_uc, E_field, cart) - ec_eq_size = np.shape(ec_eq) - MSG = """ - Error, effective charges of the wrong shape: {} - """.format(ec_eq_size) - assert len(ec_eq_size) == 3, MSG - if not self.ignore_small_w: - assert ec_eq_size[0] * ec_eq_size[2] * n_supercell == self.n_modes + 3 - assert ec_eq_size[1] == ec_eq_size[2] == 3 - - # Get the eq effective charges in the supercell (N_at_sc, E_field, 3) - ec_eq_gamma = np.zeros((n_supercell * self.dyn.structure.N_atoms, 3, 3), dtype = type(ec_eq[0])) - ec_eq_gamma = np.tile(ec_eq, (n_supercell,1,1)) - - print('[NEW] Getting the two phonon contribution...') - - # d2M_dR np.array with shape = (3 * N_atoms, 3 * N_atoms, Efield) - if ec_eq is not None: - print('[NEW] Subtracting the equilibirum effective charges...') - # ec - ec_eq_gamma, np.array with shape = (N_configs, N_at_sc, Efield, cart) - d2M_dR = perturbations.get_d2M_dR_av(ensemble, ec - ec_eq_gamma, None, symmetrize = symmetrize) - else: - d2M_dR = perturbations.get_d2M_dR_av(ensemble, ec, None, symmetrize = symmetrize) - - # Divide by the masses of the atoms in the supercell - d2M_dR = np.einsum('a, abc, b -> abc', np.sqrt(self.m)**-1, d2M_dR, np.sqrt(self.m)**-1) - - # Now go in polarization basis, np.array with shape = (n_modes, n_modes, E_filed) - d2M_dR_muspace = np.einsum('am, abc, bn -> mnc', self.pols, d2M_dR, self.pols) + raise ValueError( + "No effective charges found; cannot initialize the IR response") - # Project along the direction of the filed, np.array with shape = (n_modes, n_modes) - dZ_dR_muspace = np.einsum('mnc, c -> mn', d2M_dR_muspace, pol_vec_light) - - # Symmetrize in mu space, np.array with shape = (n_modes, n_modes) - dZ_dR_muspace = 0.5 * (dZ_dR_muspace + dZ_dR_muspace.T) - - # Get chi_minus and chi_plus tensors, np.array with shape = (n_modes, n_modes) - chi_minus = self.get_chi_minus() - chi_plus = self.get_chi_plus() - - # Get the pertubations on a'^(1) b'^(1) - pert_a = -np.einsum('nm, nm -> nm', np.sqrt(-0.5 * chi_minus), dZ_dR_muspace) - pert_b = +np.einsum('nm, nm -> nm', np.sqrt(+0.5 * chi_plus) , dZ_dR_muspace) - - # Check if everything is symmetric - assert np.all(np.abs(dZ_dR_muspace - dZ_dR_muspace.T) < 1e-10), "Second derivative of the dipole is not symmetric in pol basis" - assert np.all(np.abs(pert_a - pert_a.T) < 1e-10), "a'(1) pertubation is not symmetric in pol basis" - assert np.all(np.abs(pert_b - pert_b.T) < 1e-10), "b'(1) pertubation is not symmetric in pol basis" - - # Now get the perturbation for a'^(1) - current = self.n_modes - for i in range(self.n_modes): - self.psi[current : current + self.n_modes - i] = pert_a[i, i:] - current = current + self.n_modes - i - - # Now get the pertrubation for b'^(1) - for i in range(self.n_modes): - self.psi[current : current + self.n_modes - i] = pert_b[i, i:] - current = current + self.n_modes - i - - # Add the mask dot taking into account symmetric elements - mask_dot = self.mask_dot_wigner() - # OVERWRITE the pertubation modulus considering the two phonon sector - self.perturbation_modulus = self.psi.dot(self.psi * mask_dot) - - print('[NEW] Perturbation modulus after adding two ph contributions = {}'.format(self.perturbation_modulus)) - print() - - return + z_eff = Spectroscopy.build_ir_vector(ec, pol_vec) + self._prepare_gamma_cartesian_perturbation(z_eff) - - def prepare_anharmonic_ir(self, ec = None, ec_eq = None, pol_vec_light = np.array([1.,0.,0.]), add_two_ph = False): - """ - PREPARE THE PSI VECTOR FOR ANHARMONIC IR SPECTRUM CALCULATION - ============================================================= - - This works only with the Wigner representation if we add the two phonons effect. - Prepare the psi vector for IR spectrum considering position-dependent effective charges. - - Parameters: - ----------- - -effective_charges: nd.array (N_configs, N_atoms_sc, E_comp, cart_comp), - the effective charges for all configurations. - Indices are: Number of configuration, number of atoms in the super cell, - electric field component, atomic coordinate. - -effective_charges_eq: nd.array, the effective charges at equilibrium. - Indices are: number of atoms in the unit cell, - electric field component, atomic coordinate. - -pol_vec_light: nd.array, the polarization of in-out light. default is x - -add_two_ph: bool, if True two phonon processes are included in the calculation - -symm_eff_charges: bool, if True the effective charges are symmetrized - -ensemble: a scha ensemble object to compute the effective charges - """ - if not self.use_wigner and add_two_ph: - raise NotImplementedError('The two phonon processes are implemented only in Wigner') - - if ec is None: - raise ValueError('Must specify the effective charges for all configurations!') - - print() - print('PREPARE THE IR ANHARMONIC SPECTRUM CALCULATION') - print('==============================================') - print('Are we considering two ph effects? = {}'.format(add_two_ph)) - print('Are we using Wigner? = {}'.format(self.use_wigner)) - print() - - # The effective charges for each configuration (N_configs, N_at_sc, E_field_comp, 3) - eff = np.zeros((self.N, self.nat, 3, 3)) - - assert ec.shape == eff.shape, 'The effective charges in input have the wrong shape. The required is {}'.format(eff.shape) - - # Get the effective charges - eff = ec.copy() - - # FIRST DERIVATIVE OF THE DIPOLE - # Project along the direction of light polarization, (N_configs, N_at_sc, 3) - z_eff = np.einsum("iabc, b -> iac", eff, pol_vec_light) - - # FIRST DERIVATIVE OF THE DIPOLE - # Average of effective charges on the ensemble (N_at_sc, 3) - d1_M = np.einsum('i, iab -> ab', self.rho, z_eff) /np.sum(self.rho) - - # Now rescale by the mass and go in polarizaiton basis - self.prepare_perturbation(d1_M.ravel(), masses_exp = -1) - print('Pertubation modulus with one ph effects only = {}'.format(self.perturbation_modulus)) - print() - - # NOW PREPARE THE SECOND ORDER DIPOLE MOMENT - if add_two_ph: - if ec_eq is not None: - print('Subtracting the equilibirum effective charges...') - print() - n_supercell = np.prod(self.dyn.GetSupercell()) - ec_eq_size = np.shape(ec_eq) - MSG = """ - Error, effective charges of the wrong shape: {} - """.format(ec_eq_size) - assert len(ec_eq_size) == 3, MSG - if not self.ignore_small_w: - assert ec_eq_size[0] * ec_eq_size[2] * n_supercell == self.n_modes + 3 - assert ec_eq_size[1] == ec_eq_size[2] == 3 - - # Eq effective charges, (N_at_uc, 3) - z_eff_eq = np.einsum("abc, b -> ac", ec_eq, pol_vec_light) - # Eq effective charges at gamma, (N_at_sc, 3) - z_eff_eq_gamma = np.tile(z_eff_eq.ravel(), n_supercell).reshape((self.nat, 3)) - - # This should reduce the noise when computing the 2ph vertex - z_eff -= z_eff_eq_gamma - - print('[OLD] Getting the two phonon contribution...') - - # Polarization vectors over mass, shape = (N_at_sc, n_modes) - pols_mass = np.einsum('a, am -> am', np.sqrt(self.m)**-1, self.pols) - - # The mass rescaled projected effective charges in polarization basis, shape = (N_configs, n_modes) - z_pols_mass = np.einsum('am, ia -> im ', pols_mass, z_eff.ravel().reshape((self.N, self.nat * 3))) - - # Eigenvalues of Upsilon mass rescaled, shape = (n_modes) - xi2_inv = f_ups(self.w, self.T) - - # The mass rescaled displacements in polarization basis divided by xi2, shape = (N_configs, n_modes) - u_xi2 = np.einsum('im, m -> im', self.X, xi2_inv) - - # Add the effective charges, shape = (N_configs, n_modes, n_modes) - u_xi2_Z = np.einsum('in , im -> inm', u_xi2, z_pols_mass) - - # Get the reweighted average of the second derivative, shape = (n_modes, n_modes) - d2_M = np.einsum('i, inm -> nm', self.rho, u_xi2_Z) /np.sum(self.rho) - d2_M = 0.5 * (d2_M + d2_M.T) - - # Get chi_minus and chi_plus tensors - chi_minus = self.get_chi_minus() - chi_plus = self.get_chi_plus() - - # Get the pertubations on a'^(1) b'^(1) - pert_a = -np.einsum('nm, nm -> nm', np.sqrt(-0.5 * chi_minus), d2_M) - pert_b = +np.einsum('nm, nm -> nm', np.sqrt(+0.5 * chi_plus) , d2_M) - - # Check if everything is symmetric - assert np.all(np.abs(d2_M - d2_M.T) < 1e-10), "Second derivative of the dipole is not symmetric in pol basis" - assert np.all(np.abs(pert_a - pert_a.T) < 1e-10), "a'(1) pertubation is not symmetric in pol basis" - assert np.all(np.abs(pert_b - pert_b.T) < 1e-10), "b'(1) pertubation is not symmetric in pol basis" - - # Now get the perturbation for a'^(1) - current = self.n_modes - for i in range(self.n_modes): - self.psi[current : current + self.n_modes - i] = pert_a[i, i:] - current = current + self.n_modes - i + def prepare_anharmonic_ir_FT( + self, ec=None, ec_eq=None, + pol_vec_light=np.array([1., 0., 0.]), add_two_ph=False, + symmetrize=False, ensemble=None): + """Disabled compatibility entry point for unvalidated optical vertices.""" + raise NotImplementedError(_CONFIGURATION_DEPENDENT_IR_DISABLED) + + def prepare_anharmonic_ir( + self, ec=None, ec_eq=None, + pol_vec_light=np.array([1., 0., 0.]), add_two_ph=False): + """Disabled compatibility entry point for unvalidated optical vertices.""" + raise NotImplementedError(_CONFIGURATION_DEPENDENT_IR_DISABLED) - # Now get the pertrubation for b'^(1) - for i in range(self.n_modes): - self.psi[current : current + self.n_modes - i] = pert_b[i, i:] - current = current + self.n_modes - i - - # Add the mask dot taking into account symmetric elements - mask_dot = self.mask_dot_wigner() - # OVERWRITE the pertubation modulus considering the two phonon sector - self.perturbation_modulus = self.psi.dot(self.psi * mask_dot) - - print('[OLD] Perturbation modulus after adding two ph contributions = {}'.format(self.perturbation_modulus)) - print() - - return - - - def prepare_perturbation(self, vector, masses_exp = 1, add = False): r""" This function prepares the calculation for the Green function @@ -1994,11 +1340,14 @@ def prepare_perturbation(self, vector, masses_exp = 1, add = False): # THIS IS OK IN THE WIGNER REPRESENTATION BECAUSE # THE PERTUBATION ENTERS ONLY IN THE R SECTOR - self.perturbation_modulus = new_v.dot(new_v) - if self.symmetrize: self.symmetrize_psi() + # Account for the full accumulated one-phonon perturbation. In + # particular, add=True must include cross terms with earlier vectors. + perturbation = self.psi[:self.n_modes] + self.perturbation_modulus = perturbation.dot(perturbation) + def prepare_mode(self, index): @@ -3032,6 +2381,9 @@ def apply_anharmonic_FT(self, transpose = False, test_weights = True, use_old_ve #print("Entering in get pert...") _t_pert_start = time.time() n_syms, _, _ = np.shape(self.symmetries[0]) + coset_indices, stabilizer_indices, characters = ( + self._spectroscopy_reduction_arguments()) + n_syms = self._spectroscopy_symmetry_count(n_syms) #print("DEG:") #print(self.degenerate_space) @@ -3063,11 +2415,12 @@ def get_combined_proc(start_end): self.X.T, self.Y.T, self.w, self.rho, R1, Y1, np.float64(self.T), bool(apply_d4), self.sym_julia, self.N_degeneracy, self.deg_julia, - self.sym_block_id, start, end) + self.sym_block_id, start, end, + coset_indices, stabilizer_indices, characters) return np.concatenate([result[0], result[1].ravel()]) # Divide the configurations and symmetries on different processors (Here we get the range of work for each process) - n_total = self.n_syms * self.N + n_total = n_syms * self.N n_processors = Parallel.GetNProc() count = n_total // n_processors remainer = n_total % n_processors @@ -3582,6 +2935,9 @@ def save_abc(self, file): """ Save only the a, b, and c coefficients from the lanczos. In this way the calculation cannot be restarted. + + The perturbation modulus is stored in the first comment line, + so that it is preserved when the calculation is reanalyzed. """ total_len = len(self.a_coeffs) @@ -3591,14 +2947,25 @@ def save_abc(self, file): abc[:len(self.b_coeffs),1] = self.b_coeffs abc[:len(self.c_coeffs),2] = self.c_coeffs - np.savetxt(file, abc, header = "a; b; c") + header = "perturbation_modulus = {}\na; b; c".format( + format(self.perturbation_modulus, ".16g")) + np.savetxt(file, abc, header = header) def load_abc(self, file): """ Load only the a, b, and c coefficients from the ".abc" file + + The perturbation modulus is read back from the first comment line + if it is present. Old .abc files (or files produced by other tools) + may not store it: in that case the value is left untouched. """ - abc = np.loadtxt(file) + with open(file, "r") as fp: + first_line = fp.readline() + if "perturbation_modulus" in first_line: + self.perturbation_modulus = float(first_line.split("=")[1].strip()) + fp.seek(0) + abc = np.atleast_2d(np.loadtxt(fp)) self.a_coeffs = abc[:,0] self.b_coeffs = abc[:,1] self.c_coeffs = abc[:,2] @@ -7359,6 +6726,3 @@ def min_stdes(func, args, x0, step = 1e-2, n_iters = 100): print("F: {} | G: {}".format( f, np.sqrt(np.sum(grad**2)))) return x - - - diff --git a/Modules/QSpaceAtomFourier.py b/Modules/QSpaceAtomFourier.py new file mode 100644 index 00000000..300c0b50 --- /dev/null +++ b/Modules/QSpaceAtomFourier.py @@ -0,0 +1,1020 @@ +"""Atom-centred Fourier interpolation for the q-space TD-SCHA Lanczos. + +The stochastic ensemble and expensive Julia contractions remain on the +coarse, commensurate q mesh. Fine two-phonon blocks are folded to that mesh +with an atom-pair-resolved trigonometric cardinal kernel, contracted there, +and reconstructed with the exact adjoint kernel. + +For atoms ``a`` and ``b``, every aliased lattice harmonic is represented by +the image nearest to ``tau_a - tau_b`` in the true Cartesian cell metric. +Exact minimum-image (Nyquist) ties are split equally. This is the same +centering rule used for real-space force constants and works for arbitrary, +including non-orthogonal, cells. + +The same fold/kernel/adjoint-unfold path interpolates both contributions to +the anharmonic operator: + +* d3 couples the one- and two-phonon sectors and scales as + ``sqrt(N_coarse / N_fine)``; +* d4 acts within the two-phonon sector and scales as + ``N_coarse / N_fine``. + +The perturbation momentum must belong to the coarse mesh. The internal +two-phonon momentum is evaluated on a fine mesh whose dimensions are integer +multiples of the coarse dimensions. +""" +from __future__ import print_function +from __future__ import division + +import itertools +import warnings +import numpy as np + +import cellconstructor as CC +import cellconstructor.Phonons +import cellconstructor.Methods + +import cellconstructor.Settings as Parallel +from cellconstructor.Settings import ParallelPrint as print + +import tdscha.QSpaceLanczos as QL +import tdscha.JuliaExt as JuliaExt +from tdscha.QSpaceInterpolation import ( + build_fine_harmonic, build_q_index_lookup, mesh_key, validate_mesh, + FineHarmonicInterpolation) + + +class QSpaceAtomFourierLanczos(QL.QSpaceLanczos): + """Q-space Lanczos with atom-centred Fourier interpolation. + + Parameters + ---------- + ensemble : sscha.Ensemble.Ensemble + The (coarse-supercell) SSCHA ensemble. + fine_mesh : tuple(3) of int + The fine q-mesh; each dimension must be an integer multiple of the + corresponding coarse supercell dimension. + use_asr_dyn : bool + Apply the acoustic sum rule to the centered force constants before + Fourier-interpolating the dynamical matrix. + ignore_effective_charges : bool + Opt-in. If True, the Born effective charges and dielectric tensor + stored in the dynamical matrix are hidden from the *dynamical-matrix + interpolation only*. Default False, which keeps the standard + cellconstructor behaviour. + + The scope really is only the interpolation: the ensemble's ``dyn`` + is not modified, so IR intensities, Raman/IR response functions and + anything else that legitimately needs Z*/eps keep seeing them. + + Turn it on when the forces come from a potential with no intrinsic + long-range electrostatics -- typically a short-range machine-learning + interatomic potential -- where the Z*/eps in the dynamical-matrix + header were inherited from a DFT reference and do not describe the + potential that generated the ensemble. In that case ``ForceTensor`` + subtracts a dipole tail that is not in the data, leaving a remainder + that is not short-ranged, so centering truncates it and the ASR is + imposed on the wrong object. The damage is invisible on the coarse + mesh (the subtract/re-add cycle is exactly the identity there) and + shows up between coarse points, typically as imaginary modes on the + off-grid shell of the fine mesh. See ``ignore_effective_charges`` + in ``QSpaceInterpolation.interpolate_dyn_fine``. + w_min_guard : float + Interpolated modes with |w| below this (Ry), away from Gamma, are + masked (chi factors diverge as 1/w). + allow_unstable : bool + Diagnostic escape hatch only. If True, imaginary interpolated + frequencies are excluded from the one- and two-phonon Hilbert space + with a warning instead of raising. The resulting spectrum is + incomplete and must not be treated as a physical interpolation. + harmonic_interpolation : QSpaceInterpolation.FineHarmonicInterpolation + An already computed fine-mesh interpolation of the same dynamical + matrix, as returned by + :meth:`prepare_distributed_construction`. When given, the harmonic + interpolation is not recomputed. This is what lets the distributed + loaders keep the MPI collectives inside the interpolation matched + across ranks while only the master reads the ensemble. Before it is + used it is checked against the dynamical matrix, the mesh, and the + long-range settings it would have been built from, so it cannot + contract the ensemble in a mode basis that does not belong to it. + """ + + _INTERPOLATION_ATTRS = ( + 'fine_mesh', 'coarse_mesh', '_fine_idx', + '_q_lookup', '_coarse_lookup', '_coarse_idx', + '_fine_pair_of', + '_fine_of_coarse', '_coarse_of_fine', + 'cq_points', 'cn_q', 'cw_q', 'cpols_q', + 'cvalid_modes_q', + 'c_iq_pert', 'c_q_pair_map', 'c_unique_pairs', + 'ignore_effective_charges', + '_interp_used_effective_charges', + '_atom_fourier_kernel') + + # What the distributed loader must carry to the worker ranks: the + # interpolation state above, plus the mesh-measure scale factors set on + # the parent. These are + # broadcast rather than recomputed per rank: interpolate_dyn_fine + # diagonalises the fine dynamical matrix, and two diagonalisations of a + # degenerate block need not agree on a gauge -- ranks disagreeing on the + # polarization vectors would silently corrupt every reduced dot product. + _DISTRIBUTED_EXTRA_ATTRS = _INTERPOLATION_ATTRS + ( + 'qspace_scale3', 'qspace_scale4', 'qspace_prefiltered') + + @staticmethod + def _interpolation_lo_to_split(lo_to_split, ignore_effective_charges): + """The nonanalytic direction seen by the *harmonic interpolation*. + + Suppressing the effective charges disables the whole dipolar + correction, including its Gamma LO--TO limit, while the caller's + dynamical matrix keeps Z* for IR perturbations. + """ + return None if ignore_effective_charges else lo_to_split + + @classmethod + def prepare_distributed_construction(cls, dyn, fine_mesh=None, + use_asr_dyn=True, + ignore_effective_charges=False, + lo_to_split=None, **kwargs): + """Interpolate the dynamical matrix on every rank -- see the base class. + + The harmonic interpolation is the only collective step of this + constructor: it goes through ``ForceTensor.Tensor2.Center`` and + ``Apply_ASR``, both of which end with an unconditional + ``Settings.broadcast``. Running it here, identically on every rank, + keeps those collectives matched and lets the master build the rest + of the object -- the part that reads the configurations -- alone. + + The result is cheap next to the ensemble: it scales with the fine + mesh and the number of atoms, not with the number of + configurations. The workers' own copy is discarded when the loader + overwrites the interpolation state with the master's, so a + degenerate-subspace gauge difference between ranks (were the + diagonalization ever not bit-reproducible) cannot leak into the + Bloch fields. + """ + if fine_mesh is None: + raise ValueError( + "QSpaceAtomFourierLanczos requires fine_mesh=(m1, m2, m3)") + return {"harmonic_interpolation": build_fine_harmonic( + dyn, fine_mesh, + use_asr=use_asr_dyn, + ignore_effective_charges=ignore_effective_charges, + lo_to_split=cls._interpolation_lo_to_split( + lo_to_split, ignore_effective_charges))} + + def __init__(self, ensemble, fine_mesh=None, use_asr_dyn=True, + ignore_effective_charges=False, + w_min_guard=1e-8, allow_unstable=False, + lo_to_split=None, harmonic_interpolation=None, **kwargs): + if (harmonic_interpolation is not None + and not isinstance(harmonic_interpolation, + FineHarmonicInterpolation)): + raise TypeError( + "harmonic_interpolation must be a " + "FineHarmonicInterpolation, got {}".format( + type(harmonic_interpolation).__name__)) + # The parent fixes the commensurate (including Gamma) mode basis with + # the requested nonanalytic direction. The fine interpolation below + # uses the same long-range convention and those commensurate points + # are pinned back to this exact basis. + interpolation_lo_to_split = self._interpolation_lo_to_split( + lo_to_split, ignore_effective_charges) + super().__init__( + ensemble, lo_to_split=interpolation_lo_to_split, **kwargs) + + self.__total_attributes__.extend(self._INTERPOLATION_ATTRS) + + self.fine_mesh = None + self._fine_idx = None + self._q_lookup = None + self._coarse_lookup = None + self._coarse_idx = None + self._fine_pair_of = None + self._atom_fourier_kernel = None + self.c_iq_pert = None + self.c_q_pair_map = None + self.c_unique_pairs = None + + # Bare initialization (distributed loader path of the parent) + if ensemble is None: + return + + if fine_mesh is None: + raise ValueError( + "QSpaceAtomFourierLanczos requires " + "fine_mesh=(m1, m2, m3)") + + self.fine_mesh = validate_mesh(fine_mesh, "fine_mesh") + self.coarse_mesh = validate_mesh( + self.dyn.GetSupercell(), "coarse mesh") + try: + w_min_guard = float(w_min_guard) + except (TypeError, ValueError) as error: + raise ValueError( + "w_min_guard must be a positive finite number") from error + if not np.isfinite(w_min_guard) or w_min_guard <= 0: + raise ValueError( + "w_min_guard must be a positive finite number") + if np.any(self.fine_mesh % self.coarse_mesh != 0): + raise ValueError( + "fine_mesh {} must be an integer multiple of the coarse " + "mesh {} (the perturbation Q and the pair partners must " + "live on both meshes)".format(tuple(self.fine_mesh), + tuple(self.coarse_mesh))) + + # == 1. Stash the coarse-side arrays (Julia kernel operates here) == + self.cq_points = np.array(self.q_points) + self.cn_q = self.n_q + self.cw_q = np.array(self.w_q) + self.cpols_q = np.array(self.pols_q) + self.cvalid_modes_q = np.array(self.valid_modes_q) + expected_coarse = int(np.prod(self.coarse_mesh)) + if self.cn_q != expected_coarse: + raise ValueError( + "the dynamical matrix contains {} q-points, but coarse " + "mesh {} requires {}".format( + self.cn_q, tuple(self.coarse_mesh), expected_coarse)) + # X_q, Y_q remain the coarse ensemble Bloch fields (untouched). + + self._coarse_lookup = build_q_index_lookup( + self.cq_points, self.uci_structure, self.coarse_mesh) + self._coarse_idx = [ + mesh_key(q, self.uci_structure, self.coarse_mesh) + for q in self.cq_points] + + # == 2. Fine mesh and interpolated dynamical matrix == + self.ignore_effective_charges = bool(ignore_effective_charges) + self._interp_used_effective_charges = ( + self.dyn.effective_charges is not None + and not self.ignore_effective_charges) + if harmonic_interpolation is None: + harmonic_interpolation = build_fine_harmonic( + self.dyn, self.fine_mesh, use_asr=use_asr_dyn, + ignore_effective_charges=self.ignore_effective_charges, + lo_to_split=interpolation_lo_to_split) + harmonic_interpolation.validate_for( + self.dyn, self.fine_mesh, use_asr=use_asr_dyn, + ignore_effective_charges=self.ignore_effective_charges, + lo_to_split=interpolation_lo_to_split) + + q_fine = harmonic_interpolation.q_points + self._fine_idx = harmonic_interpolation.indices + self._q_lookup = build_q_index_lookup(q_fine, self.uci_structure, + self.fine_mesh) + # Copies: the pinning below writes the commensurate columns, and the + # injected interpolation may be shared with another construction. + w_f = np.array(harmonic_interpolation.frequencies) + pols_f = np.array(harmonic_interpolation.polarizations) + + # Pin the commensurate fine points to the parent's + # DiagonalizeSupercell output: the R sector and the kernel exchange + # R1/f_pert in the mode basis at Q, which must be the SAME basis on + # both sides (degenerate-subspace gauges of two diagonalizations of + # the same matrix need not coincide). + self._fine_of_coarse = np.full(self.cn_q, -1, dtype=int) + self._coarse_of_fine = np.full(len(q_fine), -1, dtype=int) + for jq in range(self.cn_q): + key_f = mesh_key( + self.cq_points[jq], self.uci_structure, self.fine_mesh) + iq = self._q_lookup[key_f] + self._fine_of_coarse[jq] = iq + self._coarse_of_fine[iq] = jq + w_f[:, iq] = self.cw_q[:, jq] + pols_f[:, :, iq] = self.cpols_q[:, :, jq] + + self.q_points = q_fine + self.n_q = len(q_fine) + self.w_q = w_f + self.pols_q = pols_f + + # == 3. Mode validity masks on the fine mesh == + masses_uc = self.dyn.structure.get_masses_array() + self.valid_modes_q = np.ones((self.n_bands, self.n_q), dtype=bool) + trans_mask = CC.Methods.get_translations( + np.real(self.pols_q[:, :, 0]), masses_uc) + self.valid_modes_q[:, 0] = ~trans_mask + + unstable = (self.w_q < -w_min_guard) + unstable[:, 0] = unstable[:, 0] & self.valid_modes_q[:, 0] + if np.any(unstable): + bad = np.unique(np.where(unstable)[1]) + n_bad = int(np.sum(unstable)) + msg = self._unstable_message(unstable, bad, n_bad) + if allow_unstable: + warnings.warn( + msg + "\n\nallow_unstable=True: EXCLUDING these modes " + "from the Lanczos Hilbert space. Every two-phonon " + "channel containing one of them is dropped, so the " + "resulting spectrum is INCOMPLETE and is for " + "diagnostics only -- do not publish it as an " + "interpolated result.") + self.valid_modes_q &= ~unstable + else: + raise ValueError(msg) + + small = (np.abs(self.w_q) < w_min_guard) & self.valid_modes_q + small[:, 0] = False + if np.any(small): + warnings.warn("Masking {} interpolated modes with |w| < {} Ry " + "away from Gamma.".format(np.sum(small), + w_min_guard)) + self.valid_modes_q &= ~small + + if ensemble.ignore_small_w: + small_freq = np.abs(self.w_q) < CC.Phonons.__EPSILON_W__ + self.valid_modes_q &= ~small_freq + + # == 4. Atom-pair Fourier kernel (fine geometry, Q-independent) == + self._build_atom_fourier_kernel() + + # == 5. Hermitian-symmetric mesh measure == + ratio = float(self.cn_q) / float(self.n_q) + self.qspace_scale3 = np.sqrt(ratio) + self.qspace_scale4 = ratio + self.qspace_prefiltered = False + + # Reset pair-map state (was initialized on the coarse mesh) + self.iq_pert = None + self.q_pair_map = None + self.unique_pairs = None + self._psi_size = None + + # ================================================================ + # Diagnostics + # ================================================================ + def _unstable_message(self, unstable, bad, n_bad): + """Explain imaginary interpolated modes and what to do about them. + + The single most useful discriminator is *where* the unstable points + sit. Fine q-points that are also coarse points are pinned to the + SSCHA dynamical matrix itself, so an instability there is a property + of the input, not of the interpolation. Instabilities confined to + the off-grid points are produced by the continuation, and the + commonest cause is the effective-charge subtract/re-add cycle + applied to an ensemble whose potential has no long-range part. + """ + w_cm = self.w_q * CC.Units.RY_TO_CM + on_grid = [int(iq) for iq in bad if self._coarse_of_fine[iq] >= 0] + off_grid = [int(iq) for iq in bad if self._coarse_of_fine[iq] < 0] + + lines = [ + "Interpolated dynamical matrix has {} imaginary modes at {} of " + "the {} fine q-points.".format(n_bad, len(bad), self.n_q), + " most negative signed frequency: {:.4f} cm-1".format( + float(w_cm[unstable].min())), + " on coarse-mesh q-points : {} {}".format( + len(on_grid), on_grid if on_grid else ""), + " on off-grid q-points : {} {}".format( + len(off_grid), off_grid if off_grid else ""), + ] + + if on_grid: + lines += [ + "", + "Some unstable points are commensurate with the ensemble " + "supercell. Those are pinned to the SSCHA dynamical matrix " + "and are NOT an interpolation artifact: the auxiliary " + "reference itself is unstable there, and the " + "stable-reference TD-SCHA expansion does not apply. " + "Re-converge the SSCHA minimization before interpolating.", + ] + + if self._interp_used_effective_charges: + lines += [ + "", + "The interpolation USED the Born effective charges and " + "dielectric tensor stored in the dynamical matrix (the " + "default). Is that what you want for this ensemble?", + "", + "ForceTensor subtracts the Ewald dipole-dipole term before " + "centering and adds it back at every interpolated q. That " + "cycle is exactly the identity on the coarse mesh, so it " + "cannot be detected by any commensurate test, but off-grid " + "it replaces the Fourier continuation of your measured " + "force constants with an analytic dipole continuation. If " + "the potential that generated the ensemble has no intrinsic " + "long-range electrostatics -- in particular a SHORT-RANGE " + "MACHINE-LEARNING INTERATOMIC POTENTIAL, whose dynamical " + "matrix may still carry Z*/eps inherited from a DFT " + "reference -- then the subtracted tail is not in the data, " + "the centered remainder is no longer short-ranged, and the " + "ASR is imposed on the wrong object. Spurious imaginary " + "modes on the off-grid shell are the typical symptom.", + "", + " If your forces come from a short-range MLIP, rerun with", + " {}(..., ignore_effective_charges=True)".format( + type(self).__name__), + " which hides Z*/eps from the dynamical-matrix " + "interpolation ONLY. The ensemble's dynamical matrix is not " + "modified, so IR intensities and any other response " + "function that needs the effective charges are unaffected.", + " If the potential really is polar, keep the default and " + "treat the instability as physical or as under-resolution " + "of the coarse mesh.", + ] + else: + lines += [ + "", + "The interpolation ran with ignore_effective_charges=True, " + "so the long-range subtract/re-add cycle is already " + "excluded as the cause. The remaining candidates are a " + "genuinely unstable auxiliary reference between the sampled " + "q-points, an under-converged or under-resolved coarse " + "dynamical matrix, or the centering/ASR prescription " + "(try use_asr_dyn=False to separate the last one).", + ] + + lines += [ + "", + "Set allow_unstable=True to mask these modes instead of " + "raising, but note that this REMOVES every two-phonon channel " + "containing them: the spectrum becomes incomplete and is valid " + "only as a diagnostic.", + ] + return "\n".join(lines) + + # ================================================================ + # Geometry + # ================================================================ + def find_fine_q(self, q): + """Index of a (Cartesian) q-vector in the fine mesh, O(1).""" + return self._q_lookup[ + mesh_key(q, self.uci_structure, self.fine_mesh)] + + @staticmethod + def _metric_alias_images(d, Nc, metric, tol=1e-10): + """True 3D minimal-image assignment of every aliasing class. + + For atom separation ``d`` (fractional) and coarse mesh ``Nc``, + returns a dict mapping each class tuple ``k`` in prod(range(Nc)) to + a list of ``(R (3-tuple int), weight)`` pairs: the lattice images + ``R == k (mod Nc)`` minimizing the Cartesian length + ``sqrt((R-d) . metric . (R-d))``, with exact ties split at equal + weight (the rule tensor centering uses). + + ``metric = A A^T`` with ``A`` the fractional-to-Cartesian matrix + (rows = lattice vectors), so a fractional vector ``v`` has squared + Cartesian length ``v . metric . v``. This couples the three axes + and, unlike the separable rule, can produce genuine >2-fold ties + and select non-axis-aligned images. + """ + d = np.asarray(d, dtype=np.float64) + if d.shape != (3,) or not np.all(np.isfinite(d)): + raise ValueError( + "atomic displacement must be a finite vector of shape (3,)") + Nc = validate_mesh(Nc, "coarse mesh") + metric = np.asarray(metric, dtype=np.float64) + if (metric.shape != (3, 3) + or not np.all(np.isfinite(metric)) + or not np.allclose(metric, metric.T)): + raise ValueError( + "cell metric must be a finite symmetric 3x3 matrix") + eigenvalues = np.linalg.eigvalsh(metric) + if eigenvalues[0] <= 0: + raise ValueError( + "cell metric must be a positive-definite 3x3 matrix") + distance_tol = tol * eigenvalues[-1] + + out = {} + for k in itertools.product(*[range(int(n)) for n in Nc]): + span = 1 + while True: + best = [] + best_d2 = np.inf + shifts = itertools.product( + range(-span, span + 1), repeat=3) + for m in shifts: + R = np.asarray(k) + Nc * np.asarray(m) + v = R - d + d2 = float(v @ metric @ v) + if d2 < best_d2 - distance_tol: + best_d2 = d2 + best = [tuple(int(x) for x in R)] + elif abs(d2 - best_d2) <= distance_tol: + best.append(tuple(int(x) for x in R)) + + # Any point outside the shift cube has at least one + # fractional component this large. The smallest metric + # eigenvalue converts that Euclidean bound into a rigorous + # lower bound on its Cartesian distance. + boundary = ( + Nc * (span + 1) - np.abs(np.asarray(k) - d)) + outside_d2 = eigenvalues[0] * np.min(boundary) ** 2 + if outside_d2 > best_d2 + distance_tol: + break + span *= 2 + + w = 1.0 / len(best) + out[k] = [(R, w) for R in best] + return out + + def _build_atom_fourier_kernel(self): + """Cache atom-pair cardinal weights P(q_fine,k_coarse,a,b). + + For a coarse sample n/N and atom separation d = tau_a - tau_b, + + P_d(q,n) = 1/prod(N) sum_class sum_{R == class (mod N), min image} + w_R exp[2 pi i (q-n/N) . R]. + + At a commensurate q it is exactly the identity. Between samples it + selects the aliased Fourier image(s) shortest to d. + + The minimal-image assignment uses the true 3D cell metric, including + genuine non-separable and greater-than-twofold Nyquist ties. + """ + tau = np.linalg.solve(self.uci_structure.unit_cell.T, + self.uci_structure.coords.T).T + nat = len(tau) + kernel = np.empty((self.n_q, self.cn_q, nat, nat), + dtype=np.complex128) + fine_frac = (np.asarray(self._fine_idx, dtype=np.float64) + / self.fine_mesh[None, :]) + coarse_frac = (np.asarray(self._coarse_idx, dtype=np.float64) + / self.coarse_mesh[None, :]) + + self._build_atom_fourier_kernel_metric( + kernel, tau, nat, fine_frac, coarse_frac) + self._atom_fourier_kernel = kernel + + def _build_atom_fourier_kernel_metric(self, kernel, tau, nat, + fine_frac, coarse_frac): + """Non-separable kernel with the true 3D metric minimal image. + + The image assignment depends only on the atom pair (a,b), not on + q, so it is computed once per pair and reused over all (q,k). + """ + A = np.asarray(self.uci_structure.unit_cell, dtype=np.float64) + metric = A @ A.T + Nc = self.coarse_mesh + norm = 1.0 / float(np.prod(Nc)) + + # For each atom pair the image assignment depends only on (a,b), so + # the phase exp(2 pi i (q - x).R) factorizes as + # exp(2 pi i q.R) * conj(exp(2 pi i x.R)), + # separable in the fine index q and the coarse index x. The + # per-pair kernel is then a single matmul over the images R: + # K[iq, ik] = (1/Nc) sum_R w_R E_fine[iq, R] conj(E_coarse[ik, R]). + for ia in range(nat): + for ib in range(nat): + d = tau[ia] - tau[ib] + imgs = self._metric_alias_images(d, Nc, metric) + Rs, ws = [], [] + for entries in imgs.values(): + for R, w in entries: + Rs.append(R) + ws.append(w) + Rs = np.asarray(Rs, dtype=np.float64) # (nR, 3) + ws = np.asarray(ws, dtype=np.float64) # (nR,) + e_fine = np.exp(2j * np.pi * (fine_frac @ Rs.T)) # (n_q, nR) + e_coarse = np.exp(2j * np.pi * (coarse_frac @ Rs.T)) # (cn_q,) + kernel[:, :, ia, ib] = norm * ( + (e_fine * ws[None, :]) @ e_coarse.conj().T) + + # ================================================================ + # Pair maps (fine for psi, coarse for the kernel) + # ================================================================ + def build_q_pair_map(self, iq_pert): + """Fine and coarse pair maps for a perturbation at fine index iq_pert. + + The perturbation momentum must lie on the coarse mesh; only the + internal loop q' is refined. + """ + if (not isinstance(iq_pert, (int, np.integer)) + or not 0 <= int(iq_pert) < self.n_q): + raise ValueError( + "iq_pert must be an integer in [0, {})".format(self.n_q)) + iq_pert = int(iq_pert) + q_pert = self.q_points[iq_pert] + try: + key_c = mesh_key( + q_pert, self.uci_structure, self.coarse_mesh) + except ValueError: + raise ValueError( + "The perturbation q-point {} must lie on the coarse mesh " + "{} (only the internal q' loop is interpolated)".format( + q_pert, tuple(self.coarse_mesh))) + + self.iq_pert = iq_pert + + # Fine pair map by integer mesh arithmetic + mesh = self.fine_mesh + n_pert = self._fine_idx[iq_pert] + self.q_pair_map = np.zeros(self.n_q, dtype=np.int32) + for iq1 in range(self.n_q): + n2 = tuple((n_pert - self._fine_idx[iq1]) % mesh) + self.q_pair_map[iq1] = self._q_lookup[n2] + + self.unique_pairs = [] + for iq1 in range(self.n_q): + iq2 = int(self.q_pair_map[iq1]) + if iq1 <= iq2: + self.unique_pairs.append((iq1, iq2)) + + self._compute_block_layout() + + # Fold plan: for each fine q' of the full BZ, the stored unique + # pair holding it and whether it enters as the transpose (reverse + # orientation, bilinear convention). + self._fine_pair_of = [None] * self.n_q + for p, (i1, i2) in enumerate(self.unique_pairs): + self._fine_pair_of[i1] = (p, False) + if i2 != i1: + self._fine_pair_of[i2] = (p, True) + + # Coarse pair map (kernel side) + n_pert_c = np.array(key_c, dtype=int) + self.c_iq_pert = self._coarse_lookup[key_c] + self.c_q_pair_map = np.zeros(self.cn_q, dtype=np.int32) + for ik in range(self.cn_q): + n2 = tuple((n_pert_c - np.array(self._coarse_idx[ik])) + % self.coarse_mesh) + self.c_q_pair_map[ik] = self._coarse_lookup[n2] + + self.c_unique_pairs = [] + for ik1 in range(self.cn_q): + ik2 = int(self.c_q_pair_map[ik1]) + if ik1 <= ik2: + self.c_unique_pairs.append((ik1, ik2)) + + # ================================================================ + # Symmetrization: the kernel rotates COARSE fields + # ================================================================ + def prepare_symmetrization(self, no_sym=False, verbose=True, + symmetries=None): + """Build the sparse symmetry matrices in the coarse mode basis. + + The Julia kernel rotates the coarse ensemble fields; the fold + geometry is fixed under the ensemble symmetrization, exactly like + the alpha1 blocks in the commensurate calculation. + """ + fine = (self.q_points, self.n_q, self.w_q, self.pols_q, + self.valid_modes_q) + self.q_points, self.n_q = self.cq_points, self.cn_q + self.w_q, self.pols_q = self.cw_q, self.cpols_q + self.valid_modes_q = self.cvalid_modes_q + try: + super().prepare_symmetrization(no_sym=no_sym, verbose=verbose, + symmetries=symmetries) + finally: + (self.q_points, self.n_q, self.w_q, self.pols_q, + self.valid_modes_q) = fine + + def configure_qspace_perturbation_symmetry( + self, vector=None, tolerance=1e-8): + """Detect a finite-Q stabilizer in the coarse kernel basis.""" + if self.c_iq_pert is None: + raise RuntimeError( + "Prepare a coarse-commensurate perturbation first") + if vector is not None: + vector = np.asarray(vector).ravel() + if vector.size not in (self.n_bands, + self.cn_q * self.n_bands): + raise ValueError( + "atom-Fourier symmetry vectors must use the coarse " + "one-phonon representation") + fine_n_q, fine_iq = self.n_q, self.iq_pert + self.n_q, self.iq_pert = self.cn_q, self.c_iq_pert + try: + return super().configure_qspace_perturbation_symmetry( + vector=vector, tolerance=tolerance) + finally: + self.n_q, self.iq_pert = fine_n_q, fine_iq + + # ================================================================ + # Fold / unfold (all cross-q mixing in Cartesian) + # ================================================================ + # Basis transforms (E = pols, unitary; from x = u^T conj(E)): + # mode -> Cartesian: M_cart = E1 @ M_mode @ E2.T + # Cartesian -> mode: M_mode = E1.conj().T @ M_cart @ E2.conj() + # The same pair applies to the alpha kernel (contracted with + # conjugated fields) and to the d2v dyadics (unconjugated components). + + def _get_alpha1_bare(self): + """Fine-pair perturbation blocks with the chi dressing but WITHOUT + the w1*w2/X = 2 f_Y(q1) f_Y(q2) vertex filter. + + The filter is the coarse-Upsilon part of the interpolated vertex + (w1 w2 / X * conj(x) conj(x) = 2 conj(Upsilon u) conj(Upsilon u)): + atom-Fourier interpolation evaluates the Upsilon u legs from COARSE + samples, so the filter is applied to the folded kernel + (_fold_alpha_to_coarse) with the coarse f_Y tables. Applying + it here with the fine tables (as the parent's + get_alpha1_beta1_wigner_q does) breaks exact per-configuration + Hermiticity: the kernel's output dyadics carry f_Y at the corners, + and the fine/coarse mismatch makes the D4 element asymmetric. + """ + chi_minus_list = self.get_chi_minus_q() + chi_plus_list = self.get_chi_plus_q() + blocks = [] + for pair_idx in range(len(self.unique_pairs)): + a_block = self.get_a1_block(pair_idx) + b_block = self.get_b1_block(pair_idx) + alpha = 2.0 * ( + np.sqrt(-0.5 * chi_minus_list[pair_idx]) * a_block + - np.sqrt(+0.5 * chi_plus_list[pair_idx]) * b_block) + blocks.append(alpha) + return blocks + + def _get_fy_coarse(self): + """Coarse f_Y = 2w/(1+2n) table (nb, cn_q); masked modes -> 0. + + Same definition as the Julia kernel's internal table. + """ + fy = np.zeros((self.n_bands, self.cn_q), dtype=np.float64) + for ik in range(self.cn_q): + w = self.cw_q[:, ik] + valid = self.cvalid_modes_q[:, ik] + n = np.zeros_like(w) + if self.T > QL.__EPSILON__: + n[valid] = 1.0 / (np.exp( + w[valid] * QL.__RyToK__ / self.T) - 1.0) + fy[valid, ik] = 2.0 * w[valid] / (1.0 + 2.0 * n[valid]) + return fy + + def _fold_alpha_cart(self, alpha1_fine): + """Fold the fine-pair (bare) alpha kernel onto the coarse mesh + (Cartesian). + + Returns A(cn_q, nb, nb): A[k] is the kernel block whose first leg + is at coarse k (pairing with Q - k). Enumerates the full fine BZ, + so A(Q - k) = A(k)^T holds exactly. + """ + nb = self.n_bands + nat = nb // 3 + + # Build each fine Cartesian block, then contract all blocks with the + # atom-resolved cardinal kernel. The conjugated kernel is the fold; + # reconstruction below uses the exact adjoint. + blocks = np.empty((self.n_q, nb, nb), dtype=np.complex128) + for iq in range(self.n_q): + p, is_transpose = self._fine_pair_of[iq] + blk = alpha1_fine[p] + if is_transpose: + blk = blk.T + if2 = int(self.q_pair_map[iq]) + blocks[iq] = ( + self.pols_q[:, :, iq] @ blk @ self.pols_q[:, :, if2].T) + blocks = blocks.reshape(self.n_q, nat, 3, nat, 3) + folded = np.einsum( + 'qkab,qaibj->kaibj', + np.conj(self._atom_fourier_kernel), blocks, optimize=True) + return folded.reshape(self.cn_q, nb, nb) + + def _fold_alpha_to_coarse(self, alpha1_fine): + """Folded alpha blocks in the coarse mode basis, ordered as + c_unique_pairs (the Julia kernel input). + + The coarse f_Y filter is applied on both legs AFTER the fold: this is + the coarse Upsilon on the interpolated Upsilon-u legs. + In the commensurate limit f_Y(k1) f_Y(k2) * bare = (w1 w2 / X) * + dressed, i.e. exactly the parent's alpha1. + """ + A = self._fold_alpha_cart(alpha1_fine) + fy = self._get_fy_coarse() + out = [] + for ik1, ik2 in self.c_unique_pairs: + E1 = self.cpols_q[:, :, ik1] + E2 = self.cpols_q[:, :, ik2] + A_mode = E1.conj().T @ A[ik1] @ E2.conj() + A_mode = (fy[:, ik1][:, None] * A_mode + * fy[:, ik2][None, :]) + out.append(A_mode) + return out + + def _interp_d2v_to_fine(self, d2v_coarse): + """Adjoint interpolation of coarse d2v to the fine mode basis.""" + nb = self.n_bands + nat = nb // 3 + coarse = np.zeros((self.cn_q, nb, nb), dtype=np.complex128) + for p, (ik1, ik2) in enumerate(self.c_unique_pairs): + E1 = self.cpols_q[:, :, ik1] + E2 = self.cpols_q[:, :, ik2] + block = E1 @ d2v_coarse[p] @ E2.T + coarse[ik1] = block + if ik1 != ik2: + # Reverse orientation: the kernel's dyadic weights are + # pair-symmetric, so the reversed block is the transpose. + coarse[ik2] = block.T + + iq1 = np.array([pair[0] for pair in self.unique_pairs]) + coarse = coarse.reshape(self.cn_q, nat, 3, nat, 3) + reconstructed = np.einsum( + 'pkab,kaibj->paibj', + self._atom_fourier_kernel[iq1], coarse, optimize=True) + reconstructed = reconstructed.reshape( + len(self.unique_pairs), nb, nb) + + fine = [] + for p, (iq1, iq2) in enumerate(self.unique_pairs): + E1 = self.pols_q[:, :, iq1] + E2 = self.pols_q[:, :, iq2] + fine.append( + E1.conj().T @ reconstructed[p] @ E2.conj()) + return fine + + # ================================================================ + # Anharmonic application + # ================================================================ + def apply_anharmonic_FT(self, transpose=False, **kwargs): + """Fold the fine alpha kernel to the coarse mesh, run the coarse + Julia kernel, and interpolate the outputs back to the fine pairs.""" + if self.ignore_v3 and self.ignore_v4: + return np.zeros(self.get_psi_size(), dtype=np.complex128) + + R1 = self.get_R1_q() + if self.ignore_v3: + R1 = np.zeros_like(R1) + + alpha1_fine = self._get_alpha1_bare() + alpha_coarse = self._fold_alpha_to_coarse(alpha1_fine) + alpha_flat = self._flatten_blocks(alpha_coarse) + + f_pert, d2v_coarse = self._call_julia_qspace_coarse(R1, alpha_flat) + d2v_fine = self._interp_d2v_to_fine(d2v_coarse) + + final_psi = np.zeros(self.get_psi_size(), dtype=np.complex128) + final_psi[:self.n_bands] = f_pert + + chi_minus_list = self.get_chi_minus_q() + chi_plus_list = self.get_chi_plus_q() + for pair_idx in range(len(self.unique_pairs)): + d2v_block = d2v_fine[pair_idx] + pert_a = np.sqrt(-0.5 * chi_minus_list[pair_idx]) * d2v_block + pert_b = -np.sqrt(+0.5 * chi_plus_list[pair_idx]) * d2v_block + self.set_block_in_psi(pair_idx, pert_a, 'a', final_psi) + self.set_block_in_psi(pair_idx, pert_b, 'b', final_psi) + + return final_psi + + def _unflatten_blocks_coarse(self, flat): + """Column-major unflatten over the coarse unique pairs.""" + nb = self.n_bands + blocks = [] + offset = 0 + for _ in self.c_unique_pairs: + blocks.append(flat[offset:offset + nb * nb].reshape( + nb, nb, order='F')) + offset += nb * nb + return blocks + + def _call_julia_qspace_coarse(self, R1, alpha1_flat): + """Parent's kernel call with the coarse-side arrays. + + R1 is in the mode basis at Q, identical on the fine and coarse + sides by the commensurate pinning of the eigenvectors. + """ + if self._distributed: + return self._call_julia_qspace_coarse_distributed(R1, alpha1_flat) + + jl = JuliaExt.get_main() + + n_active_syms = self._spectroscopy_symmetry_count( + self.n_syms_qspace) + reduction_args = self._spectroscopy_reduction_arguments() + n_total = n_active_syms * self.N + n_processors = Parallel.GetNProc() + + count = n_total // n_processors + remainder = n_total % n_processors + indices = [] + for rank in range(n_processors): + if rank < remainder: + start = np.int64(rank * (count + 1)) + stop = np.int64(start + count + 1) + else: + start = np.int64(rank * count + remainder) + stop = np.int64(start + count) + indices.append([start + 1, stop]) # 1-indexed for Julia + + unique_pairs_arr = np.array(self.c_unique_pairs, + dtype=np.int32) + 1 + valid_modes = np.array(self.cvalid_modes_q, dtype=np.bool_) + iq_pert_jl = int(self.c_iq_pert) + 1 + q_pair_map_jl = np.array(self.c_q_pair_map, dtype=np.int32) + 1 + + def get_combined(start_end): + return jl.get_perturb_averages_qspace( + self.X_q, self.Y_q, self.cw_q, self.rho, + R1, alpha1_flat, + float(self.T), bool(not self.ignore_v4), + iq_pert_jl, + q_pair_map_jl, + unique_pairs_arr, + int(start_end[0]), int(start_end[1]), + valid_modes, + float(self.qspace_scale3), float(self.qspace_scale4), + False, *reduction_args) + + combined = Parallel.GoParallel(get_combined, indices, "+") + f_pert = combined[:self.n_bands] + d2v_blocks = self._unflatten_blocks_coarse(combined[self.n_bands:]) + return f_pert, d2v_blocks + + def _call_julia_qspace_coarse_distributed(self, R1, alpha1_flat): + """Coarse kernel call when the configurations are split across ranks. + + The coarse counterpart of + ``QSpaceLanczos._call_julia_qspace_distributed``: instead of every rank + walking a slice of the *same* replicated ensemble through + ``GoParallel``, each rank owns a disjoint block of configurations and + the partial averages are summed with an Allreduce. + + Normalization (identical to the parent, restated because it is the + only subtle part): Julia returns ``partial_k / (n_syms * N_eff_k)``, + so multiplying by the local ``N_eff_k`` gives ``partial_k / n_syms``; + summing those over ranks and dividing by the global ``N_eff`` gives + the correctly weighted average. Rescaling by the *local* weight and + dividing by the *global* one is what makes unequal rank loads and + unequal weight sums come out right. + """ + jl = JuliaExt.get_main() + + if not QL.__MPI4PY__: + raise RuntimeError( + "Distributed mode requires MPI (mpi4py). Use " + "load_distributed_atom_fourier_tdscha under mpirun, or " + "build the Lanczos from an ensemble for a replicated run.") + + comm = QL.mpi4py.MPI.COMM_WORLD + + N_local = self.N + N_eff_local = self.N_eff + N_eff_global = self._N_eff_global + + n_blocks = len(self.c_unique_pairs) * self.n_bands * self.n_bands + if N_local == 0: + # A rank with no configurations still has to take part in the + # reduction, otherwise the others block forever. + combined_local = np.zeros(self.n_bands + n_blocks, + dtype=np.complex128) + else: + unique_pairs_arr = np.array(self.c_unique_pairs, + dtype=np.int32) + 1 + valid_modes = np.array(self.cvalid_modes_q, dtype=np.bool_) + iq_pert_jl = int(self.c_iq_pert) + 1 + q_pair_map_jl = np.array(self.c_q_pair_map, dtype=np.int32) + 1 + + combined_local = jl.get_perturb_averages_qspace( + self.X_q, self.Y_q, self.cw_q, self.rho[:N_local], + R1, alpha1_flat, + float(self.T), bool(not self.ignore_v4), + iq_pert_jl, + q_pair_map_jl, + unique_pairs_arr, + 1, int(self._spectroscopy_symmetry_count( + self.n_syms_qspace) * N_local), + valid_modes, + float(self.qspace_scale3), float(self.qspace_scale4), + False, *self._spectroscopy_reduction_arguments()) + if N_eff_local > 0: + combined_local = combined_local * N_eff_local + + combined_global = np.zeros_like(combined_local) + comm.Allreduce(np.ascontiguousarray(combined_local), combined_global, + op=QL.mpi4py.MPI.SUM) + if N_eff_global > 0: + combined_global = combined_global / N_eff_global + + f_pert = combined_global[:self.n_bands] + d2v_blocks = self._unflatten_blocks_coarse(combined_global[self.n_bands:]) + return f_pert, d2v_blocks + + +def load_distributed_atom_fourier_tdscha( + data_dir, population_id, dyn, T, fine_mesh, + use_symmetries=True, n_configs=None, final_dyn=None, + final_T=None, lo_to_split=None, **kwargs): + """Build an atom-Fourier Lanczos with a distributed ensemble. + + Same contract as ``QSpaceLanczos.load_distributed_tdscha`` -- the ensemble + is read on the master and the configurations are scattered, so each rank + holds only N/n_procs of them instead of a full replica -- but the object + returned is a :class:`QSpaceAtomFourierLanczos`. + + The ensemble is never replicated, not even transiently. The one part of + the interpolated construction that must run on every rank is the harmonic + interpolation, because it broadcasts inside CellConstructor's + ``ForceTensor``; the loader runs it through + :meth:`QSpaceAtomFourierLanczos.prepare_distributed_construction` before + the master goes on to read the configurations alone. That work scales + with the fine mesh and the number of atoms, never with the number of + configurations. + + Parameters + ---------- + fine_mesh : tuple(3) of int + The interpolation mesh, e.g. ``(12, 12, 12)``. + **kwargs + Forwarded to :class:`QSpaceAtomFourierLanczos`. + + Usage + ----- + mpirun -np 4 python driver.py + """ + return QL.load_distributed_tdscha( + data_dir, population_id, dyn, T, + lo_to_split=lo_to_split, use_symmetries=use_symmetries, + n_configs=n_configs, final_dyn=final_dyn, final_T=final_T, + lanczos_class=QSpaceAtomFourierLanczos, + fine_mesh=fine_mesh, **kwargs) diff --git a/Modules/QSpaceInterpolation.py b/Modules/QSpaceInterpolation.py new file mode 100644 index 00000000..6635cfc6 --- /dev/null +++ b/Modules/QSpaceInterpolation.py @@ -0,0 +1,416 @@ +"""Shared q-mesh and harmonic interpolation utilities. + +The anharmonic interpolation lives in :mod:`tdscha.QSpaceAtomFourier`. +This module contains only the order-independent mesh operations and the +second-order force-constant interpolation needed to build its fine harmonic +basis. +""" + +import hashlib +import itertools +from dataclasses import dataclass + +import numpy as np + +import cellconstructor as CC +import cellconstructor.ForceTensor +import cellconstructor.Methods + + +def validate_mesh(mesh, name="mesh"): + """Return a three-component positive integer mesh. + + Parameters + ---------- + mesh : array-like + Three positive integer-valued entries. + name : str + Name used in validation errors. + """ + values = np.asarray(mesh, dtype=object) + if values.shape != (3,): + raise ValueError( + "{} must contain exactly three entries, got shape {}".format( + name, values.shape)) + if any(isinstance(value, (bool, np.bool_)) for value in values.flat): + raise ValueError("{} entries must be positive integers".format(name)) + try: + numeric = values.astype(np.float64) + except (TypeError, ValueError) as error: + raise ValueError( + "{} entries must be positive integers".format(name)) from error + if (not np.all(np.isfinite(numeric)) + or not np.all(numeric == np.rint(numeric)) + or np.any(numeric <= 0)): + raise ValueError( + "{} entries must be positive integers, got {}".format( + name, tuple(values))) + return numeric.astype(int) + + +def generate_fine_mesh(structure, mesh): + """Generate a Gamma-centred uniform q mesh with Gamma first. + + Returns + ------- + q_points : ndarray, shape (prod(mesh), 3) + Cartesian q vectors in the convention used by ``Phonons.q_tot``. + indices : ndarray, shape (prod(mesh), 3) + Integer mesh indices. Fractional q coordinates are ``indices / mesh`` + modulo reciprocal lattice vectors. + """ + mesh = validate_mesh(mesh) + reciprocal = structure.get_reciprocal_vectors() / (2.0 * np.pi) + indices = np.array( + list(itertools.product(*(range(int(n)) for n in mesh))), + dtype=int) + fractional = indices / mesh[None, :] + fractional -= np.floor(fractional + 0.5) + return fractional @ reciprocal, indices + + +def mesh_key(q, structure, mesh, tol=1e-6): + """Return the integer index of a mesh q point modulo the reciprocal cell. + + Raises ``ValueError`` when ``q`` is not commensurate with ``mesh``. + """ + mesh = validate_mesh(mesh) + if not np.isfinite(tol) or tol <= 0: + raise ValueError("tol must be a positive finite number") + q = np.asarray(q, dtype=np.float64) + if q.shape != (3,) or not np.all(np.isfinite(q)): + raise ValueError("q must be a finite Cartesian vector of shape (3,)") + fractional = np.asarray(structure.unit_cell) @ q + scaled = fractional * mesh + nearest = np.rint(scaled) + if np.max(np.abs(scaled - nearest)) > tol * np.max(mesh): + raise ValueError( + "q-point {} is not on mesh {} (fractional q * mesh = {})".format( + q, tuple(mesh), scaled)) + return tuple(nearest.astype(int) % mesh) + + +def build_q_index_lookup(q_points, structure, mesh, tol=1e-6): + """Map integer mesh indices to positions in a q-point array.""" + q_points = np.asarray(q_points, dtype=np.float64) + if q_points.ndim != 2 or q_points.shape[1] != 3: + raise ValueError( + "q_points must have shape (n_q, 3), got {}".format( + q_points.shape)) + lookup = {} + for iq, q in enumerate(q_points): + key = mesh_key(q, structure, mesh, tol) + if key in lookup: + raise ValueError( + "q_points contains duplicate mesh point {} at indices {} " + "and {}".format(key, lookup[key], iq)) + lookup[key] = iq + return lookup + + +def _matching_q(q, candidates, reciprocal, tol=1e-6): + """Index of ``q`` in ``candidates`` modulo a reciprocal vector, or -1.""" + for index, candidate in enumerate(candidates): + distance = CC.Methods.get_min_dist_into_cell( + reciprocal, np.asarray(q), np.asarray(candidate)) + if distance < tol: + return index + return -1 + + +def interpolate_dyn_fine( + dyn, q_points, use_asr=True, reuse_commensurate=True, + ignore_effective_charges=False, lo_to_split=None, verbose=False): + """Fourier-interpolate a dynamical matrix at arbitrary q points. + + The real-space second-order force constants are centred before + interpolation and optionally projected onto the acoustic sum rule. + Commensurate input matrices are reused exactly, and time-reversed pairs + share conjugate eigenvectors. + + ``ignore_effective_charges=True`` removes Born charges and the dielectric + tensor on a private copy used only for this interpolation. This is useful + when the ensemble forces came from a strictly short-range potential but + ``dyn`` contains long-range metadata inherited from another calculation. + The caller's object is never modified. + + ``lo_to_split`` controls the nonanalytic Gamma limit: ``None`` disables + it, ``"random"`` lets CellConstructor choose a direction, and a finite + nonzero three-vector selects an explicit propagation direction. At + nonzero q the usual tensorial dipole--dipole interpolation is retained. + + Returns + ------- + frequencies : ndarray, shape (3 * n_atoms, n_q) + Signed phonon frequencies in Ry. + polarizations : ndarray, shape (3 * n_atoms, 3 * n_atoms, n_q) + Complex polarization vectors. + """ + q_points = np.asarray(q_points, dtype=np.float64) + if q_points.ndim != 2 or q_points.shape[1] != 3: + raise ValueError( + "q_points must have shape (n_q, 3), got {}".format( + q_points.shape)) + if len(q_points) == 0: + raise ValueError("q_points must contain at least one q-point") + if not np.all(np.isfinite(q_points)): + raise ValueError("q_points must contain only finite values") + + if isinstance(lo_to_split, str): + if lo_to_split != "random": + raise ValueError( + "lo_to_split must be None, 'random', or a three-vector") + q_direct = None + use_gamma_nonanalytic = True + elif lo_to_split is None: + q_direct = None + use_gamma_nonanalytic = False + else: + q_direct = np.asarray(lo_to_split, dtype=float) + if (q_direct.shape != (3,) or not np.all(np.isfinite(q_direct)) or + np.linalg.norm(q_direct) <= 1e-14): + raise ValueError("lo_to_split must be a finite nonzero three-vector") + use_gamma_nonanalytic = True + + if ignore_effective_charges: + # The flag is local to harmonic interpolation. It suppresses the + # complete dipolar correction, including the directional Gamma + # limit, without modifying dyn.effective_charges. Those charges can + # therefore still define an IR perturbation downstream. + q_direct = None + use_gamma_nonanalytic = False + + work_dyn = dyn + if ignore_effective_charges and dyn.effective_charges is not None: + work_dyn = dyn.Copy() + work_dyn.effective_charges = None + work_dyn.dielectric_tensor = None + + structure = work_dyn.structure + supercell = validate_mesh(work_dyn.GetSupercell(), "coarse mesh") + super_structure = structure.generate_supercell(supercell) + tensor2 = CC.ForceTensor.Tensor2( + structure, super_structure, supercell) + tensor2.SetupFromPhonons(work_dyn) + tensor2.Center() + if use_asr: + tensor2.Apply_ASR() + + n_q = len(q_points) + n_bands = 3 * structure.N_atoms + masses = np.repeat(structure.get_masses_array(), 3) + mass_factor = 1.0 / np.sqrt(np.outer(masses, masses)) + reciprocal = structure.get_reciprocal_vectors() / (2.0 * np.pi) + + commensurate = np.full(n_q, -1, dtype=int) + if reuse_commensurate: + for iq, q in enumerate(q_points): + commensurate[iq] = _matching_q( + q, work_dyn.q_tot, reciprocal) + + negative = np.full(n_q, -1, dtype=int) + for iq, q in enumerate(q_points): + negative[iq] = _matching_q(-q, q_points, reciprocal) + + frequencies = np.zeros((n_bands, n_q), dtype=np.float64) + polarizations = np.zeros( + (n_bands, n_bands, n_q), dtype=np.complex128) + done = np.zeros(n_q, dtype=bool) + + for iq, q in enumerate(q_points): + if done[iq]: + continue + if commensurate[iq] >= 0: + force_constants = np.array( + work_dyn.dynmats[commensurate[iq]], dtype=np.complex128) + else: + # Tensor2 and Phonons use opposite Fourier phase conventions. + at_gamma = np.linalg.norm( + CC.Methods.get_min_dist_into_cell( + reciprocal, np.asarray(q), np.zeros(3))) < 1e-8 + force_constants = tensor2.Interpolate( + -q, asr=False, + lo_to_splitting=(use_gamma_nonanalytic and at_gamma), + q_direct=(q_direct if at_gamma else None)) + + dynamical = force_constants * mass_factor + dynamical = 0.5 * (dynamical + dynamical.conj().T) + if negative[iq] == iq: + dynamical = dynamical.real + + eigenvalues, eigenvectors = np.linalg.eigh(dynamical) + frequencies[:, iq] = ( + np.sign(eigenvalues) * np.sqrt(np.abs(eigenvalues))) + polarizations[:, :, iq] = eigenvectors + done[iq] = True + + jq = negative[iq] + if jq >= 0 and jq != iq and not done[jq]: + frequencies[:, jq] = frequencies[:, iq] + polarizations[:, :, jq] = eigenvectors.conj() + done[jq] = True + + if verbose: + print( + "Interpolated the dynamical matrix at {} q-points " + "({} commensurate points reused).".format( + n_q, int(np.sum(commensurate >= 0)))) + + return frequencies, polarizations + + +def _interpolation_input_fingerprint(dyn, fine_mesh, use_asr, + ignore_effective_charges, lo_to_split): + """Digest of everything ``build_fine_harmonic`` reads. + + Two calls agreeing on this digest cannot produce different frequencies + or polarization vectors, which is what makes a precomputed + interpolation safe to inject into a constructor. + """ + digest = hashlib.sha256() + + def absorb(label, value): + digest.update(label.encode("ascii")) + if value is None: + digest.update(b"") + return + array = np.asarray(value) + digest.update(str(array.shape).encode("ascii")) + if array.dtype.kind in "OUS": + digest.update(repr(array.tolist()).encode("utf-8")) + return + # One canonical numeric type on purpose. Passing the same matrix + # through an Ensemble drops a Gamma block from complex128 to + # float64 without changing a single value, and the digest must not + # call that a different dynamical matrix. + digest.update(np.ascontiguousarray( + array, dtype=np.complex128).tobytes()) + + structure = dyn.structure + absorb("cell", structure.unit_cell) + absorb("coords", structure.coords) + absorb("types", np.asarray(structure.get_atomic_types())) + absorb("masses", structure.get_masses_array()) + absorb("supercell", np.asarray(dyn.GetSupercell())) + absorb("q_tot", np.asarray(dyn.q_tot)) + for index, matrix in enumerate(dyn.dynmats): + absorb("dynmat{}".format(index), matrix) + absorb("effective_charges", getattr(dyn, "effective_charges", None)) + absorb("dielectric_tensor", getattr(dyn, "dielectric_tensor", None)) + absorb("fine_mesh", np.asarray(fine_mesh)) + absorb("use_asr", np.asarray([bool(use_asr)])) + absorb("ignore_effective_charges", + np.asarray([bool(ignore_effective_charges)])) + if isinstance(lo_to_split, str): + absorb("lo_to_split_mode", np.asarray([lo_to_split])) + else: + absorb("lo_to_split", lo_to_split) + return digest.hexdigest() + + +@dataclass(frozen=True) +class FineHarmonicInterpolation: + """The harmonic content of an interpolated calculation on a fine mesh. + + This is everything the interpolated Lanczos backends need from the + dynamical matrix, and nothing that depends on the stochastic ensemble. + Keeping it as one immutable value lets a caller build it once -- see + :func:`build_fine_harmonic` -- and hand the same object to several + constructions, which is what makes the distributed loaders able to run + the collective part of the interpolation on every MPI rank while the + configurations are read only by the master. + """ + + fine_mesh: tuple + q_points: np.ndarray + indices: np.ndarray + frequencies: np.ndarray + polarizations: np.ndarray + input_fingerprint: str + + @property + def n_q(self): + return len(self.q_points) + + @property + def n_bands(self): + return self.frequencies.shape[0] + + def validate_for(self, dyn, fine_mesh, use_asr=True, + ignore_effective_charges=False, lo_to_split=None): + """Raise unless this is the interpolation those arguments produce. + + Guards the injection path. An interpolation built from a different + dynamical matrix, mesh, or long-range convention would pair the + ensemble's Bloch fields with the wrong polarization vectors -- a + wrong spectrum with nothing anywhere to signal it. + """ + fine_mesh = validate_mesh(fine_mesh, "fine_mesh") + if tuple(self.fine_mesh) != tuple(int(item) for item in fine_mesh): + raise ValueError( + "the precomputed harmonic interpolation was built on mesh " + "{}, but this calculation uses {}".format( + tuple(self.fine_mesh), tuple(int(m) for m in fine_mesh))) + expected_n_q = int(np.prod(fine_mesh)) + n_bands = 3 * dyn.structure.N_atoms + if self.n_q != expected_n_q: + raise ValueError( + "the precomputed harmonic interpolation has {} q-points, " + "mesh {} requires {}".format( + self.n_q, tuple(self.fine_mesh), expected_n_q)) + if self.frequencies.shape != (n_bands, expected_n_q): + raise ValueError( + "the precomputed harmonic interpolation has frequencies of " + "shape {}, expected {}".format( + self.frequencies.shape, (n_bands, expected_n_q))) + if self.polarizations.shape != (n_bands, n_bands, expected_n_q): + raise ValueError( + "the precomputed harmonic interpolation has polarizations " + "of shape {}, expected {}".format( + self.polarizations.shape, + (n_bands, n_bands, expected_n_q))) + expected = _interpolation_input_fingerprint( + dyn, fine_mesh, use_asr, ignore_effective_charges, lo_to_split) + if expected != self.input_fingerprint: + raise ValueError( + "the precomputed harmonic interpolation was not built from " + "this dynamical matrix and these interpolation settings; " + "using it would contract the ensemble in a mode basis that " + "does not belong to it") + + +def build_fine_harmonic(dyn, fine_mesh, use_asr=True, + ignore_effective_charges=False, lo_to_split=None, + verbose=False): + """Fourier-interpolate ``dyn`` onto a Gamma-centred fine mesh. + + This is the part of an interpolated Lanczos construction that depends + only on the dynamical matrix. It is also the part that performs MPI + collectives: ``interpolate_dyn_fine`` goes through CellConstructor's + ``ForceTensor.Tensor2``, whose ``Center`` and ``Apply_ASR`` end with an + unconditional ``Settings.broadcast``. + + **Every MPI rank must call this function together.** A rank that skipped + it while another ran it would leave the two processes in different + collectives; the mismatch is not diagnosed by MPI and shows up either as + a hang or, worse, as one rank receiving the force-constant tensor in + place of the message it was actually waiting for. + + Returns + ------- + FineHarmonicInterpolation + The mesh, its integer indices, and the interpolated frequencies and + polarization vectors. + """ + fine_mesh = validate_mesh(fine_mesh, "fine_mesh") + q_fine, idx_fine = generate_fine_mesh(dyn.structure, fine_mesh) + frequencies, polarizations = interpolate_dyn_fine( + dyn, q_fine, use_asr=use_asr, + ignore_effective_charges=ignore_effective_charges, + reuse_commensurate=True, lo_to_split=lo_to_split, verbose=verbose) + return FineHarmonicInterpolation( + fine_mesh=tuple(int(item) for item in fine_mesh), + q_points=q_fine, indices=idx_fine, + frequencies=frequencies, polarizations=polarizations, + input_fingerprint=_interpolation_input_fingerprint( + dyn, fine_mesh, use_asr, ignore_effective_charges, lo_to_split)) diff --git a/Modules/QSpaceKPM.py b/Modules/QSpaceKPM.py deleted file mode 100644 index 361fed8d..00000000 --- a/Modules/QSpaceKPM.py +++ /dev/null @@ -1,702 +0,0 @@ -"""Q-space KPM built on top of QSpaceLanczos.""" - -from __future__ import print_function, division - -import os -import sys -import time - -import numpy as np - -from tdscha.QSpaceLanczos import QSpaceLanczos, __EPSILON__ -from cellconstructor.Settings import ParallelPrint as print -import cellconstructor.Settings as Parallel -import cellconstructor.Units as Units - - -class QSpaceKPM(QSpaceLanczos): - """Kernel Polynomial Method using the q-space Liouvillian.""" - - def __init__(self, ensemble, lo_to_split=None, **kwargs): - # Handle ensemble=None case (for loading KPM files for plotting) - if ensemble is None: - # Call grandparent's __init__ with None to get bare initialization - import tdscha.DynamicalLanczos as DL - DL.Lanczos.__init__(self, ensemble=None) - # Set default for use_wigner (needed for spectral function computation) - self.use_wigner = True - # Register KPM attributes AND QSpaceLanczos attributes needed for bare init - self.__total_attributes__.extend([ - # KPM attributes - "kpm_moments", "kpm_vector_norm", "kpm_n_moments", - "kpm_lambda_min", "kpm_lambda_max", "kpm_rescale_a", - "kpm_rescale_b", "kpm_bound_factor", - "kpm_chebyshev_vm", "kpm_chebyshev_v", - # QSpaceLanczos attributes needed for from_qspace_lanczos - "n_q", "n_bands", "q_points", "w_q", "pols_q", "valid_modes_q", - "m", "X_q", "Y_q", "rho", - "iq_pert", "q_pair_map", "unique_pairs", - "_psi_size", "_block_offsets_a", "_block_offsets_b", "_block_sizes", - "n_syms_qspace", "_qspace_sym_data", "_qspace_sym_q_map", - "_distributed", "_N_global", "_N_eff_global", "_N_local", - "lo_to_split", - ]) - self._init_kpm_attributes() - return - - super().__init__(ensemble, lo_to_split=lo_to_split, **kwargs) - self.__total_attributes__.extend([ - "kpm_moments", "kpm_vector_norm", "kpm_n_moments", - "kpm_lambda_min", "kpm_lambda_max", "kpm_rescale_a", - "kpm_rescale_b", "kpm_bound_factor", - "kpm_chebyshev_vm", "kpm_chebyshev_v" - ]) - self._init_kpm_attributes() - - def _init_kpm_attributes(self): - self.kpm_moments = None - self.kpm_vector_norm = None - self.kpm_n_moments = 0 - self.kpm_lambda_min = None - self.kpm_lambda_max = None - self.kpm_rescale_a = None - self.kpm_rescale_b = None - self.kpm_bound_factor = None - self.kpm_chebyshev_vm = None - self.kpm_chebyshev_v = None - - def _invalidate_kpm_cache(self): - self.kpm_moments = None - self.kpm_vector_norm = None - self.kpm_n_moments = 0 - self.kpm_lambda_min = None - self.kpm_lambda_max = None - self.kpm_rescale_a = None - self.kpm_rescale_b = None - self.kpm_bound_factor = None - self.kpm_chebyshev_vm = None - self.kpm_chebyshev_v = None - - def save_status(self, file): - """Save KPM iteration state to NPZ file for checkpoint/restart. - - Saves all KPM parameters, moments, and Chebyshev recurrence vectors - so that run_KPM can be continued from where it left off. - - Parameters - ---------- - file : str - Path to the output file. '.npz' extension is added if missing. - """ - if self.kpm_moments is None: - raise ValueError("Run run_KPM before saving status") - Parallel.barrier() - if Parallel.am_i_the_master(): - if ".npz" not in file.lower(): - file += ".npz" - save_dict = dict( - kpm_n_moments=self.kpm_n_moments, - kpm_vector_norm=self.kpm_vector_norm, - kpm_lambda_min=self.kpm_lambda_min, - kpm_lambda_max=self.kpm_lambda_max, - kpm_rescale_a=self.kpm_rescale_a, - kpm_rescale_b=self.kpm_rescale_b, - kpm_bound_factor=self.kpm_bound_factor, - kpm_moments=self.kpm_moments, - ) - if self.kpm_chebyshev_vm is not None: - save_dict["kpm_chebyshev_vm"] = self.kpm_chebyshev_vm - if self.kpm_chebyshev_v is not None: - save_dict["kpm_chebyshev_v"] = self.kpm_chebyshev_v - np.savez_compressed(file, **save_dict) - - def load_status(self, file): - """Load KPM iteration state from NPZ file. - - Restores all KPM parameters, moments, and (if present) the Chebyshev - recurrence vectors needed for continuation with run_KPM. - - Parameters - ---------- - file : str - Path to the NPZ file. '.npz' extension is added if missing. - """ - Parallel.barrier() - if ".npz" not in file.lower(): - file += ".npz" - if Parallel.am_i_the_master(): - if not os.path.exists(file): - raise IOError("KPM status file not found: {}".format(file)) - data = dict(np.load(file, allow_pickle=True)) - else: - data = None - data = Parallel.broadcast(data) - - self.kpm_n_moments = int(data["kpm_n_moments"]) - self.kpm_vector_norm = float(data["kpm_vector_norm"]) - self.kpm_lambda_min = float(data["kpm_lambda_min"]) - self.kpm_lambda_max = float(data["kpm_lambda_max"]) - self.kpm_rescale_a = float(data["kpm_rescale_a"]) - self.kpm_rescale_b = float(data["kpm_rescale_b"]) - self.kpm_bound_factor = float(data["kpm_bound_factor"]) - self.kpm_moments = data["kpm_moments"] - # Chebyshev vectors for continuation (may be absent in old files) - self.kpm_chebyshev_vm = data.get("kpm_chebyshev_vm", None) - self.kpm_chebyshev_v = data.get("kpm_chebyshev_v", None) - - def reset_q(self): - super().reset_q() - self._invalidate_kpm_cache() - - def _metric_dot(self, v1, v2, mask=None): - if mask is None: - mask = self.mask_dot_wigner(False) - return np.real(np.conj(v1).dot(v2 * mask)) - - def _get_kpm_bounds(self, lambda_min=None, lambda_max=None, bound_factor=2.5, edge_buffer=1e-8): - if (lambda_min is None) != (lambda_max is None): - raise ValueError("lambda_min and lambda_max must be both specified or both omitted") - if lambda_min is None: - max_w = np.max(np.abs(self.w_q[self.valid_modes_q])) - spectral_width = (2 * max_w) ** 2 - margin = (bound_factor - 1) * spectral_width - if self.use_wigner: - # Wigner eigenvalues in [-(2*w_max)^2, 0] - lmin = -spectral_width - margin - lmax = margin - else: - # Non-Wigner eigenvalues in [0, (2*w_max)^2] - lmin = -margin - lmax = spectral_width + margin - span = lmax - lmin - return lmin - edge_buffer * span, lmax + edge_buffer * span - if lambda_min >= lambda_max: - raise ValueError("Invalid KPM bounds: lambda_min must be smaller than lambda_max") - span = lambda_max - lambda_min - return lambda_min - edge_buffer * span, lambda_max + edge_buffer * span - - def estimate_kpm_steps(self, precision_cm, bound_factor=1.2, regularization="jackson"): - """Estimate the number of KPM steps needed to achieve desired frequency precision. - - The KPM resolution in eigenvalue space (λ = -ω²) scales as: - Δλ ≈ π × rescale_a / n_moments (for Jackson kernel) - - Converting to frequency precision δω via dλ/dω = -2ω: - δλ = 2 × ω_min × δω - - Therefore, the required number of moments is: - n_moments ≈ π × rescale_a / (2 × ω_min × δω) - - where rescale_a ≈ 0.5 × bound_factor × (2 × ω_max)² - - Parameters - ---------- - precision_cm : float - Desired frequency precision in cm⁻¹. The KPM will be able to resolve - peaks separated by at least this amount at the smallest frequency. - bound_factor : float, default=1.2 - The bound factor to use for KPM bounds. Must be > 1.0. - regularization : str, default="jackson" - The regularization kernel type. Currently only "jackson" is supported. - - Returns - ------- - int - Estimated number of KPM steps (moments) required. - - Raises - ------ - ValueError - If precision_cm is not positive, bound_factor <= 1.0, or if - no perturbation has been prepared (iq_pert is None). - """ - if precision_cm <= 0: - raise ValueError("precision_cm must be positive") - if bound_factor <= 1.0: - raise ValueError("bound_factor must be > 1.0") - if self.iq_pert is None: - raise ValueError("Must prepare a perturbation before estimating KPM steps") - - # Get frequency range - max_w = np.max(np.abs(self.w_q[self.valid_modes_q])) - - # Get smallest non-zero frequency at the perturbation q-point - w_at_q = self.w_q[:, self.iq_pert] - valid_at_q = self.valid_modes_q[:, self.iq_pert] - w_min = np.min(np.abs(w_at_q[valid_at_q])) - - if w_min < __EPSILON__: - raise ValueError("Smallest frequency at q-point is effectively zero; cannot estimate steps") - - # Convert precision from cm⁻¹ to Ry - delta_omega = precision_cm / Units.RY_TO_CM - - # Compute KPM bounds and rescale_a - lambda_min, lambda_max = self._get_kpm_bounds( - lambda_min=None, lambda_max=None, - bound_factor=bound_factor, edge_buffer=1e-8) - rescale_a = 0.5 * (lambda_max - lambda_min) - - # Resolution in λ-space: δλ = 2 × ω_min × δω - delta_lambda = 2.0 * w_min * delta_omega - - # For Jackson kernel, resolution is approximately π × rescale_a / n_moments - # So n_moments ≈ π × rescale_a / δλ - if regularization.lower() == "jackson": - n_moments = int(np.ceil(np.pi * rescale_a / delta_lambda)) - else: - # For no regularization, resolution is better but Gibbs oscillations occur - # Use a conservative estimate (same as Jackson) - n_moments = int(np.ceil(np.pi * rescale_a / delta_lambda)) - - # Ensure at least a minimum number of steps - n_moments = max(n_moments, 8) - - return n_moments - - def _apply_rescaled_L(self, vec): - return (self.apply_full_L(vec) - self.kpm_rescale_b * vec) / self.kpm_rescale_a - - def run_KPM(self, n_moments, lambda_min=None, lambda_max=None, bound_factor=1.2, - edge_buffer=1e-8, verbose=True): - """Run the Kernel Polynomial Method to compute Chebyshev moments. - - Uses the two-vector Chebyshev trick to extract two moments per - L application, halving the number of expensive operator calls. - The identity mu_{m+n} + mu_{|m-n|} = 2 _M - (valid because L is self-adjoint under the Wigner metric) gives: - mu_{2n} = 2 _M - mu_0 - mu_{2n+1} = 2 _M - mu_1 - - If Chebyshev recurrence vectors are available from a previous run - (via save_status/load_status), the iteration continues from where - it left off rather than restarting from scratch. - - Parameters - ---------- - n_moments : int - Total number of Chebyshev moments to compute. - lambda_min, lambda_max : float, optional - Explicit bounds for the KPM. If not provided, bounds are - estimated automatically from the maximum phonon frequency. - Ignored during continuation (previous bounds are reused). - bound_factor : float, default=1.2 - Factor controlling the width of the KPM bounds relative to - the estimated spectral width. Smaller values (closer to 1.0) - give better resolution but require the eigenvalue to be within - the bounds. Values > 1.5 may cause significant baseline artifacts. - Ignored during continuation. - edge_buffer : float, default=1e-8 - Small buffer added to the bounds to avoid edge effects. - Ignored during continuation. - verbose : bool, default=True - Print progress information. - """ - if n_moments < 1: - raise ValueError("n_moments must be positive") - if self.psi is None: - raise ValueError("Prepare a perturbation before running KPM") - - mask = self.mask_dot_wigner(False) - n_L_applications = 0 - - # Check for continuation from a previous run - existing = self.kpm_n_moments - can_continue = (existing > 0 and self.kpm_moments is not None - and self.kpm_chebyshev_v is not None) - - if can_continue and n_moments <= existing: - if verbose: - print("KPM: already have {} moments, {} requested. Nothing to do.".format( - existing, n_moments)) - return - - if can_continue: - # --- Continuation path --- - if verbose: - print("KPM: continuing from {} to {} moments".format(existing, n_moments)) - - moments = np.zeros(n_moments, dtype=np.float64) - moments[:existing] = self.kpm_moments[:existing] - vm = self.kpm_chebyshev_vm.copy() - v = self.kpm_chebyshev_v.copy() - - if existing == 1: - # Only moment[0]=1.0 exists, v=T_0. Need to compute T_1 and moment[1]. - v1 = self._apply_rescaled_L(v) - n_L_applications += 1 - moments[1] = self._metric_dot(vm, v1, mask) - # vm was T_{-1} placeholder; now set vm=T_0, v=T_1 - vm, v = v, v1 - n_start = 1 - if verbose: - print("KPM: moment 1 = {:.8e}".format(moments[1])) - elif existing % 2 == 0: - # Even count: last step completed cleanly. - # vm = T_{n-1}, v = T_n where n = existing // 2 - n_start = existing // 2 - else: - # Odd count: broke mid-step after even moment. - # vm = T_{n-1}, v = T_n where n = existing // 2 - # Need to compute the missing odd moment and advance. - n_half = existing // 2 - # Compute T_{n+1} to get the odd moment - vp = 2 * self._apply_rescaled_L(v) - vm - n_L_applications += 1 - moments[2 * n_half + 1] = ( - 2 * self._metric_dot(vp, v, mask) - moments[1]) - if verbose: - print("KPM: moment {} = {:.8e} (completing interrupted step)".format( - 2 * n_half + 1, moments[2 * n_half + 1])) - vm, v = v, vp - n_start = n_half + 1 - - self.kpm_n_moments = int(n_moments) - - # Continue the main loop - n = n_start - while 2 * n < self.kpm_n_moments: - if verbose: - print("\n ===== KPM STEP {} =====\n".format(n)) - sys.stdout.flush() - - moments[2 * n] = 2 * self._metric_dot(v, v, mask) - moments[0] - if verbose: - print("KPM: moment {} = {:.8e}".format(2 * n, moments[2 * n])) - if 2 * n + 1 >= self.kpm_n_moments: - break - - t1 = time.time() - vp = 2 * self._apply_rescaled_L(v) - vm - t2 = time.time() - n_L_applications += 1 - moments[2 * n + 1] = ( - 2 * self._metric_dot(vp, v, mask) - moments[1]) - if verbose: - print("Time for L application: {:.3f} s".format(t2 - t1)) - print("KPM: moment {} = {:.8e}".format( - 2 * n + 1, moments[2 * n + 1])) - print("KPM step {} completed.".format(n)) - - vm, v = v, vp - n += 1 - - else: - # --- Fresh start path --- - psi0 = self.psi.copy() - norm_sq = self._metric_dot(psi0, psi0, mask) - if norm_sq <= __EPSILON__ or np.isnan(norm_sq): - raise ValueError("Prepare a non-zero perturbation before running KPM") - - self.kpm_lambda_min, self.kpm_lambda_max = self._get_kpm_bounds( - lambda_min=lambda_min, lambda_max=lambda_max, - bound_factor=bound_factor, edge_buffer=edge_buffer) - self.kpm_rescale_a = 0.5 * (self.kpm_lambda_max - self.kpm_lambda_min) - self.kpm_rescale_b = 0.5 * (self.kpm_lambda_max + self.kpm_lambda_min) - self.kpm_vector_norm = np.sqrt(norm_sq) - self.kpm_n_moments = int(n_moments) - self.kpm_bound_factor = bound_factor - - v0 = psi0 / self.kpm_vector_norm - moments = np.zeros(self.kpm_n_moments, dtype=np.float64) - moments[0] = 1.0 - - if verbose: - print("KPM: moment 0 = {:.8e}, norm = {:.8e}".format( - moments[0], self.kpm_vector_norm)) - - vm, v = v0, v0 # placeholder until v1 is computed - if self.kpm_n_moments > 1: - v1 = self._apply_rescaled_L(v0) - n_L_applications += 1 - moments[1] = self._metric_dot(v0, v1, mask) - if verbose: - print("KPM: moment 1 = {:.8e}".format(moments[1])) - - vm, v = v0, v1 - n = 1 - while 2 * n < self.kpm_n_moments: - if verbose: - print("\n ===== KPM STEP {} =====\n".format(n)) - sys.stdout.flush() - - moments[2 * n] = 2 * self._metric_dot(v, v, mask) - moments[0] - if verbose: - print("KPM: moment {} = {:.8e}".format( - 2 * n, moments[2 * n])) - if 2 * n + 1 >= self.kpm_n_moments: - break - - t1 = time.time() - vp = 2 * self._apply_rescaled_L(v) - vm - t2 = time.time() - n_L_applications += 1 - moments[2 * n + 1] = ( - 2 * self._metric_dot(vp, v, mask) - moments[1]) - if verbose: - print("Time for L application: {:.3f} s".format(t2 - t1)) - print("KPM: moment {} = {:.8e}".format( - 2 * n + 1, moments[2 * n + 1])) - print("KPM step {} completed.".format(n)) - - vm, v = v, vp - n += 1 - - self.psi = psi0 - - # Save Chebyshev recurrence vectors for potential continuation - self.kpm_chebyshev_vm = vm.copy() - self.kpm_chebyshev_v = v.copy() - self.kpm_moments = moments - - if verbose: - print("KPM completed: {} moments, {} L applications".format( - self.kpm_n_moments, n_L_applications)) - - def save_kpm(self, file): - """ - Save KPM moments and parameters to a single text file. - The first line is a comment with all parameters, followed by the moments. - Only master process saves the file. - """ - if self.kpm_moments is None: - raise ValueError("Run run_KPM before saving") - Parallel.barrier() - if not Parallel.am_i_the_master(): - return - header = "kpm_n_moments={} kpm_vector_norm={} kpm_lambda_min={} kpm_lambda_max={} kpm_rescale_a={} kpm_rescale_b={}".format( - self.kpm_n_moments, self.kpm_vector_norm, - self.kpm_lambda_min, self.kpm_lambda_max, - self.kpm_rescale_a, self.kpm_rescale_b) - np.savetxt(file, self.kpm_moments, header=header) - - def load_kpm(self, file): - """ - Load KPM moments and parameters from file. - Parses the header to restore state for get_spectral_function_KPM. - Only master process loads and broadcasts to others. - """ - Parallel.barrier() - if Parallel.am_i_the_master(): - with open(file, 'r') as f: - header_line = f.readline() - if not header_line.startswith('#'): - raise ValueError("Invalid KPM save file: missing header") - parts = header_line[1:].strip().split() - params = {} - for part in parts: - if '=' in part: - key, val = part.split('=') - try: - params[key] = float(val) - except ValueError: - params[key] = int(val) - moments = np.loadtxt(file) - data = { - 'kpm_n_moments': int(params['kpm_n_moments']), - 'kpm_vector_norm': params['kpm_vector_norm'], - 'kpm_lambda_min': params['kpm_lambda_min'], - 'kpm_lambda_max': params['kpm_lambda_max'], - 'kpm_rescale_a': params['kpm_rescale_a'], - 'kpm_rescale_b': params['kpm_rescale_b'], - 'kpm_moments': moments - } - else: - data = None - data = Parallel.broadcast(data) - self.kpm_n_moments = data['kpm_n_moments'] - self.kpm_vector_norm = data['kpm_vector_norm'] - self.kpm_lambda_min = data['kpm_lambda_min'] - self.kpm_lambda_max = data['kpm_lambda_max'] - self.kpm_rescale_a = data['kpm_rescale_a'] - self.kpm_rescale_b = data['kpm_rescale_b'] - self.kpm_moments = data['kpm_moments'] - - @staticmethod - def _jackson_kernel(n_moments): - n = np.arange(n_moments, dtype=np.float64) - phi = np.pi / (n_moments + 1) - return ((n_moments - n + 1) * np.cos(n * phi) + np.sin(n * phi) / np.tan(phi)) / (n_moments + 1) - - def _get_kpm_damping(self, regularization="jackson", damping_factors=None): - if self.kpm_moments is None: - raise ValueError("Run run_KPM before requesting the spectral function") - if damping_factors is not None: - g = np.asarray(damping_factors, dtype=np.float64) - elif regularization is None or str(regularization).lower() == "none": - g = np.ones(self.kpm_n_moments, dtype=np.float64) - elif str(regularization).lower() == "jackson": - g = self._jackson_kernel(self.kpm_n_moments) - else: - raise ValueError("Unknown KPM regularization: {}".format(regularization)) - if len(g) != self.kpm_n_moments: - raise ValueError("Damping factors must have length {}".format(self.kpm_n_moments)) - return g - - def get_spectral_function_KPM(self, w_array, regularization="jackson", damping_factors=None): - g = self._get_kpm_damping(regularization=regularization, damping_factors=damping_factors) - w_array = np.asarray(w_array, dtype=np.float64) - lambdas = -(w_array ** 2) if self.use_wigner else (w_array ** 2) - x = (lambdas - self.kpm_rescale_b) / self.kpm_rescale_a - inside = np.abs(x) < 1 - spectral = np.zeros_like(w_array, dtype=np.float64) - if not np.any(inside): - return spectral - - xin = np.clip(x[inside], -1 + 1e-15, 1 - 1e-15) - t0 = np.ones_like(xin) - rho = g[0] * self.kpm_moments[0] * t0 - if self.kpm_n_moments > 1: - t1 = xin.copy() - rho += 2 * g[1] * self.kpm_moments[1] * t1 - for i in range(2, self.kpm_n_moments): - t2 = 2 * xin * t1 - t0 - rho += 2 * g[i] * self.kpm_moments[i] * t2 - t0, t1 = t1, t2 - rho /= np.pi * self.kpm_rescale_a * np.sqrt(1 - xin ** 2) - rho *= self.kpm_vector_norm ** 2 - spectral[inside] = np.pi * rho - return spectral - - @classmethod - def from_qspace_lanczos(cls, qlanc): - """Create a QSpaceKPM from an existing QSpaceLanczos object. - - This method reuses the qlanc object and extends it with KPM attributes. - The object shares the same data (X_q, Y_q, rho, etc.) with the - input QSpaceLanczos object. - - Parameters - ---------- - qlanc : QSpaceLanczos - An initialized QSpaceLanczos object. - - Returns - ------- - QSpaceKPM - A QSpaceKPM object with the same underlying data. - """ - # Get all attributes from qlanc (including those not in __total_attributes__) - # that are needed for KPM operation - attrs_to_copy = [ - # Distributed mode attributes - '_distributed', '_N_global', '_N_eff_global', '_N_local', - # Ensemble data - 'ensemble', 'T', 'N', 'N_eff', 'rho', - # Q-space specific - 'n_q', 'n_bands', 'q_points', 'w_q', 'pols_q', 'valid_modes_q', - 'm', 'X_q', 'Y_q', - # Lanczos state - 'iq_pert', 'q_pair_map', 'unique_pairs', - '_psi_size', '_block_offsets_a', '_block_offsets_b', '_block_sizes', - 'n_syms_qspace', '_qspace_sym_data', '_qspace_sym_q_map', - # Flags - 'ignore_v3', 'ignore_v4', 'ignore_harmonic', 'use_wigner', - # Dynamical matrix - 'dyn', 'uci_structure', 'super_structure', - # Psi-related - 'psi', 'initialized', 'verbose', - ] - - # Create a new KPM instance (bare initialization with ensemble=None) - kpm = cls(None, lo_to_split=qlanc.lo_to_split if hasattr(qlanc, 'lo_to_split') else None) - - # Copy all relevant attributes from qlanc to kpm - for attr in attrs_to_copy: - if hasattr(qlanc, attr): - val = getattr(qlanc, attr) - try: - setattr(kpm, attr, val) - except Exception as e: - # Log but continue - some attributes may not be copyable - pass - - # Also copy any additional attributes that qlanc has - for attr in qlanc.__total_attributes__: - if attr not in attrs_to_copy and hasattr(qlanc, attr): - val = getattr(qlanc, attr) - try: - setattr(kpm, attr, val) - except Exception as e: - pass - - # Ensure __total_attributes__ includes all necessary attributes - for attr in attrs_to_copy: - if attr not in kpm.__total_attributes__: - kpm.__total_attributes__.append(attr) - - # Invalidate KPM cache to ensure fresh start - kpm._invalidate_kpm_cache() - - return kpm - - -def load_distributed_kpm(data_dir, population_id, dyn, T, lo_to_split=None, - use_symmetries=True, n_configs=None, - final_dyn=None, final_T=None, - **kwargs): - """Load QSpaceKPM with distributed configurations across MPI ranks. - - Loads the ensemble on master rank only, then distributes configuration data - across all ranks. Combines load_distributed_tdscha and QSpaceKPM.from_qspace_lanczos. - - Parameters - ---------- - data_dir : str - Directory containing the ensemble data files. - population_id : int - Population ID of the ensemble to load. - dyn : CC.Phonons.Phonons - Dynamical matrix object (used to create the ensemble). - T : float - Temperature in Kelvin. - lo_to_split : str, ndarray, or None - LO-TO splitting mode. - use_symmetries : bool - If True, use q-space symmetries. - n_configs : int or None - Number of configs to load. - final_dyn : CC.Phonons.Phonons, optional - Final dynamical matrix from the SSCHA calculation. If provided, - the ensemble weights are updated using update_weights(final_dyn, final_T). - final_T : float, optional - Temperature for weight updates. Defaults to T if not specified. - **kwargs - Additional arguments passed to QSpaceLanczos. - - Returns - ------- - QSpaceKPM - QSpaceKPM with distributed ensemble. Already initialized. - - Usage - ----- - mpirun -np 8 python your_script.py - - Example with weight update (recommended for production): - final_dyn = CC.Phonons.Phonons("final_dyn_", nqirr=3) - kpm = load_distributed_kpm( - "data/", 1, initial_dyn, 300, - final_dyn=final_dyn, final_T=300 - ) - kpm.prepare_mode_q(iq, band) - kpm.run_KPM(n_moments=100) - - Example without weight update (for testing/debugging): - kpm = load_distributed_kpm("data/", 1, dyn, 300) - kpm.prepare_mode_q(iq, band) - kpm.run_KPM(n_moments=100) - """ - from tdscha.QSpaceLanczos import load_distributed_tdscha - - qlanc = load_distributed_tdscha( - data_dir, population_id, dyn, T, - lo_to_split=lo_to_split, - use_symmetries=use_symmetries, - n_configs=n_configs, - final_dyn=final_dyn, - final_T=final_T, - **kwargs - ) - - return QSpaceKPM.from_qspace_lanczos(qlanc) diff --git a/Modules/QSpaceLanczos.py b/Modules/QSpaceLanczos.py index 1584b057..e27e3005 100644 --- a/Modules/QSpaceLanczos.py +++ b/Modules/QSpaceLanczos.py @@ -66,6 +66,52 @@ TYPE_DP = np.double +def check_numpy_version(): + """Refuse to run the q-space Lanczos under a NumPy that corrupts it. + + NumPy 1.26.4 on Python 3.14 lets a masked product alias its input and + silently mutates the Krylov vectors (see + ``numpy1_python314_qspace_issue.md``). The corruption is NOT detectable + from the Hermiticity invariant: on a full 12288-configuration CsSnI3 + ensemble the failing environment returns ``b - c == 0`` exactly and + still gives ``b[0] = 4.53e-4`` instead of the correct ``1.13e-7`` -- + two orders of magnitude above the largest possible two-phonon + eigenvalue. The run completes, the coefficients are finite, and the + spectrum is wrong. + + Raised at the start of every Lanczos recursion rather than at import, so + that analysis code which only reads stored coefficients keeps working + under any NumPy. + + Raises + ------ + RuntimeError + If the NumPy major version is below 2. + """ + major = int(np.__version__.split(".")[0]) + if major < 2: + raise RuntimeError( + "The q-space Lanczos cannot be run with NumPy %s.\n\n" + "NumPy 1.x on this interpreter aliases the masked metric " + "products and silently corrupts the Krylov vectors: the " + "recursion completes with finite coefficients and with b == c " + "to machine precision, but the coefficients -- and therefore " + "the spectrum -- are wrong (see " + "numpy1_python314_qspace_issue.md).\n\n" + "Install NumPy >= 2 in this environment, or prepend one to " + "PYTHONPATH (and pass '-x PYTHONPATH' to mpirun so that every " + "rank inherits it)." % np.__version__) + + +# Number of Krylov vectors retained by run_FT(optimized=True). The +# non-reorthogonalized three-term recurrence only ever reads basis_Q[-1], +# basis_Q[-2] (and the matching P/s_norm entries), so three is already one +# more than it needs; the extra slot keeps the restart path -- which resumes +# from [-1]/[-2] after load_status -- comfortably inside the window. Same +# convention as DynamicalLanczos.run_FT(optimized=True). +_KEEP_BASIS_OPTIMIZED = 3 + + def find_q_index(q_target, q_points, bg, tol=1e-6): """Find the index of q_target in q_points up to a reciprocal lattice vector. @@ -101,6 +147,51 @@ class QSpaceLanczos(DL.Lanczos): Only Wigner formalism is supported. Requires Julia extension. """ + # Attributes that ``load_distributed_tdscha`` must carry from the master + # to the worker ranks on top of the common q-space state. Subclasses that + # add their own structure (see QSpaceAtomFourierLanczos) list it here so + # the distributed loader stays a single code path: the master builds these + # once and broadcasts them, rather than every rank rebuilding them and + # risking a degenerate-subspace gauge mismatch between ranks. + _DISTRIBUTED_EXTRA_ATTRS = () + + @classmethod + def prepare_distributed_construction(cls, dyn, **kwargs): + """Run the collective part of the construction on every MPI rank. + + ``load_distributed_tdscha`` reads the ensemble on the master alone, + so anything the constructor does that involves an MPI collective + would leave the workers -- parked in the metadata broadcast -- in a + different collective. MPI does not diagnose the mismatch: the run + either hangs or silently delivers one collective's payload to the + other's receiver. + + The loader therefore calls this classmethod on **all** ranks before + the master/worker split. Whatever it returns is merged into the + constructor keyword arguments, so the collective work is already + done by the time the master builds the object alone. + + The plain q-space construction has no collective step, so the base + implementation returns an empty mapping. Subclasses whose + constructor calls into CellConstructor's ``ForceTensor`` -- see + :class:`~tdscha.QSpaceAtomFourier.QSpaceAtomFourierLanczos` -- + override it. + + Parameters + ---------- + dyn : CC.Phonons.Phonons + The dynamical matrix the object will be built on: the ensemble's + ``current_dyn``, i.e. ``final_dyn`` when the loader reweights. + **kwargs + The constructor keyword arguments of the loader call. + + Returns + ------- + dict + Extra keyword arguments for the constructor. + """ + return {} + def __init__(self, ensemble, lo_to_split=None, **kwargs): """Initialize the Q-Space Lanczos. @@ -135,11 +226,23 @@ def __init__(self, ensemble, lo_to_split=None, **kwargs): 'iq_pert', 'q_pair_map', 'unique_pairs', '_psi_size', '_block_offsets_a', '_block_offsets_b', '_block_sizes', '_qspace_sym_data', '_qspace_sym_q_map', 'n_syms_qspace', + # Vertex renormalization for q-mesh interpolation (1.0 = no interp) + 'qspace_scale3', 'qspace_scale4', 'qspace_prefiltered', # Distributed mode attributes '_distributed', '_N_global', '_N_eff_global', '_N_local', ] self.__total_attributes__.extend(qspace_attrs) + # D3/D4 vertex rescaling factors passed to the Julia kernel. + # They stay 1.0 for a standard (commensurate) calculation; the + # interpolated subclass sets sqrt(N_c/N_f) and N_c/N_f respectively + # (see Interpolation_plan.md, section 6). + self.qspace_scale3 = 1.0 + self.qspace_scale4 = 1.0 + # True when X_q already carries the f_Y filter and the f_psi factors + # are folded into alpha1 (set by the interpolated subclass). + self.qspace_prefiltered = False + # If ensemble is None, perform a bare initialization like the parent if ensemble is None: return @@ -765,7 +868,10 @@ def _call_julia_qspace(self, R1, alpha1_flat): jl = JuliaExt.get_main() - n_total = self.n_syms_qspace * self.N + n_active_syms = self._spectroscopy_symmetry_count( + self.n_syms_qspace) + reduction_args = self._spectroscopy_reduction_arguments() + n_total = n_active_syms * self.N n_processors = Parallel.GetNProc() count = n_total // n_processors @@ -794,7 +900,9 @@ def get_combined(start_end): q_pair_map_jl, # 1-indexed unique_pairs_arr, int(start_end[0]), int(start_end[1]), - valid_modes # Pass mask to Julia + valid_modes, # Pass mask to Julia + float(self.qspace_scale3), float(self.qspace_scale4), + bool(self.qspace_prefiltered), *reduction_args ) combined = Parallel.GoParallel(get_combined, indices, "+") @@ -856,7 +964,10 @@ def _call_julia_qspace_distributed(self, R1, alpha1_flat): return f_pert_global, d2v_blocks # Total number of (config, sym) pairs for this proc - n_total_local = self.n_syms_qspace * N_local + n_active_syms = self._spectroscopy_symmetry_count( + self.n_syms_qspace) + reduction_args = self._spectroscopy_reduction_arguments() + n_total_local = n_active_syms * N_local # Build indices for this proc (1-indexed for Julia) indices = [[1, n_total_local]] # Single element list for local range @@ -879,7 +990,9 @@ def get_combined_local(start_end): q_pair_map_jl, # 1-indexed unique_pairs_arr, int(start_end[0]), int(start_end[1]), - valid_modes # Pass mask to Julia + valid_modes, # Pass mask to Julia + float(self.qspace_scale3), float(self.qspace_scale4), + bool(self.qspace_prefiltered), *reduction_args ) # Call Julia (serial call, local configs only) @@ -942,7 +1055,7 @@ def apply_full_L(self, target=None, force_t_0=False, force_FT=True, def run_FT(self, n_iter, save_dir=None, save_each=5, verbose=True, n_rep_orth=0, n_ortho=10, flush_output=True, debug=False, prefix="LANCZOS", run_simm=None, optimized=False, - reorthogonalize=True): + reorthogonalize=False): """Run the Hermitian Lanczos algorithm for q-space. This is the same structure as the parent run_FT but with: @@ -951,6 +1064,10 @@ def run_FT(self, n_iter, save_dir=None, save_each=5, verbose=True, 3. Complex128 psi 4. Real coefficients (guaranteed by Hermitian L) """ + # Before anything else: a NumPy that corrupts the recursion must stop + # the run here, not after symmetrization has already been done. + check_numpy_version() + self.verbose = verbose if not self.initialized: @@ -966,7 +1083,14 @@ def run_FT(self, n_iter, save_dir=None, save_each=5, verbose=True, raise ValueError(ERROR_MSG) mask_dot = self.mask_dot_wigner(debug) - psi_norm = np.real(np.conj(self.psi).dot(self.psi * mask_dot)) + + def metric_dot(left, right): + """Hermitian product without allowing ufunc buffer reuse.""" + weighted_right = np.empty_like(right) + np.multiply(right, mask_dot, out=weighted_right) + return np.vdot(left, weighted_right) + + psi_norm = np.real(metric_dot(self.psi, self.psi)) if np.isnan(psi_norm) or psi_norm == 0: raise ValueError(ERROR_MSG) @@ -985,6 +1109,35 @@ def run_FT(self, n_iter, save_dir=None, save_each=5, verbose=True, # Get current step i_step = len(self.a_coeffs) + # `optimized` keeps only the tail of the Krylov basis. That is exact + # for the bare three-term recurrence, but silently wrong for anything + # that re-reads older vectors, so refuse those combinations rather + # than quietly changing the result. + if optimized: + if reorthogonalize: + raise ValueError( + "optimized=True keeps only the last %d Krylov vectors, " + "while reorthogonalize=True re-orthogonalizes against the " + "whole basis. Use one or the other." + % _KEEP_BASIS_OPTIMIZED) + if n_rep_orth > 0 and (not n_ortho + or n_ortho > _KEEP_BASIS_OPTIMIZED): + raise ValueError( + "optimized=True keeps only the last %d Krylov vectors, " + "but n_rep_orth=%d requests re-orthogonalization against " + "%s of them." + % (_KEEP_BASIS_OPTIMIZED, n_rep_orth, + "all" if not n_ortho else str(n_ortho))) + + # A basis that was truncated by a previous optimized run cannot be + # reorthogonalized against: len(basis) < i_step + 1 is the signature. + if reorthogonalize and i_step > 0 and len(self.basis_Q) < i_step + 1: + raise ValueError( + "Cannot continue with reorthogonalize=True: the stored Krylov " + "basis holds %d vectors but %d steps were run, so it was " + "truncated by an earlier optimized=True run and the older " + "vectors are gone." % (len(self.basis_Q), i_step)) + if verbose: header = """ <=====================================> @@ -1003,7 +1156,7 @@ def run_FT(self, n_iter, save_dir=None, save_each=5, verbose=True, self.basis_Q = [] self.basis_P = [] self.s_norm = [] - norm = np.sqrt(np.real(np.conj(self.psi).dot(self.psi * mask_dot))) + norm = np.sqrt(np.real(metric_dot(self.psi, self.psi))) first_vector = self.psi / norm self.basis_Q.append(first_vector) self.basis_P.append(first_vector) @@ -1043,7 +1196,7 @@ def run_FT(self, n_iter, save_dir=None, save_each=5, verbose=True, p_norm = self.s_norm[-1] / c_old # a coefficient (real for Hermitian L) - a_coeff = np.real(np.conj(psi_p).dot(L_q * mask_dot)) * p_norm + a_coeff = np.real(metric_dot(psi_p, L_q)) * p_norm if np.isnan(a_coeff): raise ValueError("Invalid value in Lanczos. Check frequencies/initialization.") @@ -1061,13 +1214,14 @@ def run_FT(self, n_iter, save_dir=None, save_each=5, verbose=True, sk -= self.b_coeffs[-1] * self.basis_P[-2] * (old_p_norm / p_norm) # s_norm - s_norm = np.sqrt(np.real(np.conj(sk).dot(sk * mask_dot))) + s_norm = np.sqrt(np.real(metric_dot(sk, sk))) sk_tilde = sk / s_norm s_norm *= p_norm # b and c coefficients (real, should be equal for Hermitian L) - b_coeff = np.sqrt(np.real(np.conj(rk).dot(rk * mask_dot))) - c_coeff = np.real(np.conj(sk_tilde).dot((rk / b_coeff) * mask_dot)) * s_norm + b_coeff = np.sqrt(np.real(metric_dot(rk, rk))) + c_coeff = np.real(metric_dot( + sk_tilde, rk / b_coeff)) * s_norm self.a_coeffs.append(a_coeff) @@ -1087,15 +1241,13 @@ def run_FT(self, n_iter, save_dir=None, save_each=5, verbose=True, # Gram-Schmidt reorthogonalization if reorthogonalize: - # Correct Hermitian MGS: orthogonalize against all Q vectors - # (which are unit-normalized in the mask inner product) new_q = psi_q.copy() for j in range(len(self.basis_Q)): - coeff = np.real(np.conj(self.basis_Q[j]).dot(new_q * mask_dot)) + coeff = np.real(metric_dot(self.basis_Q[j], new_q)) new_q -= coeff * self.basis_Q[j] - normq = np.sqrt(np.real(np.conj(new_q).dot(new_q * mask_dot))) + normq = np.sqrt(np.real(metric_dot(new_q, new_q))) if normq < __EPSILON__: next_converged = True new_q /= normq @@ -1112,22 +1264,22 @@ def run_FT(self, n_iter, save_dir=None, save_each=5, verbose=True, start = max(0, len(self.basis_P) - (n_ortho or len(self.basis_P))) for j in range(start, len(self.basis_P)): - coeff1 = np.real(np.conj(self.basis_P[j]).dot(new_q * mask_dot)) - coeff2 = np.real(np.conj(self.basis_Q[j]).dot(new_p * mask_dot)) + coeff1 = np.real(metric_dot(self.basis_P[j], new_q)) + coeff2 = np.real(metric_dot(self.basis_Q[j], new_p)) new_q -= coeff1 * self.basis_P[j] new_p -= coeff2 * self.basis_Q[j] - normq = np.sqrt(np.real(np.conj(new_q).dot(new_q * mask_dot))) + normq = np.sqrt(np.real(metric_dot(new_q, new_q))) if normq < __EPSILON__: next_converged = True new_q /= normq - normp = np.real(np.conj(new_p).dot(new_p * mask_dot)) + normp = np.real(metric_dot(new_p, new_p)) if np.abs(normp) < __EPSILON__: next_converged = True new_p /= normp - s_norm = c_coeff / np.real(np.conj(new_p).dot(new_q * mask_dot)) + s_norm = c_coeff / np.real(metric_dot(new_p, new_q)) if not converged: self.basis_Q.append(new_q) @@ -1139,6 +1291,22 @@ def run_FT(self, n_iter, save_dir=None, save_each=5, verbose=True, self.c_coeffs.append(c_coeff) self.s_norm.append(s_norm) + # Drop the Krylov vectors that will never be read again. + # Without reorthogonalization the recurrence only touches + # [-1] and [-2], so retaining _KEEP_BASIS_OPTIMIZED vectors + # leaves the coefficients bit-identical while the memory + # stops growing with the step count -- the difference between + # 25 MB and 10 GB per rank on a 12^3 fine mesh. The + # compatibility of `optimized` with the reorthogonalization + # options was checked once before the loop. + if optimized: + while len(self.basis_Q) > _KEEP_BASIS_OPTIMIZED: + self.basis_Q.pop(0) + while len(self.basis_P) > _KEEP_BASIS_OPTIMIZED: + self.basis_P.pop(0) + while len(self.s_norm) > _KEEP_BASIS_OPTIMIZED: + self.s_norm.pop(0) + if verbose: print("Time for L application: %d s" % (t2 - t1)) print("a_%d = %.8e" % (i, self.a_coeffs[-1])) @@ -1172,233 +1340,17 @@ def prepare_mode_q(self, iq, band_index): if band_index < 0 or band_index >= self.n_bands: raise ValueError("Invalid band index for perturbation: {}".format(band_index)) + self._clear_spectroscopy_symmetry() self.build_q_pair_map(iq) self.reset_q() self.psi[band_index] = 1.0 + 0j self.perturbation_modulus = 1.0 - def prepare_ir(self, effective_charges = None, pol_vec = np.array([1.0, 0.0, 0.0])): - """ - PREPARE LANCZOS FOR INFRARED SPECTRUM COMPUTATION - ================================================= - - In this subroutine we prepare the lanczos algorithm for the computation of the - infrared spectrum signal. - - Parameters - ---------- - effective_charges : ndarray(size = (n_atoms, 3, 3), dtype = np.double) - The effective charges. Indices are: Number of atoms in the unit cell, - electric field component, atomic coordinate. If None, the effective charges - contained in the dynamical matrix will be considered. - pol_vec : ndarray(size = 3) - The polarization vector of the light. - """ - - ec = self.dyn.effective_charges - if not effective_charges is None: - ec = effective_charges - - assert not ec is None, "Error, no effective charge found. Cannot initialize IR responce" - - z_eff = np.einsum("abc, b", ec, pol_vec) - - # Get the gamma effective charge - # FIX: remove double mass scaling and add supercell factor - n_cell = np.prod(self.dyn.GetSupercell()) - new_zeff = z_eff.ravel() * np.sqrt(n_cell) - - # This is a Gamma perturbation - self.prepare_perturbation_q(0, new_zeff) - - def prepare_raman(self, pol_vec_in=np.array([1.0, 0.0, 0.0]), pol_vec_out=np.array([1.0, 0.0, 0.0]), - mixed=False, pol_in_2=None, pol_out_2=None, unpolarized=None): - """ - PREPARE LANCZOS FOR RAMAN SPECTRUM COMPUTATION - ============================================== - - In this subroutine we prepare the lanczos algorithm for the computation of the - Raman spectrum signal. - - Parameters - ---------- - pol_vec_in : ndarray(size = 3) - The polarization vector of the incoming light - pol_vec_out : ndarray(size = 3) - The polarization vector for the outcoming light - mixed : bool - If True, add another component of the Raman tensor - pol_in_2 : ndarray(size = 3) or None - Second incoming polarization if mixed=True - pol_out_2 : ndarray(size = 3) or None - Second outcoming polarization if mixed=True - unpolarized : int or None - The perturbation for unpolarized raman (if different from None, overrides the behaviour - of pol_vec_in and pol_vec_out). Indices goes from 0 to 6 (included). - 0 is alpha^2 - 1 + 2 + 3 + 4 + 5 + 6 are beta^2 - alpha_0 = (xx + yy + zz)^2/9 - beta_1 = (xx -yy)^2 / 2 - beta_2 = (xx -zz)^2 / 2 - beta_3 = (yy -zz)^2 / 2 - beta_4 = 3xy^2 - beta_5 = 3xz^2 - beta_6 = 3yz^2 - - The total unpolarized raman intensity is 45 alpha^2 + 7 beta^2 - """ - # Check if the raman tensor is present - assert not self.dyn.raman_tensor is None, "Error, no Raman tensor found. Cannot initialize Raman response" - + def _prepare_gamma_cartesian_perturbation(self, vector): + """Prepare a unit-cell Cartesian Gamma perturbation in q space.""" n_cell = np.prod(self.dyn.GetSupercell()) - - if unpolarized is None: - # Get the raman vector (apply the ASR and contract the raman tensor with the polarization vectors) - raman_v = self.dyn.GetRamanVector(pol_vec_in, pol_vec_out) - - if mixed: - print('Prepare Raman') - print('Adding other component of the Raman tensor') - raman_v += self.dyn.GetRamanVector(pol_in_2, pol_out_2) - - # Scale for Γ-point constant perturbation - new_raman_v = raman_v.ravel() * np.sqrt(n_cell) - - # Convert in the polarization basis - self.prepare_perturbation_q(0, new_raman_v) - else: - px = np.array([1, 0, 0]) - py = np.array([0, 1, 0]) - pz = np.array([0, 0, 1]) - - if unpolarized == 0: - # Alpha = (xx + yy + zz)^2/9 - raman_v = self.dyn.GetRamanVector(px, px) - new_raman_v = raman_v.ravel() * np.sqrt(n_cell) / 3 - self.prepare_perturbation_q(0, new_raman_v) - - raman_v = self.dyn.GetRamanVector(py, py) - new_raman_v = raman_v.ravel() * np.sqrt(n_cell) / 3 - self.prepare_perturbation_q(0, new_raman_v, add=True) - - raman_v = self.dyn.GetRamanVector(pz, pz) - new_raman_v = raman_v.ravel() * np.sqrt(n_cell) / 3 - self.prepare_perturbation_q(0, new_raman_v, add=True) - elif unpolarized == 1: - # (xx - yy)^2 / 2 - raman_v = self.dyn.GetRamanVector(px, px) - new_raman_v = raman_v.ravel() * np.sqrt(n_cell) / np.sqrt(2) - self.prepare_perturbation_q(0, new_raman_v) - - raman_v = self.dyn.GetRamanVector(py, py) - new_raman_v = -raman_v.ravel() * np.sqrt(n_cell) / np.sqrt(2) - self.prepare_perturbation_q(0, new_raman_v, add=True) - elif unpolarized == 2: - # (xx - zz)^2 / 2 - raman_v = self.dyn.GetRamanVector(px, px) - new_raman_v = raman_v.ravel() * np.sqrt(n_cell) / np.sqrt(2) - self.prepare_perturbation_q(0, new_raman_v) - - raman_v = self.dyn.GetRamanVector(pz, pz) - new_raman_v = -raman_v.ravel() * np.sqrt(n_cell) / np.sqrt(2) - self.prepare_perturbation_q(0, new_raman_v, add=True) - elif unpolarized == 3: - # (yy - zz)^2 / 2 - raman_v = self.dyn.GetRamanVector(py, py) - new_raman_v = raman_v.ravel() * np.sqrt(n_cell) / np.sqrt(2) - self.prepare_perturbation_q(0, new_raman_v) - - raman_v = self.dyn.GetRamanVector(pz, pz) - new_raman_v = -raman_v.ravel() * np.sqrt(n_cell) / np.sqrt(2) - self.prepare_perturbation_q(0, new_raman_v, add=True) - elif unpolarized == 4: - # 3 xy^2 - raman_v = self.dyn.GetRamanVector(px, py) - new_raman_v = raman_v.ravel() * np.sqrt(n_cell) * np.sqrt(3) - self.prepare_perturbation_q(0, new_raman_v) - elif unpolarized == 5: - # 3 yz^2 - raman_v = self.dyn.GetRamanVector(py, pz) - new_raman_v = raman_v.ravel() * np.sqrt(n_cell) * np.sqrt(3) - self.prepare_perturbation_q(0, new_raman_v) - elif unpolarized == 6: - # 3 xz^2 - raman_v = self.dyn.GetRamanVector(px, pz) - new_raman_v = raman_v.ravel() * np.sqrt(n_cell) * np.sqrt(3) - self.prepare_perturbation_q(0, new_raman_v) - else: - raise ValueError(f"Error, unpolarized must be between [0, ..., 6] got invalid {unpolarized}.") - - def prepare_unpolarized_raman(self, index=0, debug=False): - """ - PREPARE UNPOLARIZED RAMAN SIGNAL - ================================ - - The raman tensor is read from the dynamical matrix provided by the original ensemble. - - The perturbations are prepared according to the formula (see https://doi.org/10.1021/jp5125266) - - ..math: - - I_unpol = 45/9 (xx + yy + zz)^2 - + 7/2 [(xx-yy)^2 + (xx-zz)^2 + (yy-zz)^2] - + 7 * 3 [(xy)^2 + (yz)^2 + (xz)^2] - - Note: This method prepares the raw components WITHOUT prefactors. - Use get_prefactors_unpolarized_raman() to get the correct prefactors. - """ - # Check if the raman tensor is present - assert not self.dyn.raman_tensor is None, "Error, no Raman tensor found. Cannot initialize the Raman response" - - labels = [i for i in range(7)] - if index not in labels: - raise ValueError(f'{index} should be in {labels}') - - epols = {'x': np.array([1, 0, 0]), - 'y': np.array([0, 1, 0]), - 'z': np.array([0, 0, 1])} - - n_cell = np.prod(self.dyn.GetSupercell()) - - # (xx + yy + zz)^2 - if index == 0: - raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) - raman_v += self.dyn.GetRamanVector(epols['y'], epols['y']) - raman_v += self.dyn.GetRamanVector(epols['z'], epols['z']) - # (xx - yy)^2 - elif index == 1: - raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) - raman_v -= self.dyn.GetRamanVector(epols['y'], epols['y']) - # (xx - zz)^2 - elif index == 2: - raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) - raman_v -= self.dyn.GetRamanVector(epols['z'], epols['z']) - # (yy - zz)^2 - elif index == 3: - raman_v = self.dyn.GetRamanVector(epols['y'], epols['y']) - raman_v -= self.dyn.GetRamanVector(epols['z'], epols['z']) - # (xy)^2 - elif index == 4: - raman_v = self.dyn.GetRamanVector(epols['x'], epols['y']) - # (xz)^2 - elif index == 5: - raman_v = self.dyn.GetRamanVector(epols['x'], epols['z']) - # (yz)^2 - elif index == 6: - raman_v = self.dyn.GetRamanVector(epols['y'], epols['z']) - - if debug: - np.save(f'raman_v_{index}', raman_v) - - # Scale for Γ-point constant perturbation - new_raman_v = raman_v.ravel() * np.sqrt(n_cell) - - # Convert in the polarization basis - self.prepare_perturbation_q(0, new_raman_v) - - if debug: - print(f'[NEW] Perturbation modulus with eq Raman tensors = {self.perturbation_modulus}') - print() + gamma_vector = np.asarray(vector).ravel() * np.sqrt(n_cell) + self.prepare_perturbation_q(0, gamma_vector) def prepare_perturbation_q(self, iq, vector, add=False): """Prepare perturbation at q from a real-space vector (3*n_at_uc,). @@ -1415,6 +1367,7 @@ def prepare_perturbation_q(self, iq, vector, add=False): If true, the perturbation is added on top of the one already setup. Calling add does not cause a reset of the Lanczos. """ + self._clear_spectroscopy_symmetry() if not add: self.build_q_pair_map(iq) self.reset_q() @@ -1423,7 +1376,9 @@ def prepare_perturbation_q(self, iq, vector, add=False): v_scaled = vector / np.sqrt(m) R1 = np.conj(self.pols_q[:, :, iq]).T @ v_scaled # (n_bands,) complex self.psi[:self.n_bands] += R1 - self.perturbation_modulus = np.real(np.conj(R1) @ R1) + perturbation = self.psi[:self.n_bands] + self.perturbation_modulus = np.real( + np.conj(perturbation) @ perturbation) def reset_q(self): """Reset the Lanczos state for q-space.""" @@ -1455,11 +1410,20 @@ def prepare_symmetrization(self, no_sym=False, verbose=True, symmetries=None): to Cartesian for the representation matrices. """ self.initialized = True + self._clear_spectroscopy_symmetry() if no_sym: # Identity only self.n_syms_qspace = 1 + self.n_syms = 1 + self._spectroscopy_symmetry_rotations = (np.eye(3),) n_total = self.n_q * self.n_bands + indices = np.arange(n_total, dtype=np.int32) + self._qspace_sym_data = (( + indices.copy(), indices.copy(), + np.ones(n_total, dtype=np.complex128)),) + self._qspace_sym_q_map = np.arange( + self.n_q, dtype=np.int32)[None, :] # Build identity sparse matrix jl = JuliaExt.get_main() jl.eval(""" @@ -1490,7 +1454,15 @@ def prepare_symmetrization(self, no_sym=False, verbose=True, symmetries=None): # Extract unique point-group rotations (keep first occurrence) unique_pg = {} + supercell_matrix = np.diag( + np.asarray(self.dyn.GetSupercell(), dtype=float)) + inverse_supercell = np.linalg.inv(supercell_matrix) for i in range(len(rot_frac_all)): + mesh_rotation = ( + inverse_supercell @ rot_frac_all[i] @ supercell_matrix) + if not np.allclose(mesh_rotation, np.rint(mesh_rotation), + atol=1e-8, rtol=0): + continue key = rot_frac_all[i].tobytes() if key not in unique_pg: unique_pg[key] = i @@ -1510,19 +1482,8 @@ def _get_atom_perm(structure, R_cart, t_cart, M, Minv, tol=0.1): Returns irt such that R @ tau[kappa] + t ≡ tau[irt[kappa]] mod lattice. """ - nat = structure.N_atoms - irt = np.zeros(nat, dtype=int) - for kappa in range(nat): - tau = structure.coords[kappa] - mapped = R_cart @ tau + t_cart - for kp in range(nat): - diff = mapped - structure.coords[kp] - diff_frac = Minv @ diff - diff_frac -= np.round(diff_frac) - if np.linalg.norm(M @ diff_frac) < tol: - irt[kappa] = kp - break - return irt + return DL.Spectroscopy.find_atom_permutation( + structure, R_cart, t_cart, tolerance=tol) def _build_qspace_symmetries(self, rot_frac_all, trans_frac_all, pg_indices, M, Minv, verbose=True): @@ -1545,11 +1506,16 @@ def _build_qspace_symmetries(self, rot_frac_all, trans_frac_all, n_syms = len(pg_indices) self.n_syms_qspace = n_syms + self.n_syms = n_syms + self._spectroscopy_symmetry_rotations = tuple( + M @ rot_frac_all[index].astype(float) @ Minv + for index in pg_indices) # Build all sparse matrices in Python, then pass to Julia all_rows = [] all_cols = [] all_vals = [] + all_q_maps = [] for i_sym_idx in pg_indices: R_frac = rot_frac_all[i_sym_idx].astype(float) @@ -1564,6 +1530,7 @@ def _build_qspace_symmetries(self, rot_frac_all, trans_frac_all, self.uci_structure, R_cart, t_cart, M, Minv) rows, cols, vals = [], [], [] + q_map = np.empty(self.n_q, dtype=np.int32) for iq in range(self.n_q): q = self.q_points[iq] @@ -1571,6 +1538,7 @@ def _build_qspace_symmetries(self, rot_frac_all, trans_frac_all, # Find iq' matching Rq iq_prime = find_q_index(Rq, self.q_points, bg) + q_map[iq] = iq_prime q_prime = self.q_points[iq_prime] # Build P_uc with Bloch phase factor @@ -1599,6 +1567,15 @@ def _build_qspace_symmetries(self, rot_frac_all, trans_frac_all, all_rows.append(np.array(rows, dtype=np.int32)) all_cols.append(np.array(cols, dtype=np.int32)) all_vals.append(np.array(vals, dtype=np.complex128)) + all_q_maps.append(q_map) + + # Keep the exact representation used by Julia available to Python. + # This is required for finite-q little-group detection and is also the + # portable representation sent by the distributed loader. + self._qspace_sym_data = tuple( + (rows.copy(), cols.copy(), vals.copy()) + for rows, cols, vals in zip(all_rows, all_cols, all_vals)) + self._qspace_sym_q_map = np.asarray(all_q_maps, dtype=np.int32) # Pass to Julia for caching (convert to 1-indexed) for i in range(n_syms): @@ -1630,6 +1607,123 @@ def _build_qspace_symmetries(self, rot_frac_all, trans_frac_all, print("Q-space symmetry matrices ({} x {}), {} symmetries cached in Julia".format( n_total, n_total, n_syms)) + def _apply_qspace_symmetry(self, symmetry_index, vector): + """Apply the exact cached Bloch-mode representation in Python.""" + if self._qspace_sym_data is None: + raise RuntimeError("Call init(use_symmetries=True) first") + vector = np.asarray(vector, dtype=np.complex128) + expected = self.n_q * self.n_bands + if vector.shape != (expected,): + raise ValueError( + "q-space vector must have shape ({},)".format(expected)) + rows, cols, values = self._qspace_sym_data[int(symmetry_index)] + result = np.zeros(expected, dtype=np.complex128) + np.add.at(result, rows, values * vector[cols]) + return result + + @staticmethod + def _line_phase(reference, candidate, tolerance): + """Unit phase when ``candidate`` spans the line of ``reference``.""" + denominator = np.vdot(reference, reference) + if abs(denominator) <= np.finfo(float).tiny: + return None + phase = np.vdot(reference, candidate) / denominator + scale = max(np.linalg.norm(reference), np.linalg.norm(candidate), + np.finfo(float).tiny) + if (abs(abs(phase) - 1.0) > tolerance or + np.linalg.norm(candidate - phase * reference) > + tolerance * scale): + return None + return phase / abs(phase) + + def configure_qspace_perturbation_symmetry( + self, vector=None, tolerance=1e-8): + """Configure coset reduction for the current finite-q perturbation. + + Unlike :meth:`configure_spectroscopy_symmetry`, which accepts the + real Cartesian Gamma representation used by optical requests, this + method detects the stabilizer directly in the complex Bloch-mode + representation cached by :class:`QSpaceLanczos`. It is therefore + valid at non-time-reversal-invariant q points as well. + + Parameters + ---------- + vector : array-like, optional + A one-phonon vector of length ``n_bands`` at ``iq_pert``, or a + full vector of length ``n_q * n_bands``. The current R sector is + used by default. + tolerance : float + Relative line-invariance tolerance. + + Returns + ------- + dict + Full order, stabilizer order, coset count, and projector phases. + """ + if self.iq_pert is None or self.psi is None: + raise RuntimeError( + "Prepare a q-space perturbation before symmetry reduction") + if not np.isfinite(tolerance) or tolerance <= 0: + raise ValueError("tolerance must be a positive finite number") + if self._qspace_sym_data is None: + raise RuntimeError("Call init(use_symmetries=True) first") + + if vector is None: + vector = self.get_R1_q() + vector = np.asarray(vector, dtype=np.complex128).ravel() + full_size = self.n_q * self.n_bands + if vector.size == self.n_bands: + full_vector = np.zeros(full_size, dtype=np.complex128) + start = self.iq_pert * self.n_bands + full_vector[start:start + self.n_bands] = vector + elif vector.size == full_size: + full_vector = vector.copy() + else: + raise ValueError( + "vector must have length n_bands or n_q * n_bands") + if np.linalg.norm(full_vector) <= np.finfo(float).tiny: + raise ValueError("the q-space perturbation must not be zero") + + # The point-group multiplication table and the Bloch matrices have + # exactly the same ordering (both were built from pg_indices). + from tdscha.Spectroscopy import SymmetryGroup + group = SymmetryGroup.from_matrices( + self._spectroscopy_symmetry_rotations, + tolerance=max(float(tolerance), 1e-7)) + stabilizer = [] + eigenphases = [] + for index in range(self.n_syms_qspace): + candidate = self._apply_qspace_symmetry(index, full_vector) + phase = self._line_phase(full_vector, candidate, tolerance) + if phase is not None: + stabilizer.append(index) + eigenphases.append(phase) + + cosets = group.right_cosets(stabilizer) + if len(cosets) >= self.n_syms_qspace: + self._clear_spectroscopy_symmetry() + else: + self._spectroscopy_coset_indices = np.asarray( + [coset[0] + 1 for coset in cosets], dtype=np.int32) + self._spectroscopy_stabilizer_indices = np.asarray( + [index + 1 for index in stabilizer], dtype=np.int32) + # P_chi = |H|^-1 sum_h conj(chi_h) D(h). + self._spectroscopy_characters = np.asarray( + np.conj(eigenphases), dtype=np.complex128) + + return { + "full_group_order": int(self.n_syms_qspace), + "stabilizer_order": len(stabilizer), + "coset_representatives": len(cosets), + "eigenphases": tuple(complex(value) for value in eigenphases), + } + + def _spectroscopy_reduction_arguments(self): + """Return complex projector characters for the q-space Julia API.""" + cosets, stabilizer, characters = super()._spectroscopy_reduction_arguments() + return (cosets, stabilizer, + np.asarray(characters, dtype=np.complex128)) + # Override init to use q-space symmetrization def init(self, use_symmetries=True): """Initialize the q-space Lanczos calculation.""" @@ -1641,9 +1735,125 @@ def init(self, use_symmetries=True): # Distributed Configuration Loading # ============================================================================= +# Sentinel carried by the distributed loader's metadata broadcast. It is +# not decoration: the failure this catches is the one that made the previous +# interpolated loader unusable. When the master enters a collective the +# workers are not in -- CellConstructor's ForceTensor broadcasts, say -- MPI +# matches the two by arrival order and the workers' ``bcast`` returns +# *something else*, with no error anywhere. Checking the payload turns that +# into an immediate, explicit failure instead of a silently wrong spectrum. +_DISTRIBUTED_METADATA_TAG = "__tdscha_distributed_metadata__" +_DISTRIBUTED_METADATA_VERSION = 1 + + +def _check_distributed_metadata(metadata): + """Fail loudly unless the broadcast delivered the loader's own metadata. + + Once a collective has been mismatched the communicator is unusable and + no rank can make progress: the master is blocked in a collective nobody + will complete. Waiting for a scheduler to time out is the worst of the + available outcomes, so this reports the diagnosis and aborts the job. + """ + if (isinstance(metadata, dict) + and metadata.get(_DISTRIBUTED_METADATA_TAG) + == _DISTRIBUTED_METADATA_VERSION): + return + message = ( + "The distributed loader received something other than its own " + "metadata from the master.\n\n" + "The master entered an MPI collective that the other ranks did " + "not: MPI matched them by arrival order and delivered the wrong " + "payload here. The usual cause is construction work that " + "broadcasts internally -- CellConstructor's ForceTensor does, " + "while centering the force constants and imposing the acoustic sum " + "rule. Such work belongs in the Lanczos class's " + "prepare_distributed_construction(), which every rank runs " + "together, not in the master-only branch.\n") + sys.stderr.write("\nTD-SCHA distributed loader: " + message) + sys.stderr.flush() + if __MPI4PY__ and mpi4py.MPI.COMM_WORLD.Get_size() > 1: + mpi4py.MPI.COMM_WORLD.Abort(1) + raise RuntimeError(message) + + +def _distributed_slice(rank, n_procs, N_global): + """Contiguous [start, end) block of configurations owned by ``rank``.""" + per_proc = N_global // n_procs + remainder = N_global % n_procs + if rank < remainder: + start = rank * (per_proc + 1) + end = start + per_proc + 1 + else: + start = rank * per_proc + remainder + end = start + per_proc + return start, end + + +def _load_distributed_build_everywhere(cls, data_dir, population_id, dyn, T, + lo_to_split=None, use_symmetries=True, + n_configs=None, final_dyn=None, + final_T=None, **kwargs): + """Distribute the configurations by building on every rank, then slicing. + + A diagnostic oracle for the master-builds-and-scatters path, not a + production loader: every rank reads the whole ensemble, so peak memory is + the replicated one and the very cost this module exists to remove is paid + in full. It survives because it makes no assumption at all about which + rank computed what -- the ranks run the same deterministic code on the + same input and are never compared -- which makes it a clean reference for + checking that the master-only path returns the same coefficients. + + A constructor performing MPI collectives is *not* a reason to prefer this + path; those collectives belong in + ``QSpaceLanczos.prepare_distributed_construction``, which the master-only + loader runs on every rank. + """ + rank = Parallel.get_rank() if hasattr(Parallel, "get_rank") else \ + mpi4py.MPI.COMM_WORLD.Get_rank() + n_procs = Parallel.GetNProc() + + ensemble = sscha.Ensemble.Ensemble(dyn, T) + if n_configs is not None: + ensemble.load_bin(data_dir, population_id, n_configs=n_configs) + else: + ensemble.load_bin(data_dir, population_id) + if final_dyn is not None: + ensemble.update_weights(final_dyn, + final_T if final_T is not None else T) + + qlanc = cls(ensemble, lo_to_split=lo_to_split, **kwargs) + qlanc.init(use_symmetries=use_symmetries) + del ensemble + + N_global = qlanc.N + N_eff_global = float(np.sum(qlanc.rho)) + + start, end = _distributed_slice(rank, n_procs, N_global) + qlanc.X_q = qlanc.X_q[:, start:end, :].copy() + qlanc.Y_q = qlanc.Y_q[:, start:end, :].copy() + qlanc.rho = qlanc.rho[start:end].copy() + + qlanc._distributed = True + qlanc._N_global = N_global + qlanc._N_eff_global = N_eff_global + qlanc._N_local = end - start + qlanc.N = end - start + # float, not int: Julia normalizes by the exact sum(rho) of this slice. + qlanc.N_eff = float(np.sum(qlanc.rho)) + + if getattr(qlanc, "X", None) is not None: + qlanc.X = None + if getattr(qlanc, "Y", None) is not None: + qlanc.Y = None + + return qlanc + + def load_distributed_tdscha(data_dir, population_id, dyn, T, lo_to_split=None, use_symmetries=True, n_configs=None, - final_dyn=None, final_T=None, **kwargs): + final_dyn=None, final_T=None, + lanczos_class=None, build_on_all_ranks=False, + **kwargs): """Load QSpaceLanczos with distributed configurations across MPI ranks. Loads the ensemble on master rank only, then distributes configuration data @@ -1674,8 +1884,24 @@ def load_distributed_tdscha(data_dir, population_id, dyn, T, lo_to_split=None, final_T : float, optional Temperature for weight updates. Defaults to T if not specified. Use this if the final temperature differs from the ensemble temperature. + lanczos_class : type, optional + The QSpaceLanczos subclass to build. Defaults to QSpaceLanczos. Pass + ``QSpaceAtomFourierLanczos`` (or use + ``QSpaceAtomFourier.load_distributed_atom_fourier_tdscha``) to + distribute an *interpolated* calculation; the subclass declares the + extra state to broadcast through ``_DISTRIBUTED_EXTRA_ATTRS`` and the + collective part of its construction through + ``prepare_distributed_construction``. + build_on_all_ranks : bool + Diagnostic path. Every rank loads the whole ensemble, builds the + whole object, and only then drops the configurations it does not + own. It replicates the ensemble during construction, which is + exactly what this loader exists to avoid, and it is kept only as an + oracle the master-only path can be compared against on small + systems. Leave it False for production. **kwargs - Additional arguments passed to QSpaceLanczos. + Additional arguments passed to the Lanczos class (e.g. ``fine_mesh`` + and ``ignore_effective_charges`` for interpolation). Returns ------- @@ -1688,6 +1914,10 @@ def load_distributed_tdscha(data_dir, population_id, dyn, T, lo_to_split=None, mpirun -np 8 python your_script.py Flow: + - All ranks: run ``cls.prepare_distributed_construction`` together, so + that any MPI collective inside the construction (the + harmonic interpolation of the interpolated subclasses) is + matched across ranks before the master goes on alone - Rank 0: loads ensemble, optionally updates weights, creates QSpaceLanczos, broadcasts metadata, sends slices - Ranks 1..n-1: receive metadata, build bare QSpaceLanczos, receive slices @@ -1697,6 +1927,22 @@ def load_distributed_tdscha(data_dir, population_id, dyn, T, lo_to_split=None, rank = comm.Get_rank() n_procs = Parallel.GetNProc() + cls = QSpaceLanczos if lanczos_class is None else lanczos_class + + if build_on_all_ranks: + return _load_distributed_build_everywhere( + cls, data_dir, population_id, dyn, T, lo_to_split=lo_to_split, + use_symmetries=use_symmetries, n_configs=n_configs, + final_dyn=final_dyn, final_T=final_T, **kwargs) + + # Collective, on every rank: the object the master is about to build + # lives on the ensemble's current_dyn, which is final_dyn whenever the + # weights are updated. + kwargs = dict(kwargs) + kwargs.update(cls.prepare_distributed_construction( + dyn if final_dyn is None else final_dyn, + lo_to_split=lo_to_split, **kwargs)) + if Parallel.am_i_the_master(): # ========== MASTER (RANK 0) ========== ensemble = sscha.Ensemble.Ensemble(dyn, T) @@ -1710,7 +1956,7 @@ def load_distributed_tdscha(data_dir, population_id, dyn, T, lo_to_split=None, T_for_update = final_T if final_T is not None else T ensemble.update_weights(final_dyn, T_for_update) - qlanc = QSpaceLanczos(ensemble, lo_to_split=lo_to_split, **kwargs) + qlanc = cls(ensemble, lo_to_split=lo_to_split, **kwargs) qlanc.init(use_symmetries=use_symmetries) # Free ensemble - we only need QSpaceLanczos arrays @@ -1722,6 +1968,7 @@ def load_distributed_tdscha(data_dir, population_id, dyn, T, lo_to_split=None, # Broadcast metadata (structure arrays, NO config data) metadata = { + _DISTRIBUTED_METADATA_TAG: _DISTRIBUTED_METADATA_VERSION, 'T': qlanc.T, 'dyn': qlanc.dyn, 'uci_structure': qlanc.uci_structure, 'super_structure': qlanc.super_structure, @@ -1734,7 +1981,15 @@ def load_distributed_tdscha(data_dir, population_id, dyn, T, lo_to_split=None, 'n_syms_qspace': qlanc.n_syms_qspace, '_qspace_sym_data': qlanc._qspace_sym_data, '_qspace_sym_q_map': qlanc._qspace_sym_q_map, + # The ensemble Bloch fields are NOT necessarily indexed by n_q: + # the interpolated subclass keeps X_q/Y_q on the coarse mesh while + # n_q counts the fine one. Send the real leading dimension so the + # workers allocate receive buffers that match what is sent. + 'xq_nq': qlanc.X_q.shape[0], } + # Whatever extra structure the subclass needs to be functional. + for attr in cls._DISTRIBUTED_EXTRA_ATTRS: + metadata[attr] = getattr(qlanc, attr, None) comm.bcast(metadata, root=0) # Barrier to ensure all ranks have received metadata before we start sending slices @@ -1775,7 +2030,10 @@ def load_distributed_tdscha(data_dir, population_id, dyn, T, lo_to_split=None, qlanc._N_eff_global = N_eff_global qlanc._N_local = N_local qlanc.N = N_local - qlanc.N_eff = int(np.sum(qlanc.rho)) + # float, not int: Julia normalizes by the exact sum(rho) of this + # rank's slice, so truncating here would leave a systematic + # mis-normalization on any reweighted ensemble (rho != 1). + qlanc.N_eff = float(np.sum(qlanc.rho)) # Free unused arrays if hasattr(qlanc, 'X') and qlanc.X is not None: @@ -1788,12 +2046,13 @@ def load_distributed_tdscha(data_dir, population_id, dyn, T, lo_to_split=None, else: # ========== OTHER RANKS ========== metadata = comm.bcast(None, root=0) + _check_distributed_metadata(metadata) # Barrier to ensure all ranks have received metadata before slices are sent comm.barrier() - # Create bare QSpaceLanczos and populate from metadata - qlanc = QSpaceLanczos(ensemble=None, lo_to_split=lo_to_split, **kwargs) + # Create bare Lanczos object and populate from metadata + qlanc = cls(ensemble=None, lo_to_split=lo_to_split, **kwargs) qlanc.T = metadata['T'] qlanc.dyn = metadata['dyn'] qlanc.uci_structure = metadata['uci_structure'] @@ -1810,14 +2069,17 @@ def load_distributed_tdscha(data_dir, population_id, dyn, T, lo_to_split=None, qlanc.n_syms_qspace = metadata['n_syms_qspace'] qlanc._qspace_sym_data = metadata['_qspace_sym_data'] qlanc._qspace_sym_q_map = metadata['_qspace_sym_q_map'] + for attr in cls._DISTRIBUTED_EXTRA_ATTRS: + setattr(qlanc, attr, metadata[attr]) # Receive local config slice N_local_arr = np.array([0], dtype=np.int64) comm.Recv(N_local_arr, source=0, tag=0) N_local = int(N_local_arr[0]) - qlanc.X_q = np.zeros((qlanc.n_q, N_local, qlanc.n_bands), dtype=np.complex128) - qlanc.Y_q = np.zeros((qlanc.n_q, N_local, qlanc.n_bands), dtype=np.complex128) + xq_nq = metadata['xq_nq'] + qlanc.X_q = np.zeros((xq_nq, N_local, qlanc.n_bands), dtype=np.complex128) + qlanc.Y_q = np.zeros((xq_nq, N_local, qlanc.n_bands), dtype=np.complex128) qlanc.rho = np.zeros(N_local, dtype=np.float64) comm.Recv(qlanc.X_q, source=0, tag=1) @@ -1830,7 +2092,10 @@ def load_distributed_tdscha(data_dir, population_id, dyn, T, lo_to_split=None, qlanc._N_eff_global = metadata['N_eff_global'] qlanc._N_local = N_local qlanc.N = N_local - qlanc.N_eff = int(np.sum(qlanc.rho)) + # float, not int: Julia normalizes by the exact sum(rho) of this + # rank's slice, so truncating here would leave a systematic + # mis-normalization on any reweighted ensemble (rho != 1). + qlanc.N_eff = float(np.sum(qlanc.rho)) # Build Julia symmetry cache qlanc.prepare_symmetrization(no_sym=not use_symmetries) diff --git a/Modules/Spectroscopy.py b/Modules/Spectroscopy.py new file mode 100644 index 00000000..c7b03387 --- /dev/null +++ b/Modules/Spectroscopy.py @@ -0,0 +1,1597 @@ +"""High-level, backend-neutral definitions for optical spectroscopy. + +This module is intentionally independent from the Lanczos implementations. +It contains the physical definitions and finite-group algebra that are shared +by real-space, q-space, and interpolation backends. Execution and +checkpointing are delegated to one private workflow layer without duplicating +the Lanczos recursion or continued-fraction analysis. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from enum import Enum +import json +from pathlib import Path +from types import MappingProxyType +from typing import Optional, Tuple + +import numpy as np + + +_DEFAULT_TOLERANCE = 1e-10 +_RAMAN_SCHEMA_VERSION = 1 +_SPECTROSCOPY_SCHEMA_VERSION = 3 + + +def _finite_array(value, shape, name): + """Return a copied float array after common validation.""" + array = np.asarray(value, dtype=float) + if array.shape != shape: + raise ValueError( + "{} must have shape {}, got {}".format(name, shape, array.shape)) + if not np.all(np.isfinite(array)): + raise ValueError("{} must contain only finite values".format(name)) + return np.array(array, copy=True) + + +def _validate_lo_to_split(value): + """Return a JSON-stable LO--TO specification shared by all backends.""" + if value is None: + return None + if isinstance(value, str): + if value != "random": + raise ValueError("lo_to_split must be None, 'random', or a vector") + return value + direction = _finite_array(value, (3,), "lo_to_split") + if np.linalg.norm(direction) <= _DEFAULT_TOLERANCE: + raise ValueError("lo_to_split direction must not be zero") + return [float(component) for component in direction] + + +def _same_lo_to_split(left, right): + try: + return _validate_lo_to_split(left) == _validate_lo_to_split(right) + except ValueError: + return False + + +def _tuple_vector(value, name="vector", normalize=False): + array = np.asarray(value, dtype=float) + if array.ndim != 1 or array.size == 0: + raise ValueError("{} must be a non-empty one-dimensional array".format( + name)) + if not np.all(np.isfinite(array)): + raise ValueError("{} must contain only finite values".format(name)) + norm = np.linalg.norm(array) + if norm <= _DEFAULT_TOLERANCE: + raise ValueError("{} must not be zero".format(name)) + if normalize: + array = array / norm + return tuple(float(item) for item in array) + + +def _tuple_matrix3(value, name, symmetric=False): + array = _finite_array(value, (3, 3), name) + if symmetric and not np.allclose( + array, array.T, atol=_DEFAULT_TOLERANCE, rtol=0): + raise ValueError("{} must be symmetric".format(name)) + return tuple(tuple(float(item) for item in row) for row in array) + + +class PerturbationKind(str, Enum): + """Kinds of stable equilibrium optical perturbations.""" + + RAMAN = "raman" + IR = "ir" + CARTESIAN = "cartesian" + + +@dataclass(frozen=True) +class RamanComponent: + """One canonical component of the Placzek unpolarized invariant.""" + + index: int + label: str + raw_coefficients: Tuple[Tuple[float, ...], ...] + normalized_scale: float + normalized_weight: float + raw_weight: float + + def coefficients(self, convention="normalized"): + """Return a fresh 3x3 coefficient tensor for the requested convention.""" + coefficients = np.asarray(self.raw_coefficients, dtype=float) + if convention == "raw": + return np.array(coefficients, copy=True) + if convention == "normalized": + return coefficients * self.normalized_scale + raise ValueError( + "Raman convention must be 'normalized' or 'raw', got {!r}".format( + convention)) + + def weight(self, convention="normalized"): + """Return the response weight matching ``coefficients(convention)``.""" + if convention == "normalized": + return self.normalized_weight + if convention == "raw": + return self.raw_weight + raise ValueError( + "Raman convention must be 'normalized' or 'raw', got {!r}".format( + convention)) + + +def _symmetric_component(i, j, value=1.0): + """Coefficient tensor whose contraction selects one symmetric component.""" + result = np.zeros((3, 3), dtype=float) + if i == j: + result[i, j] = value + else: + result[i, j] = value / 2 + result[j, i] = value / 2 + return _tuple_matrix3(result, "Raman component", symmetric=True) + + +def _diagonal_component(values): + return _tuple_matrix3(np.diag(values), "Raman component", symmetric=True) + + +RAMAN_COMPONENTS = ( + RamanComponent(0, "trace", _diagonal_component((1, 1, 1)), + 1 / 3, 45, 5), + RamanComponent(1, "xx_minus_yy", _diagonal_component((1, -1, 0)), + 1 / np.sqrt(2), 7, 7 / 2), + RamanComponent(2, "xx_minus_zz", _diagonal_component((1, 0, -1)), + 1 / np.sqrt(2), 7, 7 / 2), + RamanComponent(3, "yy_minus_zz", _diagonal_component((0, 1, -1)), + 1 / np.sqrt(2), 7, 7 / 2), + RamanComponent(4, "xy", _symmetric_component(0, 1), + np.sqrt(3), 7, 21), + RamanComponent(5, "xz", _symmetric_component(0, 2), + np.sqrt(3), 7, 21), + RamanComponent(6, "yz", _symmetric_component(1, 2), + np.sqrt(3), 7, 21), +) + + +def get_raman_component(index): + """Return one canonical Raman component by stable integer index.""" + if not isinstance(index, (int, np.integer)) or not 0 <= int(index) < 7: + raise ValueError("Raman component index must be an integer from 0 to 6") + return RAMAN_COMPONENTS[int(index)] + + +def get_unpolarized_raman_weights(convention="normalized"): + """Return all seven unpolarized Raman response weights.""" + return np.array( + [component.weight(convention) for component in RAMAN_COMPONENTS], + dtype=float) + + +def raman_coefficients_from_polarizations( + incoming, outgoing, symmetric=True): + """Return the tensor selecting a polarized Raman contraction. + + The stable non-resonant API uses ``symmetric=True``. The optional raw + outer product exists only so legacy Lanczos calls retain their exact + incoming/outgoing convention for a non-symmetric input tensor. + """ + incoming = _finite_array(incoming, (3,), "incoming polarization") + outgoing = _finite_array(outgoing, (3,), "outgoing polarization") + if np.linalg.norm(incoming) <= _DEFAULT_TOLERANCE: + raise ValueError("incoming polarization must not be zero") + if np.linalg.norm(outgoing) <= _DEFAULT_TOLERANCE: + raise ValueError("outgoing polarization must not be zero") + coefficients = np.outer(incoming, outgoing) + if symmetric: + coefficients = (coefficients + coefficients.T) / 2 + return coefficients + + +def build_raman_vector(raman_tensor, coefficients): + """Contract a Raman derivative with one symmetric optical tensor. + + ``raman_tensor`` must start with the two optical Cartesian axes and may + have any remaining atomic-coordinate shape. The returned array retains + those remaining axes. + """ + raman_tensor = np.asarray(raman_tensor) + if raman_tensor.ndim < 3 or raman_tensor.shape[:2] != (3, 3): + raise ValueError( + "raman_tensor must have shape (3, 3, ...), got {}".format( + raman_tensor.shape)) + if not np.all(np.isfinite(raman_tensor)): + raise ValueError("raman_tensor must contain only finite values") + coefficients = _finite_array(coefficients, (3, 3), + "Raman coefficient tensor") + return np.einsum("ab,ab...->...", coefficients, raman_tensor) + + +def build_ir_vector(effective_charges, direction): + """Contract equilibrium effective charges with an electric-field direction.""" + effective_charges = np.asarray(effective_charges) + if effective_charges.ndim != 3 or effective_charges.shape[1:] != (3, 3): + raise ValueError( + "effective_charges must have shape (n_atoms, 3, 3), got {}".format( + effective_charges.shape)) + if not np.all(np.isfinite(effective_charges)): + raise ValueError("effective_charges must contain only finite values") + direction = _finite_array(direction, (3,), "IR direction") + return np.einsum("abc,b->ac", effective_charges, direction).ravel() + + +@dataclass(frozen=True) +class RamanTensorPerturbation: + """A stable one-phonon Raman perturbation specification.""" + + coefficients: Tuple[Tuple[float, ...], ...] + + def __init__(self, coefficients): + object.__setattr__( + self, "coefficients", + _tuple_matrix3(coefficients, "Raman coefficient tensor", + symmetric=True)) + if np.linalg.norm(self.as_array()) <= _DEFAULT_TOLERANCE: + raise ValueError("Raman coefficient tensor must not be zero") + + @property + def kind(self): + return PerturbationKind.RAMAN + + def as_array(self): + return np.array(self.coefficients, dtype=float) + + def transformed(self, rotation): + rotation = _finite_array(rotation, (3, 3), "rotation") + coefficients = self.as_array() + return RamanTensorPerturbation( + rotation @ coefficients @ rotation.T) + + +@dataclass(frozen=True) +class IRPolarizationPerturbation: + """A stable one-phonon IR perturbation with a unit direction.""" + + direction: Tuple[float, ...] + + def __init__(self, direction): + direction = _finite_array(direction, (3,), "IR direction") + object.__setattr__( + self, "direction", _tuple_vector( + direction, name="IR direction", normalize=True)) + + @property + def kind(self): + return PerturbationKind.IR + + def as_array(self): + return np.array(self.direction, dtype=float) + + def transformed(self, rotation): + rotation = _finite_array(rotation, (3, 3), "rotation") + return IRPolarizationPerturbation(rotation @ self.as_array()) + + +@dataclass(frozen=True) +class CartesianPerturbation: + """An explicit nonzero unit-cell Cartesian perturbation vector.""" + + vector: Tuple[float, ...] + + def __init__(self, vector): + object.__setattr__(self, "vector", _tuple_vector(vector)) + + @property + def kind(self): + return PerturbationKind.CARTESIAN + + def as_array(self): + return np.array(self.vector, dtype=float) + + +@dataclass(frozen=True) +class SymmetryGroup: + """A validated finite group and its multiplication table.""" + + matrices: Tuple[Tuple[Tuple[float, ...], ...], ...] + multiplication_table: Tuple[Tuple[int, ...], ...] + identity_index: int + + @classmethod + def from_matrices(cls, matrices, tolerance=_DEFAULT_TOLERANCE): + arrays = tuple( + _finite_array(matrix, (3, 3), "symmetry matrix") + for matrix in matrices) + if not arrays: + raise ValueError("at least one symmetry matrix is required") + for matrix in arrays: + if not np.allclose(matrix.T @ matrix, np.eye(3), + atol=tolerance, rtol=0): + raise ValueError("symmetry matrices must be orthogonal") + + def find_index(target): + matches = [ + index for index, candidate in enumerate(arrays) + if np.allclose(target, candidate, atol=tolerance, rtol=0)] + if len(matches) != 1: + raise ValueError( + "symmetry matrices must be unique and closed under " + "multiplication") + return matches[0] + + identity_index = find_index(np.eye(3)) + multiplication = tuple(tuple( + find_index(left @ right) for right in arrays) for left in arrays) + immutable = tuple(_tuple_matrix3(matrix, "symmetry matrix") + for matrix in arrays) + return cls(immutable, multiplication, identity_index) + + def __len__(self): + return len(self.matrices) + + def matrix(self, index): + return np.array(self.matrices[index], dtype=float) + + def left_cosets(self, subgroup): + """Partition the group into left cosets of a validated subgroup.""" + subgroup = tuple(sorted(set(int(index) for index in subgroup))) + if not subgroup or self.identity_index not in subgroup: + raise ValueError("subgroup must contain the group identity") + if any(index < 0 or index >= len(self) for index in subgroup): + raise ValueError("subgroup contains an invalid group index") + subgroup_set = set(subgroup) + for left in subgroup: + for right in subgroup: + if self.multiplication_table[left][right] not in subgroup_set: + raise ValueError("indices do not form a subgroup") + + unseen = set(range(len(self))) + cosets = [] + while unseen: + representative = min(unseen) + coset = tuple(sorted( + self.multiplication_table[representative][member] + for member in subgroup)) + cosets.append(coset) + unseen.difference_update(coset) + return tuple(cosets) + + def right_cosets(self, subgroup): + """Partition the group into right cosets ``H g``. + + These are the cosets used by the spectroscopy ensemble reduction: + the stabilizer projector accounts for ``H`` while one transformed + ensemble representative is evaluated for each ``H g``. + """ + subgroup = tuple(sorted(set(int(index) for index in subgroup))) + # Reuse the subgroup validation in left_cosets. + self.left_cosets(subgroup) + unseen = set(range(len(self))) + cosets = [] + while unseen: + representative = min(unseen) + coset = tuple(sorted( + self.multiplication_table[member][representative] + for member in subgroup)) + cosets.append(coset) + unseen.difference_update(coset) + return tuple(cosets) + + +def find_atom_permutation(structure, rotation, translation, tolerance=1e-5): + """Return the atom permutation for one Cartesian space-group operation.""" + rotation = _finite_array(rotation, (3, 3), "rotation") + translation = _finite_array(translation, (3,), "translation") + lattice = np.asarray(structure.unit_cell, dtype=float).T + inverse_lattice = np.linalg.inv(lattice) + atom_types = np.asarray(structure.get_atomic_types()) + permutation = np.full(structure.N_atoms, -1, dtype=int) + for atom in range(structure.N_atoms): + mapped = rotation @ structure.coords[atom] + translation + for candidate in range(structure.N_atoms): + if atom_types[candidate] != atom_types[atom]: + continue + difference = mapped - structure.coords[candidate] + fractional = inverse_lattice @ difference + fractional -= np.round(fractional) + if np.linalg.norm(lattice @ fractional) <= tolerance: + permutation[atom] = candidate + break + if permutation[atom] < 0: + raise ValueError( + "Could not map atom {} under the supplied symmetry".format( + atom)) + if len(set(permutation.tolist())) != structure.N_atoms: + raise ValueError("Symmetry atom mapping is not a permutation") + return permutation + + +def get_gamma_symmetry_representation(structure, tolerance=1e-8, + symprec=1e-5, supercell=None): + """Build mesh-compatible Gamma point-group representations. + + When ``supercell`` is supplied, operations which do not map that + supercell translation lattice onto itself are excluded. This prevents + an anisotropic finite sampling mesh from spuriously identifying optical + perturbations that are equivalent only in the infinite crystal. + """ + try: + import spglib + except ImportError as error: + raise ImportError( + "spglib is required for spectroscopy symmetry reduction") from error + + symmetry = spglib.get_symmetry( + structure.get_spglib_cell(), symprec=symprec) + if symmetry is None: + raise ValueError("spglib could not determine structure symmetries") + rotations_fractional = symmetry["rotations"] + translations_fractional = symmetry["translations"] + lattice = np.asarray(structure.unit_cell, dtype=float).T + inverse_lattice = np.linalg.inv(lattice) + + if supercell is None: + supercell_matrix = np.eye(3, dtype=float) + else: + supercell = np.asarray(supercell) + if supercell.shape == (3,): + supercell_matrix = np.diag(supercell.astype(float)) + elif supercell.shape == (3, 3): + supercell_matrix = supercell.astype(float) + else: + raise ValueError("supercell must have shape (3,) or (3, 3)") + if abs(np.linalg.det(supercell_matrix)) <= tolerance: + raise ValueError("supercell matrix must be invertible") + inverse_supercell = np.linalg.inv(supercell_matrix) + + unique = {} + for index, rotation in enumerate(rotations_fractional): + mesh_rotation = ( + inverse_supercell @ rotation.astype(float) @ supercell_matrix) + if not np.allclose(mesh_rotation, np.rint(mesh_rotation), + atol=max(tolerance, 1e-8), rtol=0): + continue + unique.setdefault(rotation.tobytes(), index) + indices = tuple(unique.values()) + rotations = tuple( + lattice @ rotations_fractional[index].astype(float) @ inverse_lattice + for index in indices) + translations = tuple( + lattice @ translations_fractional[index] for index in indices) + group = SymmetryGroup.from_matrices(rotations, tolerance=tolerance) + + dimension = 3 * structure.N_atoms + representations = [] + atom_tolerance = max(symprec * 10, tolerance * 10) + for rotation, translation in zip(rotations, translations): + permutation = find_atom_permutation( + structure, rotation, translation, tolerance=atom_tolerance) + representation = np.zeros((dimension, dimension), dtype=float) + for atom, mapped_atom in enumerate(permutation): + representation[3 * mapped_atom:3 * mapped_atom + 3, + 3 * atom:3 * atom + 3] = rotation + representations.append(representation) + + # Validate the atom-space matrices against the same multiplication table. + for left in range(len(group)): + for right in range(len(group)): + product = group.multiplication_table[left][right] + if not np.allclose( + representations[left] @ representations[right], + representations[product], atol=max(tolerance, 1e-8), + rtol=0): + raise ValueError( + "Gamma atom-space symmetry matrices do not form the " + "same representation as the point group") + return group, tuple(representations) + + +def _real_phase(reference, candidate, tolerance): + """Return +1/-1 when two nonzero real vectors differ only by that phase.""" + reference = np.asarray(reference, dtype=float).ravel() + candidate = np.asarray(candidate, dtype=float).ravel() + if reference.shape != candidate.shape: + return None + scale = max(np.linalg.norm(reference), np.linalg.norm(candidate)) + if scale <= np.finfo(float).tiny: + return None + for phase in (1.0, -1.0): + if np.linalg.norm(candidate - phase * reference) <= tolerance * scale: + return phase + return None + + +@dataclass(frozen=True) +class PerturbationOrbit: + """Symmetry reconstruction data for one representative perturbation.""" + + representative: int + members: Tuple[int, ...] + operations: Tuple[int, ...] + phases: Tuple[float, ...] + stabilizer: Tuple[int, ...] + characters: Tuple[float, ...] + left_cosets: Tuple[Tuple[int, ...], ...] + right_cosets: Tuple[Tuple[int, ...], ...] + + +def find_perturbation_orbits(vectors, representations, group, + tolerance=_DEFAULT_TOLERANCE): + """Partition real perturbation vectors into sign-aware symmetry orbits. + + Parameters + ---------- + vectors : sequence of one-dimensional arrays + Requested perturbations in a common vector representation. + representations : sequence of square arrays + Representation matrix corresponding to each element of ``group``. + group : SymmetryGroup + Multiplication information for the same ordered group elements. + + Notes + ----- + The sign is retained in the reconstruction metadata. This is sufficient + for diagonal response functions; future cross-response assembly must use + the stored sign rather than discarding it. + """ + vectors = tuple(np.asarray(vector, dtype=float).ravel() + for vector in vectors) + if not vectors: + return () + dimension = vectors[0].size + if dimension == 0: + raise ValueError("perturbation vectors must not be empty") + for vector in vectors: + if vector.size != dimension or not np.all(np.isfinite(vector)): + raise ValueError( + "all perturbation vectors must be finite and have one size") + if np.linalg.norm(vector) <= np.finfo(float).tiny: + raise ValueError("perturbation vectors must not be zero") + + representations = tuple(np.asarray(item, dtype=float) + for item in representations) + if len(representations) != len(group): + raise ValueError( + "one representation matrix is required per group element") + for representation in representations: + if representation.shape != (dimension, dimension): + raise ValueError( + "representation matrices must have shape ({0}, {0})".format( + dimension)) + for left in range(len(group)): + for right in range(len(group)): + product = group.multiplication_table[left][right] + if not np.allclose( + representations[left] @ representations[right], + representations[product], atol=tolerance, rtol=0): + raise ValueError( + "representation matrices do not follow the supplied " + "group multiplication table") + + remaining = set(range(len(vectors))) + orbits = [] + while remaining: + representative = min(remaining) + reference = vectors[representative] + members = [] + operations = [] + phases = [] + for member in sorted(remaining): + for operation, representation in enumerate(representations): + phase = _real_phase( + vectors[member], representation @ reference, tolerance) + if phase is not None: + members.append(member) + operations.append(operation) + phases.append(phase) + break + + stabilizer = [] + characters = [] + for operation, representation in enumerate(representations): + character = _real_phase( + reference, representation @ reference, tolerance) + if character is not None: + stabilizer.append(operation) + characters.append(character) + + left_cosets = group.left_cosets(stabilizer) + right_cosets = group.right_cosets(stabilizer) + orbit = PerturbationOrbit( + representative=representative, + members=tuple(members), + operations=tuple(operations), + phases=tuple(phases), + stabilizer=tuple(stabilizer), + characters=tuple(characters), + left_cosets=left_cosets, + right_cosets=right_cosets) + orbits.append(orbit) + remaining.difference_update(members) + return tuple(orbits) + + +def vector_representations_for_ir(group): + """Return the ordinary Cartesian-vector representation of a group.""" + return tuple(group.matrix(index) for index in range(len(group))) + + +def vector_representations_for_symmetric_raman(group): + """Return 6x6 representations acting on symmetric Raman tensors.""" + basis = [] + for i, j in ((0, 0), (1, 1), (2, 2), (0, 1), (0, 2), (1, 2)): + tensor = np.zeros((3, 3), dtype=float) + tensor[i, j] = 1 + tensor[j, i] = 1 + basis.append(tensor) + representations = [] + for operation in range(len(group)): + rotation = group.matrix(operation) + columns = [] + for tensor in basis: + transformed = rotation @ tensor @ rotation.T + columns.append(np.array([ + transformed[0, 0], transformed[1, 1], transformed[2, 2], + transformed[0, 1], transformed[0, 2], + transformed[1, 2]], dtype=float)) + representations.append(np.column_stack(columns)) + return tuple(representations) + + +def symmetric_raman_vector(coefficients): + """Encode a symmetric 3x3 coefficient tensor in the shared 6-vector basis.""" + coefficients = _finite_array( + coefficients, (3, 3), "Raman coefficient tensor") + if not np.allclose(coefficients, coefficients.T, + atol=_DEFAULT_TOLERANCE, rtol=0): + raise ValueError("Raman coefficient tensor must be symmetric") + return np.array([ + coefficients[0, 0], coefficients[1, 1], coefficients[2, 2], + coefficients[0, 1], coefficients[0, 2], coefficients[1, 2]], + dtype=float) + + +def _load_phonons(source, nqirr, name): + """Return a CellConstructor dynamical matrix from a path or an object.""" + if source is None: + return None + if isinstance(source, (str, os.PathLike)): + if nqirr is None: + raise ValueError( + "{} was given as a file prefix, so the number of irreducible " + "q-points must be given too".format(name)) + if (not isinstance(nqirr, (int, np.integer)) + or isinstance(nqirr, bool) or int(nqirr) < 1): + raise ValueError( + "the number of irreducible q-points for {} must be a " + "positive integer".format(name)) + import cellconstructor.Phonons + + return cellconstructor.Phonons.Phonons( + os.fspath(source), int(nqirr)) + if nqirr is not None: + raise ValueError( + "{} is already a dynamical matrix object, so its number of " + "irreducible q-points must not be given".format(name)) + for attribute in ("structure", "dynmats", "GetSupercell"): + if not hasattr(source, attribute): + raise TypeError( + "{} must be a file prefix or a CellConstructor Phonons " + "object, got {}".format(name, type(source).__name__)) + return source + + +@dataclass(frozen=True) +class EnsembleSource: + """Where a stochastic ensemble lives on disk, and how to reweight it. + + :class:`Spectroscopy` takes one of these instead of a loaded ensemble so + that the configurations are read **once, by the MPI master**, and + scattered: every rank keeps only its ``N / n_procs`` slice of the + Bloch-transformed displacements and forces. Handing a fully loaded + ``sscha.Ensemble.Ensemble`` to every rank instead replicates the + configurations, which is what makes a large ensemble impossible to run. + + Nothing here is heavy. The dynamical matrices are small and are held on + every rank -- the run plan, the symmetry analysis, the fingerprint, and + the spectral analysis all need them. Only the configurations are + distributed. + + Parameters + ---------- + data_dir : str or os.PathLike + Directory holding the binary ensemble files. + population : int + Population identifier of the ensemble inside ``data_dir``. + dyn : str, os.PathLike, or CC.Phonons.Phonons + The dynamical matrix the ensemble was *generated* with, either as a + CellConstructor file prefix or as an already loaded object. + T : float + Temperature in Kelvin at which the ensemble was generated. + nqirr : int, optional + Number of irreducible q-points; required when ``dyn`` is a prefix. + n_configs : int, optional + Read only the first ``n_configs`` configurations. ``None`` reads + every configuration in the population. + final_dyn : str, os.PathLike, or CC.Phonons.Phonons, optional + The converged solution. When given, the ensemble is reweighted onto + it and it becomes the reference dynamical matrix of the calculation: + the Raman tensor, the effective charges, and the mode basis all come + from it. Production runs should always set it. + final_nqirr : int, optional + Number of irreducible q-points; required when ``final_dyn`` is a + prefix. + final_T : float, optional + Temperature of the reweighted ensemble. Defaults to ``T``. + """ + + data_dir: str + population: int + dyn: object + T: float + nqirr: Optional[int] = None + n_configs: Optional[int] = None + final_dyn: object = None + final_nqirr: Optional[int] = None + final_T: Optional[float] = None + + def __post_init__(self): + data_dir = os.fspath(self.data_dir) + if not data_dir: + raise ValueError("data_dir must be a non-empty path") + if not os.path.isdir(data_dir): + raise ValueError( + "the ensemble directory {!r} does not exist".format(data_dir)) + object.__setattr__(self, "data_dir", data_dir) + + if (not isinstance(self.population, (int, np.integer)) + or isinstance(self.population, bool)): + raise ValueError("population must be an integer") + object.__setattr__(self, "population", int(self.population)) + + temperature = float(self.T) + if not np.isfinite(temperature) or temperature < 0: + raise ValueError("T must be a finite non-negative temperature") + object.__setattr__(self, "T", temperature) + + if self.n_configs is not None: + if (not isinstance(self.n_configs, (int, np.integer)) + or isinstance(self.n_configs, bool) + or int(self.n_configs) < 1): + raise ValueError("n_configs must be a positive integer") + object.__setattr__(self, "n_configs", int(self.n_configs)) + + if self.final_dyn is None and self.final_nqirr is not None: + raise ValueError( + "final_nqirr was given without a final_dyn") + if self.final_dyn is None and self.final_T is not None: + raise ValueError( + "final_T was given without a final_dyn: the ensemble is not " + "reweighted, so its temperature is T") + + object.__setattr__(self, "_generating_dyn", _load_phonons( + self.dyn, self.nqirr, "dyn")) + object.__setattr__(self, "_converged_dyn", _load_phonons( + self.final_dyn, self.final_nqirr, "final_dyn")) + + if self.final_T is not None: + final_temperature = float(self.final_T) + if not np.isfinite(final_temperature) or final_temperature < 0: + raise ValueError( + "final_T must be a finite non-negative temperature") + object.__setattr__(self, "final_T", final_temperature) + + @property + def generating_dyn(self): + """The dynamical matrix the configurations were sampled from.""" + return self._generating_dyn + + @property + def converged_dyn(self): + """The reweighting target, or ``None`` when there is none.""" + return self._converged_dyn + + @property + def reference_dyn(self): + """The ensemble's ``current_dyn``: the converged one when reweighting.""" + if self._converged_dyn is not None: + return self._converged_dyn + return self._generating_dyn + + @property + def reference_temperature(self): + """The ensemble's ``current_T`` after any reweighting.""" + if self._converged_dyn is not None and self.final_T is not None: + return float(self.final_T) + return float(self.T) + + def load_ensemble(self): + """Read the whole ensemble into memory on the calling process. + + This is the replicated path. It is what ``backend="real"`` needs -- + the real-space Lanczos parallelizes over a replicated ensemble -- and + it is the wrong thing to call from a q-space driver, which must go + through the distributed loaders instead. + """ + import sscha.Ensemble + + ensemble = sscha.Ensemble.Ensemble(self.generating_dyn, self.T) + if self.n_configs is None: + ensemble.load_bin(self.data_dir, self.population) + else: + ensemble.load_bin(self.data_dir, self.population, + n_configs=self.n_configs) + if self.converged_dyn is not None: + ensemble.update_weights(self.converged_dyn, + self.reference_temperature) + return ensemble + + def describe(self): + """Return the JSON-stable identity checked when a run is resumed. + + Deliberately *not* the absolute path. What makes two ensembles + different is the population, how many of its configurations are read, + and how they are reweighted -- none of which the fingerprint of the + dynamical matrix can see, since the same matrix generates every + population. The directory enters only through its name, so that + moving a finished calculation to another machine does not invalidate + its checkpoint while ``pop1/`` and ``pop2/`` still do not collide. + The full path is recorded separately, for provenance. + """ + return { + "directory_name": os.path.basename( + os.path.normpath(os.path.abspath(self.data_dir))), + "population": self.population, + "n_configs": self.n_configs, + "temperature": float(self.T), + "reweighted": self.converged_dyn is not None, + "reference_temperature": self.reference_temperature, + } + + def provenance(self): + """Return where this ensemble was read from, for the record only.""" + def path_of(value): + if isinstance(value, (str, os.PathLike)): + return os.fspath(value) + return None + + return { + "data_dir": os.path.abspath(self.data_dir), + "dyn": path_of(self.dyn), + "final_dyn": path_of(self.final_dyn), + } + + +@dataclass(frozen=True) +class SpectroscopyRequest: + """One named user request, possibly containing several perturbations.""" + + name: str + observable: str + perturbations: Tuple[object, ...] + weights: Tuple[float, ...] + convention: Optional[str] = None + source: Optional[Tuple] = None + + +class Spectroscopy: + """Restartable polarized and unpolarized Raman/IR calculation driver. + + The ensemble is normally given as an :class:`EnsembleSource` -- where it + lives on disk -- rather than as a loaded ``sscha.Ensemble.Ensemble``. + Under ``mpirun`` the q-space backends then read the configurations on + the master alone and scatter them, so each rank holds ``N / n_procs`` of + them and a large ensemble does not have to fit in memory ``n_procs`` + times. :meth:`from_ensemble_path` is the shorthand for the common case. + + A loaded ensemble is still accepted and still works; it is the + replicated path, appropriate for small systems and for + ``backend="real"``, whose real-space Lanczos parallelizes over a + replicated ensemble by design. + + ``ignore_v3`` and ``ignore_v4`` control the anharmonic vertices in every + backend. ``lo_to_split`` uses one common convention: ``None`` disables + the nonanalytic Gamma correction, ``"random"`` delegates the direction + to CellConstructor, and a finite nonzero three-vector selects it. The + deprecated placement of these values inside ``backend_options`` remains + accepted so existing scripts continue to run. + """ + + SUPPORTED_BACKENDS = ("real", "qspace", "atom_fourier") + + @classmethod + def from_ensemble_path(cls, data_dir, population, dyn, T, nqirr=None, + n_configs=None, final_dyn=None, final_nqirr=None, + final_T=None, **options): + """Build a driver that reads its ensemble from ``data_dir``. + + Shorthand for ``Spectroscopy(EnsembleSource(...), **options)``; see + :class:`EnsembleSource` for the meaning of the ensemble arguments and + :meth:`__init__` for the rest. + """ + return cls(EnsembleSource( + data_dir=data_dir, population=population, dyn=dyn, T=T, + nqirr=nqirr, n_configs=n_configs, final_dyn=final_dyn, + final_nqirr=final_nqirr, final_T=final_T), **options) + + def __init__(self, ensemble, backend="qspace", workdir="spectroscopy", + use_symmetries=True, symmetry_tolerance=1e-8, + ignore_v3=None, ignore_v4=None, lo_to_split=None, + backend_options=None): + if backend not in self.SUPPORTED_BACKENDS: + raise ValueError( + "backend must be one of {}, got {!r}".format( + self.SUPPORTED_BACKENDS, backend)) + try: + workdir = os.fspath(workdir) + except TypeError as error: + raise ValueError("workdir must be a non-empty path") from error + if not workdir: + raise ValueError("workdir must be a non-empty path") + if (ensemble is not None + and not isinstance(ensemble, EnsembleSource) + and not (hasattr(ensemble, "current_dyn") + and hasattr(ensemble, "current_T"))): + raise TypeError( + "ensemble must be an EnsembleSource, an " + "sscha.Ensemble.Ensemble, or None for load-only analysis; " + "got {}".format(type(ensemble).__name__)) + self.ensemble = ensemble + self.backend = backend + self.workdir = workdir + self.use_symmetries = bool(use_symmetries) + self.symmetry_tolerance = float(symmetry_tolerance) + if self.symmetry_tolerance <= 0: + raise ValueError("symmetry_tolerance must be positive") + self.backend_options = dict(backend_options or {}) + + # These physics switches used to be hidden in backend_options. Keep + # accepting that spelling so existing scripts remain restartable, but + # expose one backend-independent public API from now on. + def resolve_legacy_option(name, explicit, default): + legacy = self.backend_options.pop(name, None) + if explicit is not None and legacy is not None: + if name == "lo_to_split": + same = _same_lo_to_split(explicit, legacy) + else: + same = bool(explicit) == bool(legacy) + if not same: + raise ValueError( + "Conflicting {!r} values were supplied explicitly " + "and through backend_options".format(name)) + value = explicit if explicit is not None else legacy + return default if value is None else value + + self.ignore_v3 = bool(resolve_legacy_option( + "ignore_v3", ignore_v3, False)) + self.ignore_v4 = bool(resolve_legacy_option( + "ignore_v4", ignore_v4, False)) + self.lo_to_split = _validate_lo_to_split(resolve_legacy_option( + "lo_to_split", lo_to_split, None)) + self._requests = {} + self._run_specs = {} + self._request_maps = {} + self._results = {} + self._manifest_data = None + + @property + def requests(self): + return MappingProxyType(self._requests.copy()) + + @property + def ensemble_source(self): + """The :class:`EnsembleSource`, or ``None`` for a loaded ensemble.""" + return self.ensemble if isinstance(self.ensemble, EnsembleSource) \ + else None + + @property + def reference_dyn(self): + """The dynamical matrix every observable and symmetry is defined on. + + This is the ensemble's ``current_dyn``: the converged solution when + the ensemble is reweighted onto one. It carries the Raman tensor, + the Born effective charges, and the electronic dielectric tensor. + It is small and is held on every MPI rank, unlike the configurations. + """ + if self.ensemble is None: + return None + if isinstance(self.ensemble, EnsembleSource): + return self.ensemble.reference_dyn + return self.ensemble.current_dyn + + @property + def reference_temperature(self): + """The ensemble's ``current_T`` after any reweighting.""" + if self.ensemble is None: + return None + if isinstance(self.ensemble, EnsembleSource): + return self.ensemble.reference_temperature + return float(self.ensemble.current_T) + + def _require_reference_dyn(self, what): + dyn = self.reference_dyn + if dyn is None: + raise ValueError( + "An ensemble source or a loaded ensemble is required to " + "{}".format(what)) + return dyn + + def _add_request(self, request): + if not isinstance(request.name, str) or not request.name: + raise ValueError("request name must be a non-empty string") + if any(character not in + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" + for character in request.name): + raise ValueError( + "request name may contain only letters, numbers, '_' and '-'") + if request.name in self._requests: + raise ValueError( + "a spectroscopy request named {!r} already exists".format( + request.name)) + self._requests[request.name] = request + return request.name + + def add_raman_polarized(self, incoming, outgoing, name): + incoming = _finite_array(incoming, (3,), "incoming polarization") + outgoing = _finite_array(outgoing, (3,), "outgoing polarization") + if np.linalg.norm(incoming) <= _DEFAULT_TOLERANCE: + raise ValueError("incoming polarization must not be zero") + if np.linalg.norm(outgoing) <= _DEFAULT_TOLERANCE: + raise ValueError("outgoing polarization must not be zero") + incoming = incoming / np.linalg.norm(incoming) + outgoing = outgoing / np.linalg.norm(outgoing) + coefficients = raman_coefficients_from_polarizations( + incoming, outgoing) + return self.add_raman_tensor(coefficients, name=name) + + def add_raman_tensor(self, tensor, name): + perturbation = RamanTensorPerturbation(tensor) + return self._add_request(SpectroscopyRequest( + name=name, observable="raman_polarized", + perturbations=(perturbation,), weights=(1.0,))) + + def add_raman_unpolarized(self, name, convention="normalized"): + perturbations = tuple(RamanTensorPerturbation( + component.coefficients(convention)) + for component in RAMAN_COMPONENTS) + weights = tuple(float(component.weight(convention)) + for component in RAMAN_COMPONENTS) + return self._add_request(SpectroscopyRequest( + name=name, observable="raman_unpolarized", + perturbations=perturbations, weights=weights, + convention=convention)) + + def add_raman_vector(self, vector, name): + """Add an explicitly prepared unit-cell Raman Cartesian vector.""" + perturbation = CartesianPerturbation(vector) + return self._add_request(SpectroscopyRequest( + name=name, observable="raman_polarized", + perturbations=(perturbation,), weights=(1.0,))) + + def add_ir_polarized(self, direction, name, effective_charges=None): + source = None + if effective_charges is not None: + effective_charges = np.asarray(effective_charges, dtype=float) + if (effective_charges.ndim != 3 or + effective_charges.shape[1:] != (3, 3)): + raise ValueError( + "effective_charges must have shape (n_atoms, 3, 3)") + if not np.all(np.isfinite(effective_charges)): + raise ValueError("effective_charges must be finite") + source = tuple(tuple(tuple(float(value) for value in row) + for row in atom) + for atom in effective_charges) + perturbation = IRPolarizationPerturbation(direction) + return self._add_request(SpectroscopyRequest( + name=name, observable="ir_polarized", + perturbations=(perturbation,), weights=(1.0,), source=source)) + + def add_ir_unpolarized(self, name, effective_charges=None): + source = None + if effective_charges is not None: + effective_charges = np.asarray(effective_charges, dtype=float) + if (effective_charges.ndim != 3 or + effective_charges.shape[1:] != (3, 3)): + raise ValueError( + "effective_charges must have shape (n_atoms, 3, 3)") + source = tuple(tuple(tuple(float(value) for value in row) + for row in atom) + for atom in effective_charges) + perturbations = tuple( + IRPolarizationPerturbation(direction) for direction in np.eye(3)) + return self._add_request(SpectroscopyRequest( + name=name, observable="ir_unpolarized", + perturbations=perturbations, weights=(1 / 3,) * 3, + source=source)) + + def add_ir_vector(self, vector, name): + """Add an explicitly prepared unit-cell IR Cartesian vector.""" + perturbation = CartesianPerturbation(vector) + return self._add_request(SpectroscopyRequest( + name=name, observable="ir_polarized", + perturbations=(perturbation,), weights=(1.0,))) + + def add_cartesian_perturbation(self, vector, observable, name): + if not isinstance(observable, str) or not observable: + raise ValueError("observable must be a non-empty string") + perturbation = CartesianPerturbation(vector) + return self._add_request(SpectroscopyRequest( + name=name, observable=observable, + perturbations=(perturbation,), weights=(1.0,))) + + def manifest(self): + """Return the JSON-compatible, execution-independent request manifest.""" + requests = [] + for request in self._requests.values(): + perturbations = [] + for perturbation in request.perturbations: + if isinstance(perturbation, RamanTensorPerturbation): + value = [list(row) for row in perturbation.coefficients] + elif isinstance(perturbation, IRPolarizationPerturbation): + value = list(perturbation.direction) + else: + value = list(perturbation.vector) + perturbations.append({ + "kind": perturbation.kind.value, + "value": value, + }) + requests.append({ + "name": request.name, + "observable": request.observable, + "convention": request.convention, + "weights": list(request.weights), + "source": request.source, + "perturbations": perturbations, + }) + source = self.ensemble_source + return { + "schema_version": _SPECTROSCOPY_SCHEMA_VERSION, + "raman_schema_version": _RAMAN_SCHEMA_VERSION, + "backend": self.backend, + "use_symmetries": self.use_symmetries, + "symmetry_tolerance": self.symmetry_tolerance, + "ignore_v3": self.ignore_v3, + "ignore_v4": self.ignore_v4, + "lo_to_split": self.lo_to_split, + "backend_options": self.backend_options, + "ensemble_source": source.describe() if source is not None + else None, + "ensemble_provenance": source.provenance() if source is not None + else None, + "requests": requests, + } + + def plan_calculations(self): + """Return the symmetry-reduced run plan without executing Lanczos.""" + dyn = self._require_reference_dyn("build a run plan") + if not self._requests: + raise ValueError("Add at least one Raman or IR request first") + from tdscha import _SpectroscopyWorkflow as workflow + + self._run_specs, self._request_maps, group_order = ( + workflow.build_execution_plan( + self._requests, dyn, + use_symmetries=self.use_symmetries, + tolerance=self.symmetry_tolerance)) + return { + "group_order": group_order, + "n_requested_components": sum( + len(request.perturbations) + for request in self._requests.values()), + "n_independent_runs": len(self._run_specs), + "runs": { + run_id: { + "kind": spec.kind, + "vector": list(spec.vector), + "stabilizer": list(spec.stabilizer), + "characters": list(spec.characters), + "cosets": [list(coset) for coset in spec.cosets], + } + for run_id, spec in self._run_specs.items() + }, + "request_components": { + name: [ + { + "run_id": component.run_id, + "weight": component.weight, + "phase": component.phase, + "component_index": component.component_index, + } + for component in components + ] + for name, components in self._request_maps.items() + }, + } + + def _execution_manifest(self, n_steps, run_options): + from tdscha import _SpectroscopyWorkflow as workflow + + plan = self.plan_calculations() + manifest = self.manifest() + dyn = self._require_reference_dyn("write an execution manifest") + temperature = float(self.reference_temperature) + structure = dyn.structure + supercell = np.asarray(dyn.GetSupercell()) + unit_cell_volume = float(abs(np.linalg.det(structure.unit_cell))) + manifest.update({ + "ensemble_fingerprint": workflow.reference_fingerprint( + dyn, temperature), + "temperature": temperature, + "unit_cell_volume_angstrom3": unit_cell_volume, + "supercell_volume_angstrom3": unit_cell_volume * float( + np.prod(supercell)), + "dielectric_tensor": workflow.json_compatible( + getattr(dyn, "dielectric_tensor", None)), + "target_steps": int(n_steps), + "run_options": workflow.json_compatible(run_options), + "group_order": plan["group_order"], + "request_components": plan["request_components"], + "runs": {}, + }) + for run_id, run in plan["runs"].items(): + manifest["runs"][run_id] = dict( + run, state="pending", completed_steps=0, converged=False, + error=None) + return workflow.json_compatible(manifest) + + @staticmethod + def _validate_restart_manifest(existing, current): + # ``ensemble_provenance`` is intentionally absent: it records the + # absolute paths the ensemble was read from, which must not stop a + # calculation from resuming after it has been moved. + fields = ( + "schema_version", "raman_schema_version", "backend", + "use_symmetries", "symmetry_tolerance", "backend_options", + "ignore_v3", "ignore_v4", "lo_to_split", + "requests", "ensemble_fingerprint", "ensemble_source", + "run_options", "request_components") + mismatches = [field for field in fields + if existing.get(field) != current.get(field)] + if mismatches: + raise ValueError( + "Spectroscopy checkpoint is incompatible in: {}".format( + ", ".join(mismatches))) + + def run(self, n_steps, save_each=10, resume=True, verbose=True, + run_options=None): + """Run every symmetry-inequivalent perturbation to ``n_steps``. + + ``n_steps`` is the total requested number of Lanczos coefficients, + including work restored from checkpoints. + + The backend engine is built once and reused for every independent + perturbation: preparing a perturbation resets the whole Lanczos + state, and reading a production ensemble is minutes of I/O that must + not be repeated per run. It is built lazily, so a fully restored + calculation reloads nothing. + """ + from tdscha import _SpectroscopyWorkflow as workflow + + if not isinstance(n_steps, (int, np.integer)) or int(n_steps) < 1: + raise ValueError("n_steps must be a positive integer") + if not isinstance(save_each, (int, np.integer)) or int(save_each) < 1: + raise ValueError("save_each must be a positive integer") + n_steps = int(n_steps) + save_each = int(save_each) + run_options = dict(run_options or {}) + current = self._execution_manifest(n_steps, run_options) + workdir = Path(self.workdir) + manifest_path = workdir / "manifest.json" + workflow.ensure_directory(workdir / "runs") + + if manifest_path.exists() and resume: + with open(manifest_path, "r", encoding="utf-8") as stream: + existing = json.load(stream) + self._validate_restart_manifest(existing, current) + for run_id, run in current["runs"].items(): + if run_id in existing.get("runs", {}): + old = existing["runs"][run_id] + run["state"] = old.get("state", "pending") + run["completed_steps"] = old.get("completed_steps", 0) + run["converged"] = old.get("converged", False) + run["error"] = old.get("error") + run["analysis"] = old.get("analysis") + self._manifest_data = current + workflow.atomic_write_json(manifest_path, current) + + engine_options = dict(self.backend_options) + engine_options.update( + ignore_v3=self.ignore_v3, + ignore_v4=self.ignore_v4, + lo_to_split=( + np.asarray(self.lo_to_split, dtype=float) + if isinstance(self.lo_to_split, list) + else self.lo_to_split)) + # Built on first use. Every rank walks the same run list and makes + # the same skip decisions, so the construction -- which is collective + # for the distributed backends -- stays matched across ranks. + engine = None + + for run_id, spec in self._run_specs.items(): + run_dir = workdir / "runs" / run_id + status_path = run_dir / "status.npz" + result_path = run_dir / "result.npz" + metadata_path = run_dir / "metadata.json" + entry = current["runs"][run_id] + completed = int(entry.get("completed_steps", 0)) + portable_path = run_dir / "lanczos.abc" + if (resume and entry.get("state") == "complete" and + (completed >= n_steps or entry.get("converged", False)) and + (result_path.exists() or portable_path.exists())): + if result_path.exists(): + self._results[run_id] = workflow.load_result(result_path) + else: + analysis = entry.get("analysis") or {} + self._results[run_id] = workflow.load_abc_result( + portable_path, run_id, current["temperature"], + use_wigner=analysis.get("use_wigner", True), + reverse=analysis.get("reverse", False), + shift=analysis.get("shift", 0.0)) + continue + + if engine is None: + engine = workflow.create_backend( + self.ensemble, self.backend, engine_options, + use_symmetries=self.use_symmetries) + workflow.prepare_engine( + engine, spec.as_array(), self.use_symmetries, spec, + self.symmetry_tolerance) + entry["analysis"] = workflow.analysis_metadata( + engine, self.backend) + if resume and status_path.exists(): + engine.load_status(str(status_path)) + completed = workflow.completed_steps(engine, self.backend) + else: + completed = 0 + + entry.update(state="running", completed_steps=completed, error=None) + workflow.atomic_write_json(metadata_path, entry) + workflow.atomic_write_json(manifest_path, current) + try: + while completed < n_steps: + chunk = min(save_each, n_steps - completed) + workflow.run_engine_chunk( + engine, self.backend, chunk, verbose, run_options) + completed = workflow.completed_steps(engine, self.backend) + workflow.atomic_save_status(engine, status_path) + entry["completed_steps"] = completed + if workflow.engine_converged(engine, self.backend): + entry["converged"] = True + workflow.atomic_write_json(metadata_path, entry) + workflow.atomic_write_json(manifest_path, current) + if entry["converged"]: + break + + workflow.save_result( + engine, self.backend, run_id, result_path) + workflow.atomic_save_abc(engine, portable_path) + self._results[run_id] = workflow.load_result(result_path) + entry.update(state="complete", completed_steps=completed, + error=None) + except Exception as error: + entry.update(state="failed", completed_steps=completed, + error="{}: {}".format( + type(error).__name__, error)) + workflow.atomic_write_json(metadata_path, entry) + workflow.atomic_write_json(manifest_path, current) + raise + workflow.atomic_write_json(metadata_path, entry) + workflow.atomic_write_json(manifest_path, current) + return self + + @classmethod + def load(cls, workdir): + """Load a completed or partially completed calculation for analysis.""" + from tdscha import _SpectroscopyWorkflow as workflow + + workdir = Path(workdir) + with open(workdir / "manifest.json", "r", encoding="utf-8") as stream: + manifest = json.load(stream) + instance = cls( + None, backend=manifest["backend"], workdir=workdir, + use_symmetries=manifest.get("use_symmetries", True), + symmetry_tolerance=manifest.get("symmetry_tolerance", 1e-8), + ignore_v3=manifest.get("ignore_v3"), + ignore_v4=manifest.get("ignore_v4"), + lo_to_split=manifest.get("lo_to_split"), + backend_options=manifest.get("backend_options", {})) + instance._manifest_data = manifest + from tdscha import _SpectroscopyWorkflow as workflow_module + instance._request_maps = { + name: tuple(workflow_module.RequestComponent(**component) + for component in components) + for name, components in manifest["request_components"].items() + } + for run_id, entry in manifest["runs"].items(): + result_path = workdir / "runs" / run_id / "result.npz" + abc_path = workdir / "runs" / run_id / "lanczos.abc" + if entry.get("state") == "complete" and result_path.exists(): + instance._results[run_id] = workflow.load_result(result_path) + elif entry.get("state") == "complete" and abc_path.exists(): + analysis = entry.get("analysis") or {} + instance._results[run_id] = workflow.load_abc_result( + abc_path, run_id, manifest["temperature"], + use_wigner=analysis.get("use_wigner", True), + reverse=analysis.get("reverse", False), + shift=analysis.get("shift", 0.0)) + return instance + + def _request_manifest(self, name): + if self._manifest_data is None: + requests = self.manifest()["requests"] + else: + requests = self._manifest_data["requests"] + for request in requests: + if request["name"] == name: + return request + raise KeyError("Unknown spectroscopy request {!r}".format(name)) + + def _evaluate_request(self, name, frequencies, quantity, options): + from tdscha import _SpectroscopyWorkflow as workflow + + if name not in self._request_maps: + raise KeyError("Unknown or unplanned spectroscopy request {!r}".format( + name)) + cache = {} + total = np.zeros_like( + np.asarray(frequencies, dtype=float), + dtype=np.complex128 if quantity == "green" else float) + for component in self._request_maps[name]: + if component.run_id is None: + continue + if component.run_id not in self._results: + raise RuntimeError( + "Result {} required by {!r} is incomplete".format( + component.run_id, name)) + if component.run_id not in cache: + result = self._results[component.run_id] + if quantity == "green": + cache[component.run_id] = workflow.evaluate_green_function( + result, frequencies, **options) + else: + cache[component.run_id] = workflow.evaluate_response( + result, frequencies, **options) + total += component.weight * cache[component.run_id] + return total + + def green_function(self, name, frequencies, **options): + """Return the weighted complex response for one named request.""" + return self._evaluate_request( + name, frequencies, "green", dict(options)) + + def response(self, name, frequencies, **options): + """Return the Bose-free weighted spectral response ``-Im G``.""" + return self._evaluate_request( + name, frequencies, "response", dict(options)) + + def raman_spectrum(self, name, frequencies, kind="stokes", + temperature=None, laser_frequency=None, **options): + """Return Raman response, Stokes, or anti-Stokes intensity.""" + request = self._request_manifest(name) + if not request["observable"].startswith("raman"): + raise ValueError("Request {!r} is not Raman".format(name)) + frequencies = np.asarray(frequencies, dtype=float) + spectrum = self.response(name, frequencies, **options) + if kind == "response": + return spectrum + if kind not in ("stokes", "anti_stokes"): + raise ValueError( + "kind must be 'response', 'stokes', or 'anti_stokes'") + if np.any(frequencies <= 0): + raise ValueError("Thermal Raman frequencies must be positive") + if temperature is None: + if self._manifest_data is not None: + temperature = self._manifest_data["temperature"] + else: + temperature = self.reference_temperature + if temperature is None: + raise ValueError( + "No temperature is available: pass temperature=, or run " + "or load the calculation first") + import tdscha.DynamicalLanczos as DL + occupation = DL.bose_occupation(frequencies, float(temperature)) + spectrum = spectrum * ( + occupation + 1 if kind == "stokes" else occupation) + if laser_frequency is not None: + laser_frequency = float(laser_frequency) + scattered = (laser_frequency - frequencies + if kind == "stokes" + else laser_frequency + frequencies) + if np.any(scattered <= 0): + raise ValueError( + "laser_frequency must exceed every Stokes shift") + spectrum = spectrum * scattered**4 + return spectrum + + def ir_susceptibility(self, name, frequencies, **options): + """Return the projected ionic susceptibility in Hartree atomic units. + + The Lanczos Green function uses CellConstructor's Rydberg frequency + and mass convention. Converting its displacement response to the + conventional Hartree atomic units contributes the factor two below. + + The Green function is computed from the gamma perturbation + ``Z* . direction * sqrt(n_cell)`` (``prepare_ir`` scales the unit-cell + charge vector by ``sqrt(n_cell)``), so it already carries the + ``n_cell`` factor. ``dielectric_function`` therefore divides by the + **supercell** volume ``V = n_cell * V_unit_cell``, exactly matching the + CellConstructor non-analytic LO-TO term ``8*pi/V`` (the 8 is the + Rydberg ``e^2 = 2``). + """ + request = self._request_manifest(name) + if not request["observable"].startswith("ir"): + raise ValueError("Request {!r} is not IR".format(name)) + return 2 * self.green_function(name, frequencies, **options) + + def dielectric_function(self, name, frequencies, + epsilon_infinity=None, ionic_prefactor=None, + electronic_projection=None, **options): + """Return projected ``epsilon_inf + (4 pi / Omega) chi_ionic``. + + The default volume is the **supercell** volume converted from the + CellConstructor Angstrom convention to Bohr cubed. This is required + because the Lanczos perturbation carries ``sqrt(n_cell)`` + (``prepare_ir``), so ``chi_ionic`` already includes the ``n_cell`` + factor and the volume must cancel it. Together with the factor of two + in ``ir_susceptibility`` (Rydberg ``e^2 = 2``), the total prefactor is + ``8*pi / V_supercell``, matching the CellConstructor non-analytic LO-TO + term. ``ionic_prefactor`` can override the full prefactor when a + different electromagnetic/unit convention is needed. + """ + request = self._request_manifest(name) + if not request["observable"].startswith("ir"): + raise ValueError("Request {!r} is not IR".format(name)) + if self._manifest_data is None: + raise RuntimeError("Run or load the calculation before analysis") + if epsilon_infinity is None and self.reference_dyn is not None: + epsilon_infinity = getattr( + self.reference_dyn, "dielectric_tensor", None) + if epsilon_infinity is None: + epsilon_infinity = self._manifest_data.get("dielectric_tensor") + if epsilon_infinity is None: + raise ValueError("No electronic dielectric tensor is available") + epsilon_infinity = _finite_array( + epsilon_infinity, (3, 3), "epsilon_infinity") + if electronic_projection is not None: + electronic = float(electronic_projection) + elif request["observable"] == "ir_unpolarized": + electronic = np.trace(epsilon_infinity) / 3 + elif request["perturbations"][0]["kind"] == "ir": + direction = np.asarray( + request["perturbations"][0]["value"], dtype=float) + direction = direction / np.linalg.norm(direction) + electronic = direction @ epsilon_infinity @ direction + else: + raise ValueError( + "An explicit IR vector requires electronic_projection") + if ionic_prefactor is None: + from cellconstructor.Units import A_TO_BOHR + volume_angstrom3 = self._manifest_data.get( + "supercell_volume_angstrom3") + if volume_angstrom3 is None: + # Backward compatibility with manifests written before the + # supercell volume was stored. + dyn = self._require_reference_dyn( + "recover the supercell volume of an old manifest") + n_cell = float(np.prod(np.asarray(dyn.GetSupercell()))) + volume_angstrom3 = ( + self._manifest_data["unit_cell_volume_angstrom3"] + * n_cell) + volume_bohr3 = volume_angstrom3 * float(A_TO_BOHR)**3 + ionic_prefactor = 4 * np.pi / volume_bohr3 + return (electronic + float(ionic_prefactor) + * self.ir_susceptibility(name, frequencies, **options)) + + +__all__ = [ + "CartesianPerturbation", "EnsembleSource", "IRPolarizationPerturbation", + "PerturbationKind", "PerturbationOrbit", "RAMAN_COMPONENTS", + "RamanComponent", "RamanTensorPerturbation", "Spectroscopy", + "SpectroscopyRequest", "SymmetryGroup", "build_ir_vector", + "build_raman_vector", "find_perturbation_orbits", + "find_atom_permutation", "get_gamma_symmetry_representation", + "get_raman_component", "get_unpolarized_raman_weights", + "raman_coefficients_from_polarizations", "symmetric_raman_vector", + "vector_representations_for_ir", + "vector_representations_for_symmetric_raman", +] diff --git a/Modules/_SpectroscopyWorkflow.py b/Modules/_SpectroscopyWorkflow.py new file mode 100644 index 00000000..582d0f82 --- /dev/null +++ b/Modules/_SpectroscopyWorkflow.py @@ -0,0 +1,523 @@ +"""Private execution, persistence, and symmetry helpers for Spectroscopy. + +The public physical definitions live in :mod:`tdscha.Spectroscopy`. This +module keeps filesystem and backend orchestration out of that definition +layer and never reimplements Lanczos recursions or continued fractions. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +import os +from pathlib import Path +from typing import Optional, Tuple +import warnings + +import numpy as np + +import cellconstructor.Settings as Parallel + + +@dataclass(frozen=True) +class RunSpec: + run_id: str + kind: str + vector: Tuple[float, ...] + stabilizer: Tuple[int, ...] + characters: Tuple[float, ...] + cosets: Tuple[Tuple[int, ...], ...] + group_rotations: Tuple[Tuple[Tuple[float, ...], ...], ...] + + def as_array(self): + return np.asarray(self.vector, dtype=float) + + +@dataclass(frozen=True) +class RequestComponent: + # ``None`` denotes a symmetry-forbidden (identically zero) optical + # component. It participates in the observable definition, but does not + # require a Lanczos calculation. + run_id: Optional[str] + weight: float + phase: float + component_index: int + + +@dataclass(frozen=True) +class SpectroscopyResult: + run_id: str + method: str + temperature: float + perturbation_modulus: float + data: dict + + +def json_compatible(value): + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, np.generic): + return value.item() + if isinstance(value, Path): + return str(value) + if isinstance(value, dict): + return {str(key): json_compatible(item) + for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [json_compatible(item) for item in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + raise TypeError("Value {!r} is not JSON serializable".format(value)) + + +def array_fingerprint(*arrays): + digest = hashlib.sha256() + for array in arrays: + if array is None: + digest.update(b"") + continue + array = np.asarray(array) + digest.update(str(array.shape).encode("ascii")) + digest.update(str(array.dtype).encode("ascii")) + if array.dtype.kind in "OUS": + digest.update(json.dumps(array.tolist(), sort_keys=True).encode()) + else: + digest.update(np.ascontiguousarray(array).tobytes()) + return digest.hexdigest() + + +def reference_fingerprint(dyn, temperature): + """Fingerprint the reference dynamical matrix and temperature. + + This is what identifies a calculation for restart purposes. It + deliberately does not touch the configurations: they live only on the + master once the ensemble is loaded distributed, so any fingerprint over + them would either be unavailable or force a collective. The + configurations are identified separately, by the manifest's + ``ensemble_source`` entry. + """ + structure = dyn.structure + dynmats = getattr(dyn, "dynmats", ()) + arrays = [ + structure.unit_cell, structure.coords, + np.asarray(structure.get_atomic_types()), + np.asarray(dyn.GetSupercell()), + ] + arrays.extend(dynmats) + arrays.extend([ + getattr(dyn, "raman_tensor", None), + getattr(dyn, "effective_charges", None), + getattr(dyn, "dielectric_tensor", None), + np.asarray([float(temperature)], dtype=float), + ]) + return array_fingerprint(*arrays) + + +def canonical_vector(vector, tolerance): + vector = np.asarray(vector, dtype=float).ravel() + norm = np.linalg.norm(vector) + significant = np.flatnonzero(np.abs(vector) > tolerance * norm) + if significant.size and vector[significant[0]] < 0: + vector = -vector + return vector + + +def vector_run_id(vector, tolerance): + vector = canonical_vector(vector, tolerance) + # Optical vertices have physical units and no universal absolute scale. + # Hash the deterministic canonical bytes; tolerance is used only to pick + # the sign, never to quantize a small physical perturbation to zero. + digest = hashlib.sha256(np.ascontiguousarray(vector).tobytes()) + return "p_" + digest.hexdigest()[:20] + + +def _request_vector(request, perturbation, dyn): + import tdscha.Spectroscopy as SP + + if isinstance(perturbation, SP.RamanTensorPerturbation): + if dyn.raman_tensor is None: + raise ValueError( + "Raman request {!r} requires a Raman tensor".format( + request.name)) + return SP.build_raman_vector( + dyn.raman_tensor, perturbation.as_array()).ravel() + if isinstance(perturbation, SP.IRPolarizationPerturbation): + effective_charges = request.source + if effective_charges is None: + effective_charges = dyn.effective_charges + if effective_charges is None: + raise ValueError( + "IR request {!r} requires effective charges".format( + request.name)) + return SP.build_ir_vector( + np.asarray(effective_charges), perturbation.as_array()) + return perturbation.as_array().ravel() + + +def build_execution_plan(requests, dyn, use_symmetries=True, tolerance=1e-8): + """Build globally deduplicated runs and per-request reconstruction maps.""" + import tdscha.Spectroscopy as SP + + if use_symmetries: + group, representations = SP.get_gamma_symmetry_representation( + dyn.structure, tolerance=tolerance, + supercell=dyn.GetSupercell()) + else: + group = SP.SymmetryGroup.from_matrices([np.eye(3)]) + dimension = 3 * dyn.structure.N_atoms + representations = (np.eye(dimension),) + + run_specs = {} + request_maps = {} + flattened = [] + for request in requests.values(): + vectors = tuple(_request_vector(request, perturbation, dyn) + for perturbation in request.perturbations) + if any(vector.size != representations[0].shape[0] + for vector in vectors): + raise ValueError( + "Request {!r} has a Cartesian vector incompatible with the " + "unit-cell structure".format(request.name)) + components = [None] * len(vectors) + norms = np.asarray([np.linalg.norm(vector) for vector in vectors]) + reference_norm = float(np.max(norms, initial=0.0)) + zero_threshold = ( + 100 * np.finfo(float).eps * reference_norm + if reference_norm > 0 else 0.0) + for index, (perturbation, vector, norm) in enumerate(zip( + request.perturbations, vectors, norms)): + if norm <= zero_threshold: + components[index] = RequestComponent( + run_id=None, + weight=float(request.weights[index]), + phase=1.0, + component_index=index) + else: + flattened.append( + (request, index, perturbation, vector)) + request_maps[request.name] = components + + vectors = tuple(item[3] for item in flattened) + orbits = SP.find_perturbation_orbits( + vectors, representations, group, tolerance=tolerance) + for orbit in orbits: + _, _, perturbation, representative_vector = ( + flattened[orbit.representative]) + run_id = vector_run_id(representative_vector, tolerance) + canonical = canonical_vector(representative_vector, tolerance) + if run_id in run_specs: + existing = run_specs[run_id].as_array() + scale = max(np.linalg.norm(existing), + np.linalg.norm(canonical), np.finfo(float).tiny) + if np.linalg.norm(existing - canonical) > tolerance * scale: + raise RuntimeError("Perturbation hash collision") + else: + run_specs[run_id] = RunSpec( + run_id=run_id, + kind=perturbation.kind.value, + vector=tuple(float(item) for item in canonical), + stabilizer=orbit.stabilizer, + characters=orbit.characters, + cosets=orbit.right_cosets, + group_rotations=group.matrices) + + for member, phase in zip(orbit.members, orbit.phases): + member_request, member_index, _, _ = flattened[member] + request_maps[member_request.name][member_index] = RequestComponent( + run_id=run_id, + weight=float(member_request.weights[member_index]), + phase=float(phase), + component_index=member_index) + + request_maps = { + name: tuple(components) for name, components in request_maps.items() + } + return run_specs, request_maps, len(group) + + +def _create_distributed_backend(source, backend, options, use_symmetries): + """Build a q-space engine whose configurations live on one rank each. + + The master reads the ensemble and scatters the Bloch-transformed + configurations; no rank ever holds a replica. Anything in the + construction that performs an MPI collective is run by every rank first, + through ``prepare_distributed_construction`` -- see + ``QSpaceLanczos.load_distributed_tdscha``. + """ + loader_options = dict( + use_symmetries=use_symmetries, + n_configs=source.n_configs, + final_dyn=source.converged_dyn, + final_T=(source.reference_temperature + if source.converged_dyn is not None else None), + lo_to_split=options.pop("lo_to_split", None)) + + if backend == "qspace": + import tdscha.QSpaceLanczos as QL + return QL.load_distributed_tdscha( + source.data_dir, source.population, source.generating_dyn, + source.T, **loader_options, **options) + if backend == "atom_fourier": + import tdscha.QSpaceAtomFourier as QAF + fine_mesh = options.pop("fine_mesh", None) + if fine_mesh is None: + raise ValueError( + "backend='atom_fourier' needs backend_options={'fine_mesh': " + "(m1, m2, m3), ...}") + return QAF.load_distributed_atom_fourier_tdscha( + source.data_dir, source.population, source.generating_dyn, + source.T, fine_mesh, **loader_options, **options) + raise AssertionError("unreachable backend {!r}".format(backend)) + + +def _create_replicated_backend(ensemble, backend, options): + """Build an engine from an ensemble already in this process's memory.""" + if backend == "real": + import tdscha.DynamicalLanczos as DL + return DL.Lanczos(ensemble, **options) + if backend == "qspace": + import tdscha.QSpaceLanczos as QL + return QL.QSpaceLanczos(ensemble, **options) + if backend == "atom_fourier": + import tdscha.QSpaceAtomFourier as QAF + return QAF.QSpaceAtomFourierLanczos(ensemble, **options) + raise AssertionError("unreachable backend {!r}".format(backend)) + + +def create_backend(ensemble, backend, options, use_symmetries=True): + """Build the Lanczos engine for one spectroscopy calculation. + + ``ensemble`` is either a :class:`~tdscha.Spectroscopy.EnsembleSource` -- + the production path, where the configurations are read once by the MPI + master and scattered -- or a loaded ``sscha.Ensemble.Ensemble``, which + is replicated on every rank. + + ``backend="real"`` has no distributed loader: the real-space Lanczos + parallelizes by splitting a *replicated* ensemble across ranks, so from + a source it loads the ensemble on every rank. That is correct for the + small systems this backend is for, and is why the q-space backends exist + for the large ones. + """ + import tdscha.Spectroscopy as SP + + if backend not in ("real", "qspace", "atom_fourier"): + raise ValueError("Unsupported spectroscopy backend {!r}".format( + backend)) + + options = dict(options) + runtime_flags = {} + for name in ("ignore_v3", "ignore_v4", "ignore_harmonic", + "ignore_small_w"): + if name in options: + runtime_flags[name] = options.pop(name) + + if isinstance(ensemble, SP.EnsembleSource) and backend != "real": + engine = _create_distributed_backend( + ensemble, backend, options, use_symmetries) + else: + if isinstance(ensemble, SP.EnsembleSource): + ensemble = ensemble.load_ensemble() + elif backend != "real" and Parallel.GetNProc() > 1: + warnings.warn( + "Spectroscopy was given an already loaded ensemble, so every " + "one of the {} ranks holds a full copy of the " + "configurations. Pass an EnsembleSource (or use " + "Spectroscopy.from_ensemble_path) to have the master read " + "them once and scatter them instead.".format( + Parallel.GetNProc())) + engine = _create_replicated_backend(ensemble, backend, options) + + for name, value in runtime_flags.items(): + setattr(engine, name, value) + return engine + + +def prepare_engine(engine, vector, use_symmetries, run_spec=None, + symmetry_tolerance=1e-8): + # Gamma optical perturbations can separate the expensive point-group + # average from the cheap translation projector in the real-space engine. + if hasattr(engine, "gamma_only"): + engine.gamma_only = True + engine.init(use_symmetries=use_symmetries) + engine._prepare_gamma_cartesian_perturbation(np.asarray(vector, dtype=float)) + if (use_symmetries and run_spec is not None and + hasattr(engine, "configure_spectroscopy_symmetry")): + engine.configure_spectroscopy_symmetry( + run_spec.group_rotations, run_spec.stabilizer, + run_spec.characters, run_spec.cosets, + tolerance=max(float(symmetry_tolerance), 1e-7)) + + +def analysis_metadata(engine, backend): + """Return the small set of conventions needed to read portable results.""" + metadata = {"use_wigner": bool(engine.use_wigner)} + metadata.update( + reverse=bool(engine.reverse_L), + shift=float(engine.shift_value), + ) + full_order = int(getattr(engine, "n_syms", 1)) + active = getattr(engine, "_spectroscopy_coset_indices", None) + metadata["symmetry_reduction"] = { + "enabled": active is not None, + "full_group_order": full_order, + "ensemble_representatives": ( + len(active) if active is not None else full_order), + "stabilizer_order": ( + len(engine._spectroscopy_stabilizer_indices) + if active is not None else 1), + } + return metadata + + +def completed_steps(engine, backend): + return len(engine.a_coeffs) + + +def engine_converged(engine, backend): + """Detect a terminated three-term recursion with no next Krylov vector.""" + return len(engine.a_coeffs) > len(engine.b_coeffs) + + +def run_engine_chunk(engine, backend, target_or_count, verbose, run_options): + options = dict(run_options) + options.pop("verbose", None) + engine.run_FT(target_or_count, verbose=verbose, **options) + + +def _barrier(): + Parallel.barrier() + + +def _master(): + return Parallel.am_i_the_master() + + +def ensure_directory(path): + if _master(): + Path(path).mkdir(parents=True, exist_ok=True) + _barrier() + + +def atomic_write_json(path, data): + path = Path(path) + ensure_directory(path.parent) + temporary = path.with_name("." + path.name + ".tmp") + if _master(): + with open(temporary, "w", encoding="utf-8") as stream: + json.dump(json_compatible(data), stream, indent=2, sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + _barrier() + + +def atomic_save_status(engine, path): + path = Path(path) + ensure_directory(path.parent) + temporary = path.with_name("." + path.stem + ".tmp.npz") + engine.save_status(str(temporary)) + _barrier() + if _master(): + os.replace(temporary, path) + _barrier() + + +def atomic_save_abc(engine, path): + """Save portable Lanczos coefficients without duplicating their format.""" + path = Path(path) + ensure_directory(path.parent) + temporary = path.with_name("." + path.name + ".tmp") + if _master(): + engine.save_abc(str(temporary)) + os.replace(temporary, path) + _barrier() + + +def save_result(engine, backend, run_id, path): + path = Path(path) + ensure_directory(path.parent) + temporary = path.with_name("." + path.stem + ".tmp.npz") + if _master(): + common = dict( + run_id=np.asarray(run_id), + method=np.asarray("lanczos"), + temperature=np.float64(engine.T), + perturbation_modulus=np.float64(engine.perturbation_modulus), + ) + common.update( + a_coeffs=np.asarray(engine.a_coeffs), + b_coeffs=np.asarray(engine.b_coeffs), + c_coeffs=np.asarray(engine.c_coeffs), + use_wigner=np.bool_(engine.use_wigner), + reverse=np.bool_(engine.reverse_L), + shift=np.float64(engine.shift_value), + ) + np.savez_compressed(temporary, **common) + os.replace(temporary, path) + _barrier() + + +def load_result(path): + with np.load(path, allow_pickle=False) as archive: + raw = {key: archive[key] for key in archive.files} + run_id = str(raw.pop("run_id").item()) + method = str(raw.pop("method").item()) + temperature = float(raw.pop("temperature")) + modulus = float(raw.pop("perturbation_modulus")) + return SpectroscopyResult( + run_id=run_id, method=method, temperature=temperature, + perturbation_modulus=modulus, data=raw) + + +def load_abc_result(path, run_id, temperature, use_wigner=True, + reverse=False, shift=0.0): + """Load a portable ``.abc`` calculation into the common result model.""" + import tdscha.DynamicalLanczos as DL + + engine = DL.Lanczos(None) + engine.load_abc(str(path)) + return SpectroscopyResult( + run_id=run_id, method="lanczos", temperature=float(temperature), + perturbation_modulus=float(engine.perturbation_modulus), + data={ + "a_coeffs": np.asarray(engine.a_coeffs), + "b_coeffs": np.asarray(engine.b_coeffs), + "c_coeffs": np.asarray(engine.c_coeffs), + "use_wigner": np.asarray(bool(use_wigner)), + "reverse": np.asarray(bool(reverse)), + "shift": np.asarray(float(shift)), + }) + + +def evaluate_green_function(result, frequencies, **options): + import tdscha.DynamicalLanczos as DL + + engine = DL.Lanczos(None) + engine.a_coeffs = list(result.data["a_coeffs"]) + engine.b_coeffs = list(result.data["b_coeffs"]) + engine.c_coeffs = list(result.data["c_coeffs"]) + engine.T = result.temperature + engine.perturbation_modulus = result.perturbation_modulus + engine.use_wigner = bool(result.data["use_wigner"]) + engine.reverse_L = bool(result.data["reverse"]) + engine.shift_value = float(result.data["shift"]) + engine.verbose = False + return engine.get_green_function_continued_fraction( + np.asarray(frequencies, dtype=float), **options) + + +def evaluate_response(result, frequencies, **options): + return -np.imag(evaluate_green_function(result, frequencies, **options)) + + +__all__ = [ + "RequestComponent", "RunSpec", "SpectroscopyResult", "analysis_metadata", + "atomic_save_abc", + "atomic_save_status", "atomic_write_json", "build_execution_plan", + "completed_steps", "create_backend", "engine_converged", + "ensure_directory", "evaluate_green_function", "evaluate_response", + "json_compatible", "load_abc_result", "load_result", "prepare_engine", + "reference_fingerprint", "run_engine_chunk", "save_result", +] diff --git a/Modules/_TwoPhononRamanLegacy.py b/Modules/_TwoPhononRamanLegacy.py new file mode 100644 index 00000000..0af47c77 --- /dev/null +++ b/Modules/_TwoPhononRamanLegacy.py @@ -0,0 +1,435 @@ +"""Private source archive for the disabled two-phonon Raman implementation. + +This module is deliberately not installed or imported. The former implementation +is stored as inert text for audit/reference only; it must not be executed. The +same source is available at commit b4f6dc2af70796b0bc42323b9333f017bacdc0a0. +""" + +LEGACY_SOURCE_COMMIT = "b4f6dc2af70796b0bc42323b9333f017bacdc0a0" + +LEGACY_TWO_PHONON_RAMAN_SOURCE = r''' def prepare_unpolarized_raman_FT(self, index = 0, debug = False, eq_raman_tns = None, use_symm = True,\ + ens_av_raman = None, raman_tns_ens = None, add_2ph = True): + """ + PREPARE UNPOLARIZED RAMAN SIGNAL CONSIDERING FLUCTUATIONS OF THE RAMAN TENSOR + ============================================================================= + + The raman tensor is read from the dynamical matrix provided by the original ensemble. + + The perturbations are prepared accordin to the formula (see https://doi.org/10.1021/jp5125266) + + ..math: + + I_unpol = 45/9 (xx + yy + zz)^2 + + 7/2 [(xx-yy)^2 + (xx-zz)^2 + (yy-zz)^2] + + 7 * 3 [(xy)^2 + (yz)^2 + (xz)^2] + + Parameters: + ----------- + -index: the pol component of the unpolarized signal + -debug: if true we save the second order Raman tensor + -eq_raman_tns: np.array with shape (3, 3, 3 * N_at_uc), the equilibirum raman tensor + -use_symm: bool, if True symmetries are enforced + -ens_av_raman: the ensemble on which we compute the averages of the Raman tensors + -raman_tns_ens: np.array with shape (N_conf, 3, 3, 3 * N_at_sc), the raman tensors on the displaced configruations + """ + # Check if the raman tensor is present + assert not self.dyn.raman_tensor is None, "Error, no Raman tensor found. Cannot initialize the Raman responce" + + labels = [i for i in range(7)] + if not(index in labels): + raise ValueError('{} should be in {}'.format(index, labels)) + + epols = {'x' : np.array([1,0,0]),\ + 'y' : np.array([0,1,0]),\ + 'z' : np.array([0,0,1])} + + # (xx + yy + zz)^2 + if index == 0: + # raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) + # raman_v += self.dyn.GetRamanVector(epols['y'], epols['y']) + # raman_v += self.dyn.GetRamanVector(epols['z'], epols['z']) + self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ + pol_in = epols['x'], pol_out = epols['x'],\ + mixed = True,\ + pol_in_2 = epols['y'], pol_out_2 = epols['y'],\ + pol_in_3 = epols['z'], pol_out_3 = epols['z'],\ + add_two_ph = add_2ph, symmetrize = use_symm,\ + ensemble = ens_av_raman,\ + save_raman_tensor2 = debug, file_raman_tensor2 = 'xx_plus_yy_plus_zz') + # (xx - yy)^2 + elif index == 1: + # raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) + # raman_v -= self.dyn.GetRamanVector(epols['y'], epols['y']) + # NB we put just one minus sign because the component is (xx - yy)^2 + self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ + pol_in = epols['x'], pol_out = epols['x'],\ + mixed = True,\ + pol_in_2 = -epols['y'], pol_out_2 = epols['y'],\ + pol_in_3 = np.zeros(3), pol_out_3 = np.zeros(3),\ + add_two_ph = add_2ph, symmetrize = use_symm,\ + ensemble = ens_av_raman,\ + save_raman_tensor2 = debug, file_raman_tensor2 = 'xx_minus_yy') + # (xx - zz)^2 + elif index == 2: + # raman_v = self.dyn.GetRamanVector(epols['x'], epols['x']) + # raman_v -= self.dyn.GetRamanVector(epols['z'], epols['z']) + self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ + pol_in = epols['x'], pol_out = epols['x'],\ + mixed = True,\ + pol_in_2 = -epols['z'], pol_out_2 = epols['z'],\ + pol_in_3 = np.zeros(3), pol_out_3 = np.zeros(3),\ + add_two_ph = add_2ph, symmetrize = use_symm,\ + ensemble = ens_av_raman,\ + save_raman_tensor2 = debug, file_raman_tensor2 = 'xx_minus_zz') + # (yy - zz)^2 + elif index == 3: + # raman_v = self.dyn.GetRamanVector(epols['y'], epols['y']) + # raman_v -= self.dyn.GetRamanVector(epols['z'], epols['z']) + self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ + pol_in = epols['y'], pol_out = epols['y'],\ + mixed = True,\ + pol_in_2 = -epols['z'], pol_out_2 = epols['z'],\ + pol_in_3 = np.zeros(3), pol_out_3 = np.zeros(3),\ + add_two_ph = add_2ph, symmetrize = use_symm,\ + ensemble = ens_av_raman,\ + save_raman_tensor2 = debug, file_raman_tensor2 = 'yy_minus_zz') + # (xy)^2 + elif index == 4: + # raman_v = self.dyn.GetRamanVector(epols['x'], epols['y']) + self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ + pol_in = epols['x'], pol_out = epols['y'],\ + mixed = False,\ + add_two_ph = add_2ph, symmetrize = use_symm,\ + ensemble = ens_av_raman,\ + save_raman_tensor2 = debug, file_raman_tensor2 = 'xy_square') + # (xz)^2 + elif index == 5: + # raman_v = self.dyn.GetRamanVector(epols['x'], epols['z']) + self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ + pol_in = epols['x'], pol_out = epols['z'],\ + mixed = False,\ + add_two_ph = add_2ph, symmetrize = use_symm,\ + ensemble = ens_av_raman,\ + save_raman_tensor2 = debug, file_raman_tensor2 = 'xz_square') + # (yz)^2 + elif index == 6: + # raman_v = self.dyn.GetRamanVector(epols['y'], epols['z']) + self.prepare_anharmonic_raman_FT(raman = raman_tns_ens, raman_eq = eq_raman_tns,\ + pol_in = epols['y'], pol_out = epols['z'],\ + mixed = False,\ + add_two_ph = add_2ph, symmetrize = use_symm,\ + ensemble = ens_av_raman,\ + save_raman_tensor2 = debug, file_raman_tensor2 = 'yz_square') + + return + + + + def prepare_anharmonic_raman_FT(self, raman = None, raman_eq = None,\ + pol_in = np.array([1.,0.,0.]), pol_out = np.array([1.,0.,0.]),\ + mixed = False, pol_in_2 = None, pol_out_2 = None,\ + pol_in_3 = None, pol_out_3 = None,\ + add_two_ph = False, symmetrize = False, ensemble = None,\ + save_raman_tensor2 = False, file_raman_tensor2 = None): + r""" + PREPARE THE PSI VECTOR FOR ANHARMONIC RAMAN SPECTRUM CALCULATION (NEW VERSION) + =========================================================================== + + This works only with the Wigner representation if we add the two phonons effect. + Prepare the psi vector for RAMAN spectrum considering position-dependent raman tensors. + + Parameters: + ----------- + -raman: nd.array (N_configs, E_comp, E_comp, 3 * N_at_sc), + the Raman tensor for all configurations. + Indices are: Number of configuration, electric field component, + electric field component, atomic coordinates in sc. + rama_eq: nd.array, (E_comp, E_comp, 3 * N_at_uc), the effective charges at equilibrium. + Indices are: electric field component, + electric field component, atomic coordinate in uc. + -pol_in: nd.array, the polarization of in-out light. default is x + -pol_out: nd.array, the polarization of in-out light. default is x + -mixed: if True we can study the one and two phonon response to + pol_in \cdto \Xi \cdot pol_in + pol_in_2 \cdto \Xi \cdot pol_in_2 + pol_in_3 \cdto \Xi \cdot pol_in_3 + (\Xi is the Raman tensor) + -pol_in_2: nd.array, the polarization of in-out light. default is None + -pol_out_2: nd.array, the polarization of in-out light. default is None + -pol_in_3: nd.array, the polarization of in-out light. default is None + -pol_out_3: nd.array, the polarization of in-out light. default is None + -add_two_ph: bool, if True two phonon processes are included in the calculation + -symmetrize: bool, if True the first/second order Raman tensors are symmetrized + -ensemble: a scha ensemble object for computing the averages + -save_raman_tensor2: bool if True we save the second order Raman tensor + """ + if not self.use_wigner and add_two_ph: + raise NotImplementedError('The two phonon processes are implemented only in Wigner') + + if raman is None: + raise ValueError('Must specify the raman tensors for all configurations!') + + if mixed: + #Check that we have the other polarization vectors + if (pol_in_2 is None) or (pol_out_2 is None): + raise ValueError('Must specify pol_in_2 pol_out_2 if mixed = True!') + + if (pol_in_3 is None) or (pol_out_3 is None): + raise ValueError('Must specify pol_in_3 pol_out_3 if mixed = True!') + + if len(pol_in_2) != 3 or len(pol_out_2) != 3: + raise ValueError('pol_in_2 pol_out_2 must be array of len 3') + + if len(pol_in_3) != 3 or len(pol_out_3) != 3: + raise ValueError('pol_in_3 pol_out_3 must be array of len 3') + + + print() + print('PREPARE THE RAMAN ANHARMONIC SPECTRUM CALCULATION') + print('=================================================') + print('Are we considering two ph effects? = {}'.format(add_two_ph)) + print('Are we using Wigner? = {}'.format(self.use_wigner)) + print('Are we symmetrizing the raman tensor? = {}'.format(symmetrize)) + print() + if ensemble is not None: + Nconf = ensemble.N + else: + Nconf = self.N + + required = 'N_conf - E_field - E_field - 3 * N_at_sc' + assert raman.shape[0] == Nconf, 'The raman tensor in input have the wrong shape. The required is {}'.format(required) + assert raman.shape[1] == 3, 'The raman tensor in input have the wrong shape. The required is {}'.format(required) + assert raman.shape[2] == 3, 'The raman tensor in input have the wrong shape. The required is {}'.format(required) + assert raman.shape[3] == self.nat * 3, 'The raman tensor in input have the wrong shape. The required is {}'.format(required) + + # alpha is the polarizability + + # Get the average of the raman tensor, np.array with shape = (3, 3, 3 * N_at_sc) + d1alpha_dR_av = perturbations.get_d1alpha_dR_av(ensemble, raman, symmetrize = symmetrize) + + # Get the supercell dyn then set the raman tensor euqal to d1alpha_dR_av + sc_dyn = self.dyn.GenerateSupercellDyn(self.dyn.GetSupercell()) + sc_dyn.raman_tensor = d1alpha_dR_av + + # Get the Raman vector np.array (3 * N_at_sc) + raman_vector_sc = sc_dyn.GetRamanVector(pol_in, pol_out) + + if mixed: + print('ONE PH SECTOR adding compoent pol_in_2 pol_out_2 of the Raman tensor') + raman_vector_sc += sc_dyn.GetRamanVector(pol_in_2, pol_out_2) + print('ONE PH SECTOR adding compoent pol_in_3 pol_out_3 of the Raman tensor') + raman_vector_sc += sc_dyn.GetRamanVector(pol_in_3, pol_out_3) + + + # Now rescale by the mass and go in polarizaiton basis + self.prepare_perturbation(raman_vector_sc, masses_exp = -1) + print('[NEW] Pertubation modulus with one ph effects only = {}'.format(self.perturbation_modulus)) + print() + + # NOW PREPARE THE SECOND RAMAN TENSOR + if add_two_ph: + if raman_eq is not None: + print('[NEW] Getting the equilibirum RAMAN tensor...') + print() + n_supercell = np.prod(self.dyn.GetSupercell()) + # raman_eq is np.array with shape = (E_field, E_field, N_at_uc * 3) + raman_eq_size = np.shape(raman_eq) + MSG = """ + Error, raman tns of the wrong shape: {} + """.format(raman_eq_size) + assert len(raman_eq_size) == 3, MSG + if not self.ignore_small_w: + assert raman_eq_size[2] * n_supercell == self.nat * 3 #self.n_modes + 3 + assert raman_eq_size[0] == raman_eq_size[1] == 3 + + # Get the raman tensor in the supercell (E_field, E_filed, 3 * N_at_sc) + raman_eq_gamma = np.zeros((3, 3, 3 * n_supercell * self.dyn.structure.N_atoms), dtype = type(raman_eq[0,0,0])) + raman_eq_gamma = np.tile(raman_eq, n_supercell) + + print('[NEW] Getting the two phonon contribution in RAMAN...') + + # d2M_dR np.array with shape = (3 * N_atoms, 3 * N_atoms, Efield) + if raman_eq is not None: + print('[NEW] Subtracting the equilibirum RAMAN tensor...') + # raman - raman_eq_gamma, np.array with shape = (N_configs, Efield, Efield, 3 * N_at_sc) + # THE RESULT HAS shape = (Efield, Efield, 3 * N_at_sc, 3 * N_at_sc) + d2alpha_dR = perturbations.get_d2alpha_dR_av(ensemble, raman - raman_eq_gamma, None, symmetrize = symmetrize) + else: + # THE RESULT HAS shape = (Efield, Efield, 3 * N_at_sc, 3 * N_at_sc) + d2alpha_dR = perturbations.get_d2alpha_dR_av(ensemble, raman, None, symmetrize = symmetrize) + + print('[NEW] Divide by the masses') + # Divide by the masses of the atoms in the supercell shape = (Efield, Efield, 3 * N_at_sc, 3 * N_at_sc) + d2alpha_dR = np.einsum('c, abcd, d -> abcd', np.sqrt(self.m)**-1, d2alpha_dR, np.sqrt(self.m)**-1) + + if save_raman_tensor2: + print('[NEW] Saving the second-order SCHA Raman tensor') + np.save('{}'.format(file_raman_tensor2), d2alpha_dR) + return + + print('[NEW] Go in polarization basis') + # Now go in polarization basis, np.array with shape = (E_field, E_field, n_modes, n_modes) + # d2alpha_dR_muspace = np.einsum('cm, abcd, dn -> abmn', self.pols, d2alpha_dR, self.pols) + # -> substitute + tmp = np.einsum('abcd, cm -> abmd', d2alpha_dR, self.pols) + d2alpha_dR_muspace = np.einsum('abmd, dn -> abmn', tmp, self.pols) + + # Project along the direction of the filed, np.array with shape = (n_modes, n_modes) + dXi_dR_muspace = np.einsum('abmn, a, b -> mn', d2alpha_dR_muspace, pol_in, pol_out) + + if mixed: + print('TWO PH SECTOR adding component pol_in_2 pol_out_2 of the Raman tensor') + dXi_dR_muspace += np.einsum('abmn, a, b -> mn', d2alpha_dR_muspace, pol_in_2, pol_out_2) + print('TWO PH SECTOR adding component pol_in_3 pol_out_3 of the Raman tensor') + dXi_dR_muspace += np.einsum('abmn, a, b -> mn', d2alpha_dR_muspace, pol_in_3, pol_out_3) + + # Symmetrize in mu space, np.array with shape = (n_modes, n_modes) + dXi_dR_muspace = 0.5 * (dXi_dR_muspace + dXi_dR_muspace.T) + + # Get chi_minus and chi_plus tensors, np.array with shape = (n_modes, n_modes) + chi_minus = self.get_chi_minus() + chi_plus = self.get_chi_plus() + + # Get the pertubations on a'^(1) b'^(1) + pert_a = -np.einsum('nm, nm -> nm', np.sqrt(-0.5 * chi_minus), dXi_dR_muspace) + pert_b = +np.einsum('nm, nm -> nm', np.sqrt(+0.5 * chi_plus) , dXi_dR_muspace) + + # Check if everything is symmetric + assert np.all(np.abs(dXi_dR_muspace - dXi_dR_muspace.T) < 1e-10), "Second derivative of the polarizability is not symmetric in pol basis" + assert np.all(np.abs(pert_a - pert_a.T) < 1e-10), "a'(1) pertubation is not symmetric in pol basis" + assert np.all(np.abs(pert_b - pert_b.T) < 1e-10), "b'(1) pertubation is not symmetric in pol basis" + + # Now get the perturbation for a'^(1) + current = self.n_modes + for i in range(self.n_modes): + self.psi[current : current + self.n_modes - i] = pert_a[i, i:] + current = current + self.n_modes - i + + # Now get the pertrubation for b'^(1) + for i in range(self.n_modes): + self.psi[current : current + self.n_modes - i] = pert_b[i, i:] + current = current + self.n_modes - i + + # Add the mask dot taking into account symmetric elements + mask_dot = self.mask_dot_wigner() + # OVERWRITE the pertubation modulus considering the two phonon sector + self.perturbation_modulus = self.psi.dot(self.psi * mask_dot) + + print('[NEW] Perturbation modulus after adding two ph contributions RAMAN = {}'.format(self.perturbation_modulus)) + print() + + return + + + + def prepare_anharmonic_raman_FT_2ph(self, d2alpha_dR = None, pol_in = np.array([1.,0.,0.]), pol_out = np.array([1.,0.,0.]),\ + mixed = False, pol_in_2 = None, pol_out_2 = None): + r""" + PREPARE THE PSI VECTOR FOR RAMAN SPECTRUM CALCULATION (NEW VERSION) DIRECTLY FROM 2nd ORDER RAMAN TENSOR + ======================================================================================================== + + This function is useful if we want to interpolate the 2nd Raman tensor on a bigger supercell. + + This works only with the Wigner representation if we add the two phonons effect. + Prepare the psi vector for RAMAN spectrum considering position-dependent raman tensors. + + NOTE: we completely neglect the frist order Raman scattering! + + Parameters: + ----------- + -d2alpha_dR: nd.array (E_comp, E_comp, 3 * N_at_sc, 3 * N_at_sc), + 2nd order Raman tensor. + Indices are: Number of configuration, electric field component, + electric field component, atomic coordinates in sc. + -pol_in: nd.array, the polarization of in-out light. default is x + -pol_out: nd.array, the polarization of in-out light. default is x + -mixed: if True we can study the one and two phonon response to + pol_in \cdot \Xi \cdot pol_out + pol_in_2 \cdot \Xi \cdot pol_out_2 + (\Xi is the Raman tensor) + -pol_in_2: nd.array, the polarization of in-out light. default is x + -pol_out_2: nd.array, the polarization of in-out light. default is x + """ + if not self.use_wigner: + raise NotImplementedError('The two phonon processes are implemented only in Wigner') + + if d2alpha_dR is None: + raise ValueError('Must specify the 2nd order Raman tensor!') + + exp_shape = (3, 3, self.nat * 3, self.nat * 3) + if d2alpha_dR.shape != exp_shape: + raise ValueError('The shape of the 2nd order Raman tensor is not correct, expected {}'.format(exp_shape)) + + if mixed: + if (pol_in_2 is None) or (pol_out_2 is None): + raise ValueError('Must specify pol_in_2 pol_out_2 if mixed = True!') + + if len(pol_in_2) != 3 or len(pol_out_2) != 3: + raise ValueError('pol_in_2 pol_out_2 must be array of len 3') + + + print() + print('PREPARE THE RAMAN ANHARMONIC SPECTRUM CALCULATION FROM 2nd ORDER RAMAN TENSOR') + print('=============================================================================') + # print('Are we considering two ph effects? = {}'.format(add_two_ph)) + print('Are we using Wigner? = {}'.format(self.use_wigner)) + # print('Are we symmetrizing the raman tensor? = {}'.format(symmetrize)) + print() + + print('TWO PH Going in polarization basis') + # Now go in polarization basis, np.array with shape = (E_field, E_field, n_modes, n_modes) + # d2alpha_dR_muspace = np.einsum('cm, abcd, dn -> abmn', self.pols, d2alpha_dR, self.pols) + # -> substitute + tmp = np.einsum('abcd, cm -> abmd', d2alpha_dR, self.pols) + d2alpha_dR_muspace = np.einsum('abmd, dn -> abmn', tmp, self.pols) + # print(d2alpha_dR_muspace.shape) + + print('TWO PH Selecting the polarizations') + # Project along the direction of the filed, np.array with shape = (n_modes, n_modes) + dXi_dR_muspace = np.einsum('abmn, a, b -> mn', d2alpha_dR_muspace, pol_in, pol_out) + # print(dXi_dR_muspace.shape) + + if mixed: + print('TWO PH SECTOR adding component pol_in_2 pol_out_2 of the Raman tensor') + dXi_dR_muspace += np.einsum('abmn, a, b -> mn', d2alpha_dR_muspace, pol_in_2, pol_out_2) + + # Symmetrize in mu space, np.array with shape = (n_modes, n_modes) + dXi_dR_muspace = 0.5 * (dXi_dR_muspace + dXi_dR_muspace.T) + + # Get chi_minus and chi_plus tensors, np.array with shape = (n_modes, n_modes) + chi_minus = self.get_chi_minus() + chi_plus = self.get_chi_plus() + + # Get the pertubations on a'^(1) b'^(1) + pert_a = -np.einsum('nm, nm -> nm', np.sqrt(-0.5 * chi_minus), dXi_dR_muspace) + pert_b = +np.einsum('nm, nm -> nm', np.sqrt(+0.5 * chi_plus) , dXi_dR_muspace) + + # Check if everything is symmetric + assert np.all(np.abs(dXi_dR_muspace - dXi_dR_muspace.T) < 1e-10), "Second derivative of the polarizability is not symmetric in pol basis" + assert np.all(np.abs(pert_a - pert_a.T) < 1e-10), "a'(1) pertubation is not symmetric in pol basis" + assert np.all(np.abs(pert_b - pert_b.T) < 1e-10), "b'(1) pertubation is not symmetric in pol basis" + + print('[NEW] Perturbation modulus = {}'.format(self.perturbation_modulus)) + print() + + # Now get the perturbation for a'^(1) + current = self.n_modes + for i in range(self.n_modes): + self.psi[current : current + self.n_modes - i] = pert_a[i, i:] + current = current + self.n_modes - i + + # Now get the pertrubation for b'^(1) + for i in range(self.n_modes): + self.psi[current : current + self.n_modes - i] = pert_b[i, i:] + current = current + self.n_modes - i + + # Add the mask dot taking into account symmetric elements + mask_dot = self.mask_dot_wigner() + # OVERWRITE the pertubation modulus considering the two phonon sector + self.perturbation_modulus = self.psi.dot(self.psi * mask_dot) + + print('[NEW] Perturbation modulus adding two ph contributions RAMAN = {}'.format(self.perturbation_modulus)) + print() + + return + + + +''' diff --git a/Modules/__init__.py b/Modules/__init__.py index 9a0403e5..7eeeebcc 100644 --- a/Modules/__init__.py +++ b/Modules/__init__.py @@ -6,8 +6,11 @@ from tdscha import DynamicalLanczos from tdscha import QSpaceLanczos -from tdscha import QSpaceKPM from tdscha import QSpaceHessian +from tdscha import QSpaceInterpolation +from tdscha import QSpaceAtomFourier +from tdscha import Spectroscopy from tdscha import cli -__all__ = ["DynamicalLanczos", "QSpaceLanczos", "QSpaceKPM", "QSpaceHessian", "cli"] +__all__ = ["DynamicalLanczos", "QSpaceLanczos", "QSpaceHessian", + "QSpaceInterpolation", "QSpaceAtomFourier", "Spectroscopy", "cli"] diff --git a/Modules/cli.py b/Modules/cli.py index 3c185e32..e5d54a83 100644 --- a/Modules/cli.py +++ b/Modules/cli.py @@ -3,6 +3,7 @@ from __future__ import division import sys, os +import argparse import numpy as np import matplotlib.pyplot as plt @@ -13,7 +14,6 @@ import sscha import tdscha, tdscha.DynamicalLanczos as DL -import tdscha.QSpaceKPM as QKPM import sscha.Ensemble MSG = """ TDSCHA @@ -54,12 +54,25 @@ Usage: -tdscha-plot-data file [w_start] [w_end] [smearing] +tdscha-plot-data file [w_start] [w_end] [smearing] [options] -Pass a .abc, .npz, or .kpm file resulting from a linear response calculation. -- .abc / .npz : use Lanczos continued fraction -- .kpm : use KPM spectral function -Optionally you can pass a range of frequencies (cm-1) and the smearing. +Pass a .abc or .npz file resulting from a Lanczos calculation. + +The legacy positional arguments [w_start] [w_end] [smearing] (in cm-1) are +still supported. Use the optional flags below for full control. + +Main options: + --w-start FLOAT, --w-end FLOAT Frequency range in cm-1 (default 0, 5000) + --n-w INT Number of frequency points (default 50000) + --smearing FLOAT Smearing in cm-1 (default 5) + --terminator Use the Lanczos terminator + --last-average INT Coefficients averaged for the terminator (default 1) + --smooth-ramp INT Blend the last coefficients toward the terminator mean (default 0) + --title TEXT Title of the plot + --dpi INT Figure resolution in dots per inch (default 100) + --save PATH Save the figure to a file instead of showing it + --no-show Do not open the plot window (useful with --save) + -h, --help Show the full help message """ @@ -173,68 +186,95 @@ def plot_hessian_convergence(): def plot(): - print(MSG_PLOT) - if len(sys.argv) not in [2, 3, 4, 5]: - print("Error, wrong number of arguments.") + parser = argparse.ArgumentParser( + prog = "tdscha-plot-data", + description = "Plot the spectrum of a TDSCHA calculation.", + epilog = MSG_PLOT, + formatter_class = argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("file", help = "the .abc or .npz file from a Lanczos calculation") + parser.add_argument("w_start_pos", nargs = "?", type = float, default = None, + metavar = "w_start", + help = "[legacy] start frequency in cm-1 (use --w-start instead)") + parser.add_argument("w_end_pos", nargs = "?", type = float, default = None, + metavar = "w_end", + help = "[legacy] end frequency in cm-1 (use --w-end instead)") + parser.add_argument("smearing_pos", nargs = "?", type = float, default = None, + metavar = "smearing", + help = "[legacy] smearing in cm-1 (use --smearing instead)") + + parser.add_argument("--w-start", dest = "w_start", type = float, default = None, + help = "start frequency in cm-1 (default 0)") + parser.add_argument("--w-end", dest = "w_end", type = float, default = None, + help = "end frequency in cm-1 (default 5000)") + parser.add_argument("--n-w", dest = "n_w", type = int, default = 50000, + help = "number of frequency points (default 50000)") + parser.add_argument("--smearing", dest = "smearing", type = float, default = None, + help = "smearing in cm-1 (default 5)") + parser.add_argument("--terminator", action = "store_true", + help = "use the Lanczos terminator to approximate the infinite fraction") + parser.add_argument("--last-average", dest = "last_average", type = int, default = 1, + help = "how many a and b coefficients are averaged for the terminator (default 1)") + parser.add_argument("--smooth-ramp", dest = "smooth_ramp", type = int, default = 0, + help = "blend the last coefficients towards the terminator mean (default 0)") + parser.add_argument("--title", default = None, + help = "title of the plot") + parser.add_argument("--dpi", type = int, default = 100, + help = "figure resolution in dots per inch (default 100)") + parser.add_argument("--save", default = None, + help = "save the figure to this file (e.g. spectrum.png) instead of showing it") + parser.add_argument("--no-show", action = "store_true", + help = "do not open the plot window (useful together with --save)") + + args = parser.parse_args() + + fname = args.file + if not os.path.exists(fname): + parser.error("Error, file {} does not exist".format(fname)) + + print("Loading file {}".format(fname)) + lanc = DL.Lanczos() + if fname.endswith(".abc"): + lanc.load_abc(fname) + elif fname.endswith(".npz"): + lanc.load_status(fname) + else: + print("ERROR, the specified file must be a .abc or .npz file.") exit() - - fname = sys.argv[1] - assert os.path.exists(fname), "Error, file {} does not exist".format(fname) - - use_kpm = fname.endswith(".kpm") + # The explicit flags take precedence over the legacy positional arguments + w_start = args.w_start if args.w_start is not None else (0 if args.w_start_pos is None else args.w_start_pos) + w_end = args.w_end if args.w_end is not None else (5000 if args.w_end_pos is None else args.w_end_pos) + smearing = args.smearing if args.smearing is not None else (5 if args.smearing_pos is None else args.smearing_pos) + n_w = args.n_w - if use_kpm: - print("Loading KPM file {}".format(fname)) - kpm = QKPM.QSpaceKPM(None) - kpm.load_kpm(fname) - lanc = None - else: - print("Loading file {}".format(fname)) - lanc = DL.Lanczos() - if fname.endswith(".abc"): - lanc.load_abc(fname) - elif fname.endswith(".npz"): - lanc.load_status(fname) - else: - print("ERROR, the specified file must be a .abc, .npz, or .kpm file.") - exit() - - w_start = 0 - w_end = 5000 - n_w = 50000 - smearing = 5 - - if len(sys.argv) >= 3: - w_start = float(sys.argv[2]) - if len(sys.argv) >= 4: - w_end = float(sys.argv[3]) - if len(sys.argv) == 5: - smearing = float(sys.argv[4]) - w = np.linspace(w_start, w_end, n_w) w_ry = w / CC.Units.RY_TO_CM smearing /= CC.Units.RY_TO_CM - if use_kpm: - # KPM spectral function does not use smearing parameter - spectrum = kpm.get_spectral_function_KPM(w_ry, regularization="jackson") - else: - gf = lanc.get_green_function_continued_fraction(w_ry, smearing = smearing, use_terminator=False) - spectrum = - np.imag(gf) + gf = lanc.get_green_function_continued_fraction( + w_ry, smearing=smearing, use_terminator=args.terminator, + last_average=args.last_average, smooth_ramp=args.smooth_ramp) + spectrum = -np.imag(gf) # Print some info about the calculation print() - if use_kpm: - print("Number of KPM moments: {}".format(kpm.kpm_n_moments)) - else: - print("Number of poles: {}".format(len(lanc.a_coeffs))) - + print("Number of poles: {}".format(len(lanc.a_coeffs))) + + plt.figure(dpi = args.dpi) plt.plot(w, spectrum) plt.xlabel("Frequency [cm-1]") plt.ylabel("Spectrum [a.u.]") + if args.title: + plt.title(args.title) plt.tight_layout() - plt.show() + + if args.save: + print("Saving the plot to {}".format(args.save)) + plt.savefig(args.save, dpi = args.dpi) + + if not args.no_show: + plt.show() def convert(): @@ -410,4 +450,3 @@ def tdscha_convergence_analysis(): plt.tight_layout() plt.show() - diff --git a/Modules/tdscha_core.jl b/Modules/tdscha_core.jl index 8c18125f..41c685c9 100644 --- a/Modules/tdscha_core.jl +++ b/Modules/tdscha_core.jl @@ -61,6 +61,27 @@ function create_sparse_matrix_from_symmetries(sym_info::SymmetriesInfo{T}) where return mysym end +function project_perturbation_average( + f_average::Vector{T}, d2v_dr2::Matrix{T}, + symmetries::Vector{SparseMatrixCSC{T,Int32}}, + stabilizer_indices::Vector{Int32}, characters::Vector{T} +) where {T<:AbstractFloat} + isempty(stabilizer_indices) && return f_average, d2v_dr2 + length(stabilizer_indices) == length(characters) || + error("one stabilizer character is required per symmetry") + + projected_f = zeros(T, length(f_average)) + projected_d2v = zeros(T, size(d2v_dr2)) + for (index, character) in zip(stabilizer_indices, characters) + symmetry = symmetries[index] + projected_f .+= character .* (symmetry * f_average) + projected_d2v .+= character .* ( + symmetry * d2v_dr2 * transpose(symmetry)) + end + scale = inv(T(length(stabilizer_indices))) + return projected_f .* scale, projected_d2v .* scale +end + function get_d2v_dR2_from_R_pert_sym_fast(ensemble::Ensemble{T}, symmetries::Vector{SparseMatrixCSC{T,Int32}}, temperature::T, R1::Vector{T}, ω_is::Vector{T}, start_index::Int64, end_index::Int64) where {T<: AbstractFloat} n_modes = length(ensemble.ω) n_configs = size(ensemble.X, 2) @@ -212,16 +233,21 @@ end function get_perturb_averages_sym(X::Matrix{T}, Y::Matrix{T}, ω::Vector{T}, rho::Vector{T}, R1::Vector{T}, Y1::Matrix{T}, temperature::T, apply_v4::Bool, symmetries::Array{T, 4}, n_degeneracies::Vector{Int32}, - degenerate_space::Matrix{Int32}, blocks::Vector{Int32}, start_index::Int64, end_index::Int64) where {T<:AbstractFloat} + degenerate_space::Matrix{Int32}, blocks::Vector{Int32}, start_index::Int64, end_index::Int64, + coset_indices::Vector{Int32}=Int32[], + stabilizer_indices::Vector{Int32}=Int32[], + characters::Vector{T}=T[]) where {T<:AbstractFloat} # Use cached sparse matrices if available, otherwise build them if _cached_symmetries[] !== nothing - new_symmetries = _cached_symmetries[] + full_symmetries = _cached_symmetries[] else sym_info = SymmetriesInfo(symmetries, n_degeneracies, degenerate_space, blocks) - new_symmetries = create_sparse_matrix_from_symmetries(sym_info) + full_symmetries = create_sparse_matrix_from_symmetries(sym_info) end + new_symmetries = isempty(coset_indices) ? full_symmetries : + full_symmetries[coset_indices] # Create the ensemble ensemble = Ensemble(X, Y, ω) @@ -234,7 +260,9 @@ function get_perturb_averages_sym(X::Matrix{T}, Y::Matrix{T}, ω::Vector{T}, rho d2v_dr2 += get_d2v_dR2_from_Y_pert_sym_fast(ensemble, new_symmetries, temperature, Y1, rho, start_index, end_index) end - return f_average, d2v_dr2 + return project_perturbation_average( + f_average, d2v_dr2, full_symmetries, + stabilizer_indices, characters) end diff --git a/Modules/tdscha_qspace.jl b/Modules/tdscha_qspace.jl index 868205f3..bbb2658b 100644 --- a/Modules/tdscha_qspace.jl +++ b/Modules/tdscha_qspace.jl @@ -462,6 +462,13 @@ For each (config, sym): 4. Compute buf_f_weight from buffer_u (shared by f_pert and d2v_v4) 5. Accumulate f_pert (2 terms) 6. Accumulate d2v with fused D3 + D4 weights in single inner loop + +The vertex renormalization factors scale3 and scale4 multiply the D3-type +(3-field) and D4-type (4-field) averages respectively. They implement the +N_c -> N_f mode-space vertex rescaling of the q-mesh interpolation +(d3 ~ N^-1/2, d4 ~ N^-1, see Interpolation_plan.md §6): + scale3 = sqrt(N_coarse / N_fine), scale4 = N_coarse / N_fine. +Both default to 1.0 (commensurate/no-interpolation behavior). """ function get_perturb_averages_qspace_fused( X_q::Array{ComplexF64,3}, @@ -478,7 +485,9 @@ function get_perturb_averages_qspace_fused( n_bands::Int64, n_q::Int64, start_index::Int64, - end_index::Int64 + end_index::Int64, + scale3::Float64=1.0, + scale4::Float64=1.0 ) n_pairs = size(unique_pairs, 1) n_syms = length(symmetries) @@ -518,13 +527,13 @@ function get_perturb_averages_qspace_fused( for nu in 1:n_bands weight_R += f_Y[nu, iq_pert] * conj(x_pert[nu]) * R1[nu] end - weight_R *= rho[i_config] / 3.0 + weight_R *= rho[i_config] / 3.0 * scale3 weight_Rf = zero(ComplexF64) for nu in 1:n_bands weight_Rf += R1[nu] * conj(y_pert[nu]) end - weight_Rf *= rho[i_config] / 3.0 + weight_Rf *= rho[i_config] / 3.0 * scale3 # === Step 3: D4 intermediates (buffer_u, total_sum) === total_sum = zero(ComplexF64) @@ -577,14 +586,14 @@ function get_perturb_averages_qspace_fused( end # === Step 5: Accumulate f_pert === - # Term 1: (-total_sum/2) * rho/3 * y_rot[q_pert] - w1 = -total_sum / 2.0 * rho[i_config] / 3.0 + # Term 1: (-total_sum/2) * rho/3 * y_rot[q_pert] (D3-type -> scale3) + w1 = -total_sum / 2.0 * rho[i_config] / 3.0 * scale3 for nu in 1:n_bands f_pert[nu] += w1 * y_pert[nu] end - # Term 2: (-buf_f_weight) * rho/3 * f_Y[nu,q_pert] * x_rot[q_pert,nu] - w2 = -buf_f_weight * rho[i_config] / 3.0 + # Term 2: (-buf_f_weight) * rho/3 * f_Y[nu,q_pert] * x_rot[q_pert,nu] (D3-type -> scale3) + w2 = -buf_f_weight * rho[i_config] / 3.0 * scale3 for nu in 1:n_bands f_pert[nu] += w2 * f_Y[nu, iq_pert] * x_pert[nu] end @@ -595,8 +604,8 @@ function get_perturb_averages_qspace_fused( total_wD4 = zero(ComplexF64) total_wb = zero(ComplexF64) if apply_v4 - total_wD4 = -total_sum * rho[i_config] / 8.0 - total_wb = -buf_f_weight * rho[i_config] / 4.0 + total_wD4 = -total_sum * rho[i_config] / 8.0 * scale4 + total_wb = -buf_f_weight * rho[i_config] / 4.0 * scale4 end # Combined weights for fused inner loop @@ -638,6 +647,57 @@ function get_perturb_averages_qspace_fused( return f_pert, d2v_blocks end +function project_perturbation_average_qspace( + f_pert::Vector{ComplexF64}, + d2v_blocks::Vector{Matrix{ComplexF64}}, + symmetries::Vector{SparseMatrixCSC{ComplexF64,Int32}}, + stabilizer_indices::Vector{Int32}, + characters::Vector{ComplexF64}, iq_pert::Int64, + unique_pairs::Matrix{Int32}, n_bands::Int64, n_q::Int64 +) + isempty(stabilizer_indices) && return f_pert, d2v_blocks + length(stabilizer_indices) == length(characters) || + error("one stabilizer character is required per symmetry") + + n_total = n_q * n_bands + full_f = zeros(ComplexF64, n_total) + gamma_range = (iq_pert - 1) * n_bands + 1:iq_pert * n_bands + full_f[gamma_range] .= f_pert + + full_d2v = spzeros(ComplexF64, n_total, n_total) + for (pair, block) in enumerate(d2v_blocks) + iq1 = unique_pairs[pair, 1] + iq2 = unique_pairs[pair, 2] + range1 = (iq1 - 1) * n_bands + 1:iq1 * n_bands + range2 = (iq2 - 1) * n_bands + 1:iq2 * n_bands + full_d2v[range1, range2] = block + if iq1 != iq2 + full_d2v[range2, range1] = transpose(block) + end + end + + projected_f = zeros(ComplexF64, n_total) + projected_d2v = spzeros(ComplexF64, n_total, n_total) + for (index, character) in zip(stabilizer_indices, characters) + symmetry = symmetries[index] + projected_f .+= character .* (symmetry * full_f) + projected_d2v = projected_d2v + character .* ( + symmetry * full_d2v * transpose(symmetry)) + end + projected_f ./= length(stabilizer_indices) + projected_d2v ./= length(stabilizer_indices) + + projected_blocks = Matrix{ComplexF64}[] + for pair in axes(unique_pairs, 1) + iq1 = unique_pairs[pair, 1] + iq2 = unique_pairs[pair, 2] + range1 = (iq1 - 1) * n_bands + 1:iq1 * n_bands + range2 = (iq2 - 1) * n_bands + 1:iq2 * n_bands + push!(projected_blocks, Matrix(projected_d2v[range1, range2])) + end + return projected_f[gamma_range], projected_blocks +end + """ get_perturb_averages_qspace(...) @@ -661,20 +721,35 @@ function get_perturb_averages_qspace( unique_pairs::Matrix{Int32}, start_index::Int64, end_index::Int64, - valid_modes_q::Matrix{Bool} # Mask from Python: false for acoustic/small-w modes + valid_modes_q::Matrix{Bool}, # Mask from Python: false for acoustic/small-w modes + scale3::Float64=1.0, # D3 vertex rescaling sqrt(N_c/N_f) for interpolation + scale4::Float64=1.0, # D4 vertex rescaling N_c/N_f for interpolation + prefiltered::Bool=false, # X_q fields already carry f_Y; f_psi folded in alpha1 + coset_indices::Vector{Int32}=Int32[], + stabilizer_indices::Vector{Int32}=Int32[], + characters::Vector{ComplexF64}=ComplexF64[] ) n_q = size(X_q, 1) n_bands = size(X_q, 3) n_pairs = size(unique_pairs, 1) # Get symmetries - symmetries = _cached_qspace_symmetries[] - if symmetries === nothing + full_symmetries = _cached_qspace_symmetries[] + if full_symmetries === nothing error("Q-space symmetries not initialized. Call init_sparse_symmetries_qspace first.") end + symmetries = isempty(coset_indices) ? full_symmetries : + full_symmetries[coset_indices] # Precompute occupation numbers and scaling factors # Masked modes (valid_modes_q == false) get f_Y=0, f_psi=0 to avoid NaN/Inf + # + # In prefiltered mode the X_q fields already carry the f_Y filter + # (applied on the COARSE grid, where it exactly strips the phonon + # propagator dressing of every displacement leg by the Gaussian + # integration-by-parts identity), and the fine-side f_psi factors are + # folded into the alpha1 blocks by the Python caller. Both tables then + # reduce to the validity mask. See Interpolation_plan.md section 5.6. f_Y = zeros(Float64, n_bands, n_q) f_psi = zeros(Float64, n_bands, n_q) @@ -685,6 +760,11 @@ function get_perturb_averages_qspace( f_psi[nu, iq] = 0.0 continue end + if prefiltered + f_Y[nu, iq] = 1.0 + f_psi[nu, iq] = 1.0 + continue + end w = w_q[nu, iq] if temperature > 0 nw = 1.0 / (exp(w * RY_TO_K_Q / temperature) - 1.0) @@ -708,7 +788,11 @@ function get_perturb_averages_qspace( f_pert, d2v = get_perturb_averages_qspace_fused( X_q, Y_q, f_Y, f_psi, rho, R1, alpha1_blocks, symmetries, apply_v4, iq_pert, unique_pairs, n_bands, n_q, - start_index, end_index) + start_index, end_index, scale3, scale4) + + f_pert, d2v = project_perturbation_average_qspace( + f_pert, d2v, full_symmetries, stabilizer_indices, characters, + iq_pert, unique_pairs, n_bands, n_q) # Pack result: f_pert followed by flattened d2v blocks result = zeros(ComplexF64, n_bands + n_pairs * n_bands^2) diff --git a/docs/api/qspace_atom_fourier.md b/docs/api/qspace_atom_fourier.md new file mode 100644 index 00000000..621f1b60 --- /dev/null +++ b/docs/api/qspace_atom_fourier.md @@ -0,0 +1,19 @@ +# QSpaceAtomFourier Module + +Atom-centred Fourier interpolation of the q-space TD-SCHA Lanczos d3 and d4 +operators. + +The constructor accepts the same `lo_to_split=None`, `"random"`, or explicit +three-vector convention as `QSpaceLanczos`. Commensurate fine points, +including Gamma, are pinned to that parent basis; noncommensurate points keep +CellConstructor's tensorial dipole--dipole interpolation. With +`ignore_effective_charges=True`, the harmonic interpolation omits both that +tail and its Gamma LO--TO limit, but the caller's dynamical matrix retains its +effective charges for IR response vertices. + +::: tdscha.QSpaceAtomFourier + options: + show_root_heading: true + members: + - QSpaceAtomFourierLanczos + - load_distributed_atom_fourier_tdscha diff --git a/docs/api/spectroscopy.md b/docs/api/spectroscopy.md new file mode 100644 index 00000000..00337d7e --- /dev/null +++ b/docs/api/spectroscopy.md @@ -0,0 +1,11 @@ +# Spectroscopy API Reference + +::: tdscha.Spectroscopy.Spectroscopy + +::: tdscha.Spectroscopy.EnsembleSource + +::: tdscha.Spectroscopy.RamanTensorPerturbation + +::: tdscha.Spectroscopy.IRPolarizationPerturbation + +::: tdscha.Spectroscopy.CartesianPerturbation diff --git a/docs/examples.md b/docs/examples.md index 3095a14c..42ce71df 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -159,6 +159,12 @@ np.savetxt("raman_unpolarized_total.dat", print("Total unpolarized Raman intensity computed") ``` +This example uses the raw-component +`prepare_unpolarized_raman(index=i)` convention. Equivalently, call +`prepare_raman(unpolarized=i)` and replace `prefactors` with +`[45, 7, 7, 7, 7, 7, 7]`; the resulting weighted channel intensities are +identical. + ## Example 4: StaticHessian Calculation Compute free energy Hessian for stability analysis: @@ -303,46 +309,6 @@ Use distributed loading when: - Running on 4+ MPI processes - Memory per process is limiting your calculation -### Distributed QSpaceLanczos with KPM - -```python -# distributed_kpm.py -import cellconstructor as CC -import sscha.Ensemble -from tdscha.QSpaceLanczos import load_distributed_tdscha -import tdscha.QSpaceKPM as QK - -# Load dynamical matrix -dyn = CC.Phonons.Phonons("final_dyn_", NQIRR) - -# Load with distributed configurations across MPI ranks -# The ensemble is loaded on master rank only, then distributed -qlanc = load_distributed_tdscha( - data_dir="ensemble/", - population_id=1, - dyn=dyn, - T=TEMPERATURE, - use_symmetries=True, - n_configs=N_CONFIGS -) - -# Prepare perturbation -iq = 0 # Gamma point -band = 5 # Mode index -qlanc.prepare_mode_q(iq, band) - -# Create KPM from distributed Lanczos -kpm = QK.QSpaceKPM.from_qspace_lanczos(qlanc) -kpm.prepare_mode_q(iq, band) - -# Run KPM -n_moments = kpm.estimate_kpm_steps(precision_cm=50) -kpm.run_KPM(n_moments) - -# Save results -kpm.save_kpm("kpm_results.dat") -``` - ### Distributed Lanczos for IR/Raman ```python @@ -428,7 +394,7 @@ The normalization is handled automatically: # Run with MPI (automatically distributes configurations) # The Julia extension (pip install juliacall) is picked up automatically, # no python-jl wrapper is needed. -mpirun -np 8 python distributed_kpm.py +mpirun -np 8 python distributed_ir.py ``` ### Memory Comparison @@ -443,7 +409,7 @@ For N=10000, n_q=10, n_bands=60, the memory per rank drops from ~460 MB to ~57 M ### Limitations - Serial execution (1 process): falls back to standard mode -- Currently implemented for QSpaceLanczos, QSpaceKPM, and QSpaceHessian +- Currently implemented for QSpaceLanczos and QSpaceHessian - Standard DynamicalLanczos does not yet support distributed loading ## Example 7: Convergence Analysis @@ -650,4 +616,4 @@ See the `Examples/Comparison/` directory for: And `Examples/example_IR_Raman_2p/` for: - `IR_UNPOL/` - Unpolarized IR with 1ph/2ph processes - `RAMAN_UNPOL/` - Unpolarized Raman calculations -- Complete README files with instructions \ No newline at end of file +- Complete README files with instructions diff --git a/docs/index.md b/docs/index.md index 22ca87d2..ea8a1c2b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,7 @@ Part of the SSCHA ecosystem: `cellconstructor` → `python-sscha` → `tdscha`. 5. **[CLI Tools](cli.md)** - Command-line interface for analysis and visualization. 6. **API Reference** - Automatically generated documentation: + - [Spectroscopy](api/spectroscopy.md) - Stable Raman and IR calculations - [DynamicalLanczos](api/dynamical_lanczos.md) - Core Lanczos algorithm - [QSpaceLanczos](api/qspace_lanczos.md) - Q-space (Bloch basis) Lanczos algorithm - [StaticHessian](api/static_hessian.md) - Free energy Hessian calculations @@ -27,6 +28,8 @@ Part of the SSCHA ecosystem: `cellconstructor` → `python-sscha` → `tdscha`. - **Full quantum treatment of atomic nuclei** - **Parallel execution** with MPI - **Symmetry-aware** calculations for efficiency +- **Restartable polarized and unpolarized Raman/IR workflows** with automatic + symmetry-orbit and stabilizer/coset reduction - **Q-space Lanczos**: Bloch-basis formulation exploiting momentum conservation for large supercells (see [In-Depth Usage](usage.md#q-space-lanczos)) ## Theoretical Foundation diff --git a/docs/kpm.md b/docs/kpm.md deleted file mode 100644 index df6940a1..00000000 --- a/docs/kpm.md +++ /dev/null @@ -1,59 +0,0 @@ -# Kernel Polynomial Method - -The Kernel Polynomial Method (KPM) is an alternative algorithm to compute the dynamical linear response in TD-SCHA. It uses Chebyshev polynomials to express the spectral function, which provides a different trade-off compared to the standard Lanczos algorithm. - -## Why KPM? - -KPM never suffers from loss of orthogonality in the basis vectors. The Chebyshev polynomials are orthonormal by construction, so the computed spectral function does not develop spurious artifacts from numerical roundoff even for large numbers of steps. This makes KPM particularly reliable for systems where the Lanczos algorithm might accumulate numerical errors. - -The price to pay is that KPM typically requires more steps than Lanczos to achieve the same spectral resolution, since the Chebyshev basis is generic rather than optimized for the specific spectral features of the problem. - -## Spectral Bounds - -KPM requires specifying the bounds of the eigenvalue spectrum of the Liouvillian operator. These bounds must enclose all eigenvalues, otherwise the method fails to converge to the correct spectral function. The bounds are specified through a `bound_factor` parameter that controls how much the KPM bounds extend beyond the estimated spectral width. - -The relationship between frequency ω and Liouvillian eigenvalue λ depends on the Wigner formalism used. In Wigner mode, λ = -ω², so the eigenvalues are negative and bounded above by zero. The spectral width scales as (2ω_max)², where ω_max is the maximum phonon frequency in the system. - -A `bound_factor = 1.2` means the KPM bounds extend 20% beyond the theoretical spectral width. The default value of 1.2 works well in practice: it is wide enough to safely enclose all spectral features, while being tight enough to maintain good resolution. - -## Frequency Resolution - -The number of steps N determines the frequency resolution through the Jackson kernel. The frequency resolution Δω scales as: - -Δω ≈ π × rescale_a / (2 × ω_min × N) - -where ω_min is the smallest phonon frequency at the perturbation q-point, and rescale_a is the half-width of the normalized eigenvalue interval. Larger bound factors increase rescale_a, which in turn requires more steps to achieve the same resolution. - -After preparing a perturbation, you can estimate the required steps for a target precision using the `estimate_kpm_steps` method: - -```python -kpm = QSpaceKPM.QSpaceKPM(ensemble) -kpm.init() -kpm.prepare_mode_q(0, 5) - -# Estimate steps for 2 cm^-1 frequency precision -n_steps = kpm.estimate_kpm_steps(precision_cm=2.0, bound_factor=1.2) -kpm.run_KPM(n_steps) -``` - -## A Practical Example - -```python -import tdscha.QSpaceKPM as QKPM - -# Setup -kpm = QKPM.QSpaceKPM(ensemble) -kpm.init() -kpm.prepare_mode_q(iq=0, band_index=5) - -# Estimate and run -n_steps = kpm.estimate_kpm_steps(1.0) # 1 cm^-1 precision -kpm.run_KPM(n_steps) - -# Compute the spectral function -w_cm = np.linspace(0, 200, 1000) -w_ry = w_cm / CC.Units.RY_TO_CM -spectral = kpm.get_spectral_function_KPM(w_ry, regularization="jackson") -``` - -Values of `bound_factor` larger than 1.5 can introduce visible baseline artifacts because the Jackson kernel produces broader peaks when the normalized eigenvalue range is larger. If you need larger bounds for safety, increase the number of steps to compensate. diff --git a/docs/spectroscopy.md b/docs/spectroscopy.md new file mode 100644 index 00000000..d9d9ff4b --- /dev/null +++ b/docs/spectroscopy.md @@ -0,0 +1,230 @@ +# Raman and IR spectroscopy + +`tdscha.Spectroscopy.Spectroscopy` is the stable API for one-phonon Raman +and IR calculations. It owns request planning, symmetry reduction, execution, +restart files, and spectrum assembly. The lower-level +`DynamicalLanczos.prepare_raman` and `prepare_ir` methods remain available so +existing scripts keep working, but new calculations should use this class. + +## Where the ensemble comes from + +The driver takes the **location** of the ensemble, not a loaded one: + +```python +from tdscha.Spectroscopy import EnsembleSource + +source = EnsembleSource( + data_dir="ensemble_data", + population=3, + dyn="dyn_gen_pop3_", nqirr=8, # the generating dynamical matrix + T=250.0, + final_dyn="dyn_end_", final_nqirr=8, # the converged solution + final_T=250.0, + n_configs=None, # None reads the whole population +) +``` + +Under `mpirun` the `qspace` and `atom_fourier` backends then read the +configurations **once, on the master**, and scatter them: every rank keeps +only `N / n_procs` of the Bloch-transformed displacements and forces. A +160 000-configuration ensemble therefore never exists more than once, which +is what makes it runnable at all. Nothing else is distributed — the +dynamical matrices are small and every rank holds them, because the run +plan, the symmetry analysis, and the spectral assembly all need them. + +`final_dyn` is the reference of the whole calculation: the ensemble is +reweighted onto it, and its Raman tensor, Born effective charges, and +dielectric tensor define the optical vertices. Production runs should always +set it. + +A loaded `sscha.Ensemble.Ensemble` is still accepted and behaves as before — +replicated on every rank (a warning says so when a q-space backend gets one +under `mpirun`). That is the right thing for small systems and it is what +`backend="real"` needs, since the real-space Lanczos parallelizes by splitting +a replicated ensemble across ranks. Passing an `EnsembleSource` to +`backend="real"` therefore loads the ensemble on every rank, by design. + +## Polarized and unpolarized requests + +```python +import numpy as np +from tdscha.Spectroscopy import Spectroscopy + +spectra = Spectroscopy( + source, + backend="qspace", # real, qspace, or atom_fourier + workdir="optical_spectroscopy", + ignore_v3=False, + ignore_v4=False, + lo_to_split=None, # None, "random", or a 3-vector +) + +# Raman: incoming and outgoing optical polarization vectors. +spectra.add_raman_polarized([1, 0, 0], [0, 1, 0], name="raman_xy") +spectra.add_raman_unpolarized(name="raman_powder") + +# IR: electric-field direction or Cartesian powder average. +spectra.add_ir_polarized([1, 0, 0], name="ir_x") +spectra.add_ir_unpolarized(name="ir_powder") + +print(spectra.plan_calculations()) +spectra.run(200, save_each=10) +``` + +`Spectroscopy.from_ensemble_path` is the shorthand that builds the +`EnsembleSource` for you: + +```python +spectra = Spectroscopy.from_ensemble_path( + "ensemble_data", 3, "dyn_gen_pop3_", 250.0, nqirr=8, + final_dyn="dyn_end_", final_nqirr=8, + backend="qspace", workdir="optical_spectroscopy", +) +``` + +The polarization vectors are normalized by the API. Equilibrium Raman +derivatives, Born effective charges, and the electronic dielectric tensor are +read from the reference dynamical matrix (`spectra.reference_dyn`, i.e. the +ensemble's `current_dyn`: `final_dyn` when the ensemble is reweighted). +`effective_charges=` can be passed to either IR request when an explicit +override is needed. + +For `atom_fourier`, the interpolation mesh is a backend option: + +```python +spectra = Spectroscopy( + source, backend="atom_fourier", workdir="raman_interpolated", + backend_options={"fine_mesh": (8, 6, 8)}, +) +``` + +`ignore_v3` and `ignore_v4` are explicit backend-independent physics +switches. The former `backend_options={"ignore_v3": ..., "ignore_v4": ...}` +spelling remains accepted for existing scripts. For `backend="atom_fourier"`, +`lo_to_split` also fixes the nonanalytic Gamma direction while the tensorial +dipole correction is retained on the interpolated mesh. When +`ignore_effective_charges=True`, that harmonic interpolation instead omits the +entire dipolar correction, including its Gamma LO--TO limit. The original +dynamical matrix is not modified, so its effective charges remain available +for constructing IR perturbations. + +For data that are already contracted, use `add_raman_vector` or +`add_ir_vector`. An explicit IR vector needs `electronic_projection=` when +constructing a dielectric function because its electric-field direction is +not recoverable from the vector alone. + +## Symmetry reduction + +`use_symmetries=True` is the default. The planner: + +1. excludes crystal rotations that do not preserve the finite supercell mesh; +2. groups symmetry-equivalent requested perturbations, so cubic `x`, `y`, and + `z` IR requests require one Lanczos run; +3. finds the sign-aware stabilizer of each independent perturbation; +4. evaluates anharmonic ensemble averages only on right-coset + representatives and applies the stabilizer projector to each returned + force and second-derivative contribution. + +An exactly zero contracted optical vertex is a valid symmetry-forbidden +component. It is recorded in the request reconstruction map as an exact zero +and does not start an undefined zero-norm Lanczos recursion. + +The last step is implemented in the shared Lanczos operator path and is used +by the real-space, q-space, and atom-Fourier backends. Set +`use_symmetries=False` for a direct unsymmetrized reference calculation. + +For a manually prepared finite-q `QSpaceLanczos` calculation, +`configure_qspace_perturbation_symmetry()` detects the little group in the +actual complex Bloch-mode representation. It supports complex characters at +non-time-reversal-invariant q. The optical workflow uses the Gamma-specific +`configure_spectroscopy_symmetry()` automatically. + +## Restart and load-only analysis + +`run(n_steps, ...)` interprets `n_steps` as the total target, including an +existing checkpoint. A compatible calculation resumes automatically. + +```python +# Continue an interrupted run from 100 to 200 coefficients. +spectra.run(200, save_each=10, resume=True) + +# Analyze on a machine that does not hold the original ensemble. +loaded = Spectroscopy.load("optical_spectroscopy") +``` + +Each independent perturbation stores native restart state, a backend-neutral +result, and (for Lanczos backends) a portable `.abc` file. Manifest +fingerprints reject restarts made with different ensembles, requests, backend +options, or run options. The manifest records both a fingerprint of the +reference dynamical matrix and temperature, and the identity of the ensemble +on disk (`data_dir`, population, `n_configs`, whether it was reweighted), so a +restart pointed at a different population is refused rather than silently +mixed. + +The engine is built once per `run()` and reused for every independent +perturbation: preparing a perturbation resets the whole Lanczos state, and +reading a production ensemble is minutes of I/O that must not be repeated per +run. It is built lazily, so re-running a finished calculation for analysis +reloads nothing. + +## Raman spectra + +Frequencies use TD-SCHA internal Rydberg units. + +```python +omega = np.linspace(1e-5, 0.01, 1000) +analysis = dict(smearing=2e-5, use_terminator=False) + +response = loaded.raman_spectrum( + "raman_powder", omega, kind="response", **analysis) +stokes = loaded.raman_spectrum( + "raman_powder", omega, kind="stokes", **analysis) +anti_stokes = loaded.raman_spectrum( + "raman_powder", omega, kind="anti_stokes", **analysis) +``` + +The default unpolarized convention is the normalized seven-component Placzek +invariant with weights `[45, 7, 7, 7, 7, 7, 7]`. The legacy raw convention is +available as `convention="raw"`; both conventions assemble the same total. +Supplying `laser_frequency=` additionally applies the scattered-frequency +fourth-power factor. + +## IR susceptibility and dielectric function + +```python +chi_ionic = loaded.ir_susceptibility("ir_x", omega, **analysis) +epsilon_x = loaded.dielectric_function( + "ir_x", omega, + **analysis, +) +epsilon_powder = loaded.dielectric_function( + "ir_powder", omega, + **analysis, +) +``` + +The default projected dielectric result is +`epsilon_infinity + (4*pi/Omega) * chi_ionic`, with `Omega` the **supercell** +volume converted from Angstrom cubed to Bohr cubed. `ir_susceptibility` +includes the factor of two that converts the raw Rydberg-convention Lanczos +Green function to Hartree atomic units. The Lanczos perturbation carries +`sqrt(n_cell)` (`prepare_ir`), so `chi_ionic` already includes the `n_cell` +factor and `Omega` must be the supercell volume +`n_cell * V_unit_cell`; together the total prefactor is `8*pi/V_supercell`, +matching the CellConstructor non-analytic LO-TO term (the 8 is the Rydberg +`e^2 = 2`). `ionic_prefactor=` can override the remaining `4*pi/Omega` +convention explicitly. +The full 3x3 `reference_dyn.dielectric_tensor` is stored in the +manifest and inferred during both live and load-only analysis. Polarized IR +uses `e.T @ epsilon_infinity @ e`; an unpolarized request uses +`trace(epsilon_infinity)/3`. Passing `epsilon_infinity=` remains an explicit +tensor override. + +## Two-phonon optical vertices + +The former configuration-dependent two-phonon Raman entry points are kept as +compatibility names but deliberately raise `NotImplementedError`. Their old +source is archived privately in `_TwoPhononRamanLegacy.py` and is neither +installed nor imported. It must not be used for production spectra until its +observable definition, units, and symmetry properties are independently +validated. diff --git a/docs/usage.md b/docs/usage.md index 0315fac9..869c99f9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,6 +2,13 @@ ## Choosing Perturbation Types +!!! tip "Use the Spectroscopy driver for Raman and IR" + + The restartable, symmetry-reduced API for new optical calculations is + documented in [Raman and IR spectroscopy](spectroscopy.md). The direct + `Lanczos.prepare_raman` and `prepare_ir` calls below are compatibility + primitives for existing scripts. + TD-SCHA supports three main perturbation types, each with specific use cases: ### 1. Single Phonon Mode @@ -71,6 +78,11 @@ for i in range(7): lanczos.save_status(f"raman_unpolarized_{i}.npz") ``` +!!! warning "Raman data produced before the 1.7 hotfix" + + Saved `prepare_raman(unpolarized=i)` calculations for channels 0–3 used + incomplete diagonal combinations and must be recomputed. Channels 4–6 + were unaffected and can be reused. Then you can plot the unpolarized Raman spectrum by summing the contributions of the 7 components. This is done in the following way: @@ -86,8 +98,9 @@ w_ry = w/CC.Units.RY_TO_CM # Convert in Ry (the internal unit of tdscha) smearing = 2/CC.Units.RY_TO_CM # Smearing in cm⁻¹ raman_signal = np.zeros_like(w) +weights = [45, 7, 7, 7, 7, 7, 7] -# Load the 7 unpolarized Raman components and sum them. +# Load and combine the seven normalized invariant components. for i in range(7): lanczos = DL.Lanczos() lanczos.load_status(f"raman_unpolarized_{i}.npz") @@ -97,7 +110,7 @@ for i in range(7): # The response is proportional to the imaginary part of the Green's function. # The '-' sign selects the retarded response, which is the one relevant for Raman scattering. - raman_signal += -np.imag(gf) + raman_signal += weights[i] * -np.imag(gf) # Then, we can just plot the data @@ -107,6 +120,14 @@ plt.ylabel("Unpolarized Raman Intensity (arb. units)") plt.show() ``` +`prepare_raman(unpolarized=i)` prepares normalized invariants and therefore +uses weights `[45, 7, 7, 7, 7, 7, 7]`. The legacy +`prepare_unpolarized_raman(index=i)` method prepares the corresponding raw +Cartesian combinations. When using that API, multiply each spectrum by +`lanczos.get_prefactors_unpolarized_raman(i)`, currently +`[5, 7/2, 7/2, 7/2, 21, 21, 21]`. The two conventions give the same total +unpolarized intensity. + ## Parallel Execution Modes TD-SCHA supports four computation modes: @@ -271,6 +292,61 @@ gf = qlanc.get_green_function_continued_fraction(w, smearing=smearing) spectral = -np.imag(gf) ``` +### Atom-Fourier interpolation + +Use `QSpaceAtomFourierLanczos` when the ensemble supercell provides a coarse +q mesh but the internal two-phonon integration needs a finer mesh. The same +atom-centred Fourier map interpolates both d3 and d4; there is no interpolation +strategy flag to select. + +```python +import tdscha.QSpaceAtomFourier as AF + +qlanc = AF.QSpaceAtomFourierLanczos( + ens, + fine_mesh=(8, 8, 8), # integer multiple of dyn.GetSupercell() +) +qlanc.init(use_symmetries=True) + +# External perturbations must remain on the coarse ensemble mesh. +iq = qlanc.find_fine_q(dyn.q_tot[0]) +qlanc.prepare_mode_q(iq, band_index=3) +qlanc.run_FT(100) +``` + +The interpolation uses the full cell metric, so odd/even, anisotropic, and +non-orthogonal meshes follow the same API. It preserves commensurate values, +minimum-image Nyquist ties, d3/d4 permutation symmetry, and the adjoint +relation between folding and reconstruction. + +By default, Born effective charges and the dielectric tensor participate in +the harmonic dynamical-matrix interpolation. Set +`ignore_effective_charges=True` only when the ensemble forces came from a +strictly short-range potential and those values are inherited metadata. The +input dynamical matrix is never modified. + +For a configuration-distributed MPI calculation: + +```python +lanczos = AF.load_distributed_atom_fourier_tdscha( + "ensemble_dir", population_id=1, dyn=dyn, T=300, + fine_mesh=(8, 8, 8), + final_dyn=final_dyn, final_T=300, +) +``` + +The master reads the ensemble and scatters the configurations, so no rank ever +holds a replica. The harmonic interpolation is the one part every rank runs +together — it broadcasts inside CellConstructor's `ForceTensor`, and a +master-only build would leave the workers in a different collective. It is +handled by `QSpaceAtomFourierLanczos.prepare_distributed_construction()`, which +the loader calls before the master/worker split; nothing needs to be passed for +it. + +For Raman and IR the same loading happens automatically through +`Spectroscopy(EnsembleSource(...), backend="atom_fourier", ...)`; see +[Raman and IR spectroscopy](spectroscopy.md). + ### Choosing the Perturbation #### Single mode at a q-point diff --git a/julia_design.md b/julia_design.md index 2067a0f8..1bc117e9 100644 --- a/julia_design.md +++ b/julia_design.md @@ -310,7 +310,6 @@ They would fail identically on the unpatched branch. | `test_qspace_anharmonic_invariants.py::TestDiagonalD2vHermitian::test_d4_only_mixed` | same | | `test_qspace_anharmonic_invariants.py::TestFlagGating::test_R1_zero_gives_d4_only_off_diagonal` | D3/D4 flag gating inconsistent for off-diagonal pairs | | `test_qspace_hessian_1d.py::test_compare_real_vs_qspace_hessian` | real-space vs q-space Hessian mismatch | -| `test_qspace_kpm.py::test_qspace_kpm_physics_regression` | KPM physics regression value off | These are physics-invariant checks on `get_perturb_averages_qspace` (`tdscha_qspace.jl`) with synthetic inputs: with purely real inputs the @@ -324,8 +323,7 @@ Evidence that these failures pre-date the migration and are independent of it: legacy backend by forcing `SSCHA_JULIA_BACKEND=pyjulia`: `TestFpertReality::test_with_off_diagonal` fails with the **bit-identical** value `Im = 1.3502495550836942` under both pyjulia and juliacall, and - `test_qspace_hessian_1d.py` / `test_qspace_kpm.py` fail identically under - pyjulia as well (re-run: `2 failed, 2 passed`, same tests). If the bridge's + `test_qspace_hessian_1d.py` fails identically under pyjulia as well. If the bridge's argument/return conversion were corrupting data, the two backends — which use completely different conversion machinery (PyCall copy vs `juliacall.convert` + `np.asarray`) — would not agree to the last bit. @@ -334,11 +332,10 @@ Evidence that these failures pre-date the migration and are independent of it: *"Suspect #3: `f_pert` D3 Contribution from Off-Diagonal Pairs"* — the D3 accumulation `f_pert += w1 * y_pert; f_pert += w2 * f_Y[:, iq_pert] * x_pert` picks up a spurious imaginary part through the off-diagonal pair pathway - (and a second pathway via Suspect #1 contaminates the Hessian/KPM results, - which is consistent with the `test_qspace_hessian_1d` and `test_qspace_kpm` - failures). + (and a second pathway via Suspect #1 contaminates the Hessian results, + which is consistent with the `test_qspace_hessian_1d` failure). 3. **The failing test files are themselves part of that debugging effort**: - `tests/test_qspace/test_qspace_anharmonic_invariants.py` and the gold/KPM + `tests/test_qspace/test_qspace_anharmonic_invariants.py` and the gold regression tests are untracked, in-flight files written to pin the bug down; they are not part of any previously green CI state. diff --git a/meson.build b/meson.build index e5c95522..eee17cdf 100644 --- a/meson.build +++ b/meson.build @@ -132,13 +132,16 @@ py.install_sources( 'Modules/__init__.py', 'Modules/DynamicalLanczos.py', 'Modules/QSpaceLanczos.py', - 'Modules/QSpaceKPM.py', 'Modules/QSpaceHessian.py', + 'Modules/QSpaceInterpolation.py', + 'Modules/QSpaceAtomFourier.py', 'Modules/Dynamical.py', 'Modules/JuliaExt.py', 'Modules/Parallel.py', 'Modules/Perturbations.py', 'Modules/StaticHessian.py', + 'Modules/Spectroscopy.py', + 'Modules/_SpectroscopyWorkflow.py', 'Modules/tdscha_core.jl', 'Modules/tdscha_qspace.jl', 'Modules/juliapkg.json', @@ -148,6 +151,17 @@ py.install_sources( subdir: 'tdscha' ) +# Test-data helpers used by the doctests in docs/. They are part of the +# package because scripts/validate_docs.py imports them from the *installed* +# tdscha, not from the source tree. +py.install_sources( + [ + 'Modules/testing/__init__.py', + 'Modules/testing/test_data.py' + ], + subdir: 'tdscha/testing' +) + install_data( 'Modules/tdscha_core.jl', install_dir: py.get_install_dir() / 'Modules' diff --git a/mkdocs.yml b/mkdocs.yml index 34d6fae4..77451437 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,13 +44,15 @@ nav: - Installation: installation.md - Quick Start: quickstart.md - In-Depth Usage: usage.md + - Raman and IR Spectroscopy: spectroscopy.md - StaticHessian: static-hessian.md - QSpaceHessian: qspace-hessian.md - - Kernel Polynomial Method: kpm.md - CLI Tools: cli.md - API Reference: + - Spectroscopy: api/spectroscopy.md - DynamicalLanczos: api/dynamical_lanczos.md - QSpaceLanczos: api/qspace_lanczos.md + - QSpaceAtomFourier: api/qspace_atom_fourier.md - QSpaceHessian: api/qspace_hessian.md - StaticHessian: api/static_hessian.md diff --git a/plan_polish.md b/plan_polish.md new file mode 100644 index 00000000..13d6709d --- /dev/null +++ b/plan_polish.md @@ -0,0 +1,66 @@ +# Atom-Fourier interpolation polish plan + +This checklist is the source of truth for preparing the pull-request branch. +It must be updated whenever a task starts or finishes. + +## Scope + +- Keep a single interpolation strategy: `atom_fourier`. +- Support both third- and fourth-order tensors (`d3` and `d4`). +- Share mesh, Fourier, validation, and reconstruction helpers wherever + the tensor orders have the same mathematical operation. +- Remove experimental APIs, implementations, tests, and documentation from the + pull-request branch. They remain recoverable from + `backup/qspace-interpolation-attempts` at commit `0ceecd59`. +- Do not add report outputs, datasets, PDFs, caches, or generated test files. + +## TODO + +- [x] Preserve the pre-polish source and tests in a snapshot commit. +- [x] Create `backup/qspace-interpolation-attempts` at the snapshot. +- [x] Create and switch to the `atom-fourier-interpolation` branch. +- [x] Inventory interpolation implementations, public entry points, call sites, + and tests. +- [x] Define the smallest user-facing API for `atom_fourier` d3/d4 + interpolation, including validation and error messages. +- [x] Refactor common mesh, centered-image, Fourier-transform, + and reconstruction logic into order-independent helpers. +- [x] Retain only the `atom_fourier` implementation and remove obsolete + interpolation modes, aliases, branches, and dead helpers. +- [x] Update callers to use the polished API without experimental switches. +- [x] Replace experiment-oriented tests with focused d3/d4 correctness tests. +- [x] Cover edge cases: identity mesh, anisotropic meshes, odd/even meshes, + Nyquist ties, complex phases, tensor permutation symmetry, invalid shapes, + and non-commensurate meshes. +- [x] Add concise user-facing API documentation and examples. +- [x] Run targeted interpolation tests and relevant Q-space regressions. +- [x] Review the complete diff for dead code, generated files, accidental report + changes, naming consistency, and minimality. +- [x] Record final verification commands and results below. + +## Verification record + +- `pytest -q tests/test_atom_fourier tests/test_interpolation/test_mesh_and_dyn.py + tests/test_interpolation/test_ignore_effective_charges.py`: 36 passed. +- The same focused run plus restart and distributed-loader tests: 41 passed. +- `pytest -q tests/test_qspace`: 50 passed, 7 skipped. +- `meson setup --reconfigure build && meson compile -C build`: passed. +- `python scripts/validate_docs.py docs/usage.md + docs/api/qspace_atom_fourier.md`: passed. +- `python -m py_compile` on the interpolation modules: passed. +- `git diff --check`: passed. + +## Inventory and API decision + +- Remove the windowed, stochastic-centering, tensor, factor-kernel, + piecewise-trilinear, atomic-phase, and separable-Nyquist implementations. +- Keep `generate_fine_mesh`, mesh-index lookup, and harmonic dynamical-matrix + Fourier interpolation as shared infrastructure. +- Expose one implementation as + `tdscha.QSpaceAtomFourier.QSpaceAtomFourierLanczos`; atom-Fourier and the + true cell-metric image assignment are unconditional, so users do not need + strategy flags. +- Expose distributed construction as + `load_distributed_atom_fourier_tdscha`. +- Keep d3/d4 on the same fold/coarse-kernel/adjoint-unfold path, with the + existing order-specific mesh normalization factors. diff --git a/raman_plan.md b/raman_plan.md new file mode 100644 index 00000000..ae17c6ba --- /dev/null +++ b/raman_plan.md @@ -0,0 +1,529 @@ +# Stable, symmetry-aware Raman and IR API + +## Decision + +Introduce a new high-level `tdscha.Spectroscopy` class, analogous in role to +`StaticHessian` and `QSpaceHessian`. It owns optical perturbations, symmetry +reduction, independent Lanczos runs, checkpoints, loading, and spectrum +assembly. `DynamicalLanczos.Lanczos` and its q-space derivatives remain +numerical engines; they will no longer be the recommended user-facing Raman +or IR API. + +Only equilibrium one-phonon Raman and IR perturbations will be supported in +the stable API initially. The configuration-dependent/two-phonon Raman code +is not trustworthy enough to expose. It will be copied to a private legacy +module as a source backup, excluded from imports, and every old public entry +point that could invoke it will raise `NotImplementedError` with a direct +explanation. Git history remains the authoritative history of the removed +implementation. + +This supersedes the earlier plan to add more selectors directly to +`prepare_raman`. Existing correct scripts must keep working through small +compatibility wrappers, but new scripts should use `Spectroscopy`. + +## Physical conventions retained + +For a symmetric, non-resonant Raman tensor, the unpolarized response is + +\[ +I = 45\,\bar\alpha^2 + 7\,\gamma^2, +\qquad +\bar\alpha = (R_{xx}+R_{yy}+R_{zz})/3, +\] + +\[ +\gamma^2 = \frac{1}{2}\left[(R_{xx}-R_{yy})^2 ++(R_{xx}-R_{zz})^2+(R_{yy}-R_{zz})^2\right] ++3(R_{xy}^2+R_{xz}^2+R_{yz}^2). +\] + +The canonical component convention remains the normalized convention already +used by the CsSnI3 production scripts: + +| Component | Perturbation | Response weight | +|---:|---|---:| +| 0 | `(xx + yy + zz) / 3` | 45 | +| 1 | `(xx - yy) / sqrt(2)` | 7 | +| 2 | `(xx - zz) / sqrt(2)` | 7 | +| 3 | `(yy - zz) / sqrt(2)` | 7 | +| 4 | `sqrt(3) xy` | 7 | +| 5 | `sqrt(3) xz` | 7 | +| 6 | `sqrt(3) yz` | 7 | + +The legacy raw convention is retained only by the old +`prepare_unpolarized_raman(index=...)` wrapper with weights +`[5, 7/2, 7/2, 7/2, 21, 21, 21]`. + +The stable Raman result API distinguishes: + +- `response`: the Bose-free retarded response, proportional to `-Im G`; +- `stokes`: response multiplied by `n_B(omega, T) + 1`; +- `anti_stokes`: response multiplied by `n_B(omega, T)`; +- optional experimental photon-frequency prefactors, only when the incident + laser frequency and the desired unit convention are explicitly supplied. + +The stable IR API returns the ionic susceptibility first. A dielectric result +is constructed as `epsilon(omega) = epsilon_infinity + epsilon_ionic(omega)`. +The implementation must not hard-code the volume, `4 pi`, charge, or frequency +conversion until the existing internal units have been audited and checked +against a harmonic reference. Absorption and optical conductivity are +derived from that complex dielectric function with an explicit unit system. + +## Public API + +The intended workflow is: + +```python +import tdscha.Spectroscopy as SP + +job = SP.Spectroscopy( + ensemble, + backend="qspace", # "real", "qspace", or "atom_fourier" + workdir="spectroscopy", + ignore_v3=False, + ignore_v4=False, + lo_to_split=None, +) + +job.add_raman_polarized( + incoming=[1, 0, 0], + outgoing=[0, 1, 0], + name="raman_xy", +) +job.add_raman_unpolarized(name="raman_unpolarized") +job.add_ir_polarized(direction=[1, 0, 0], name="ir_x") +job.add_ir_unpolarized(name="ir_unpolarized") + +job.run(n_steps=500, save_each=10, resume=True) + +raman = job.raman_spectrum( + "raman_unpolarized", frequencies, + kind="stokes", smearing=5.0, +) +epsilon = job.dielectric_function( + "ir_unpolarized", frequencies, + smearing=5.0, +) +``` + +Additional stable perturbation constructors are: + +```python +job.add_raman_tensor(tensor, name=...) +job.add_ir_polarized(direction, effective_charges=..., name=...) +job.add_cartesian_perturbation(vector, observable=..., name=...) +``` + +`add_raman_tensor` accepts a symmetric 3x3 coefficient tensor `C` and prepares +`sum_ab C[a,b] d alpha_ab / dR`; it is the unambiguous replacement for +`mixed=True` and the various `pol_in_2`/`pol_out_2` arguments. A polarized +Raman request is converted to this representation internally. Antisymmetric +or resonant Raman tensors are rejected in the first release because their +rotational invariants differ. + +The electronic background defaults to the full 3x3 +`ensemble.current_dyn.dielectric_tensor`, which is persisted for load-only +analysis. Polarized IR projects it as `e.T @ epsilon @ e`; unpolarized IR +uses `trace(epsilon)/3`. `epsilon_infinity=` remains an explicit tensor +override. The public constructor exposes `ignore_v3`, `ignore_v4`, and the +common `lo_to_split` convention (`None`, `"random"`, or a direction). +Atom-Fourier pins commensurate modes to the parent LO--TO basis and retains +the tensorial long-range correction between coarse points. If +`ignore_effective_charges=True`, only the harmonic interpolation drops the +dipolar tail and LO--TO limit; the original dynamical matrix keeps its +effective charges so IR perturbations remain available. + +Each `add_*` method returns an immutable perturbation identifier. Names must +be unique and filesystem-safe. The driver expands aggregate requests such as +unpolarized Raman/IR into symmetry orbits of elementary perturbations, but the +manifest records both the requested observable and every actual Lanczos run. + +## Internal data model + +Create `Modules/Spectroscopy.py` with immutable specifications: + +- `PerturbationKind`: `RAMAN`, `IR`, `CARTESIAN`; +- `RamanTensorPerturbation`: symmetric 3x3 optical coefficient tensor; +- `IRPolarizationPerturbation`: normalized electric-field direction; +- `CartesianPerturbation`: explicit unit-cell Cartesian vector; +- `PerturbationOrbit`: representative, equivalent members, group mappings, + stabilizer, coset representatives, and reconstruction weights; +- `SpectroscopyResult`: loaded coefficients plus immutable manifest metadata; +- `Spectroscopy`: orchestration, execution, restart, and analysis. + +The definitions of the seven normalized Raman components and their weights +live once in this module. Lanczos compatibility wrappers import or delegate +to the same definitions; no second conditional implementation remains. + +The driver creates one fresh backend object per inequivalent perturbation. +Lanczos objects are not reused between channels because their recursion and +restart state are mutable. Backend creation is isolated behind a small +factory so tests can use a fake engine and real/q-space/interpolation can +share orchestration. + +## Symmetry design + +### Observable action + +Use the crystallographic point group of the unit cell. For Cartesian +rotation `R`: + +- IR directions transform as `e -> R e`; +- Raman coefficient tensors transform as `C -> R C R^T`; +- explicit atomic Cartesian vectors transform with the atom permutation and + Cartesian rotation used by the selected backend. + +Two requested perturbations are symmetry-equivalent only when a stored group +operation maps their complete prepared vectors within a numerical tolerance. +Comparing only labels (`x`, `y`, `z`) is forbidden. The map may include a +known scalar phase/sign; reconstruction records that character explicitly. +For diagonal scalar response functions, sign-related vectors have the same +response, but cross responses must retain the sign/phase. + +This reduces cubic unpolarized IR from three runs to one. For unpolarized +Raman it normally leaves one trace representative, one diagonal-deviatoric +representative, and one off-diagonal representative, rather than seven runs. +Lower-symmetry systems automatically retain more representatives. + +### Stabilizer and cosets inside `apply_anharmonic_FT` + +Let `G` be the symmetry group used to symmetrize the ensemble and `H` the +stabilizer (little group) of the prepared perturbation/Krylov representation. +The existing expensive average over all `(configuration, g in G)` replicas +can be decomposed into: + +1. rotate/symmetrize configurations only with representatives of the relevant + cosets of `H` in `G`; +2. apply the subgroup projector to each returned perturbed average: + `f_pert` transforms as a vector and `d2v_pert` as a rank-two operator; +3. restore exactly the same normalization as the full `G` average. + +For an invariant perturbation the projector is the ordinary subgroup average. +For a one-dimensional sign/phase representation it must be the +character-weighted projector + +\[ +P_H^{(\chi)} = |H|^{-1}\sum_{h\in H}\chi(h)^* D(h). +\] + +Higher-dimensional irreducible subspaces cannot use a scalar character +shortcut. The first implementation falls back to the full symmetry average +unless it can prove that the requested reduction is exact. It must never +silently approximate. + +Group multiplication order matters when choosing left versus right cosets. +The implementation will determine it from the actual representation matrices +and verify the decomposition numerically. The optimization remains disabled +by default until full-average parity tests pass for every backend. + +### Lanczos backend hook + +Add a private reduction object to the Lanczos engines and a narrow API: + +```python +lanczos.configure_perturbation_symmetry(reduction_or_none) +``` + +The reduction supplies coset symmetry indices and subgroup representation +operators. It is configured after preparing `psi` and before the recursion. +It is cleared whenever `psi`, q point, mesh, or symmetrization data changes. + +Refactor the expensive part of `apply_anharmonic_FT` into two backend hooks: + +```python +f_pert, d2v_pert = self._compute_perturbed_averages(symmetry_indices=...) +f_pert, d2v_pert = self._project_perturbed_averages( + f_pert, d2v_pert, reduction=...) +``` + +- `DynamicalLanczos` passes only coset representatives to the Julia/C kernel, + then projects the returned mode-space vector and matrix under `H`. +- `QSpaceLanczos` retains Python copies of its sparse q-space representation + matrices (currently only cached in Julia), passes the selected coset subset + to the q-space kernel, and projects the force block and every momentum-pair + `d2v` block consistently. +- `QSpaceAtomFourierLanczos` uses the coarse-ensemble symmetry + representation for the external Gamma perturbation and its existing + fine/coarse maps for the two-phonon blocks. Identity-mesh and fine-mesh + parity tests are mandatory. + +The existing `gamma_only` optimization becomes the translation-only special +case of this mechanism. It is not extended independently. Until the generic +path is validated, the old flag stays functional and unchanged. + +## Checkpoint and restart format + +Each work directory contains: + +```text +spectroscopy/ + manifest.json + runs/ + / + status.npz + lanczos.abc + metadata.json +``` + +The manifest is schema-versioned and written atomically. It stores: + +- package/schema version and backend type; +- temperature, supercell, structure, masses, mode-selection flags, and a + stable fingerprint of the dynamical matrix and optical tensors; +- user request, canonical perturbation specification, prepared vector hash, + modulus, symmetry orbit, stabilizer, cosets, and reconstruction map; +- Lanczos options and completed iteration count; +- state: `pending`, `running`, `complete`, or `failed` plus failure message. + +`run(resume=True)` validates the manifest against the current ensemble, +loads every compatible incomplete run, and continues it. It refuses +incompatible files with a field-by-field error. Completed symmetry-equivalent +runs are reconstructed in memory and are never recomputed. + +`Spectroscopy.load(workdir, ensemble=None)` loads all run files and can produce +spectra without an ensemble when every quantity required for analysis is in +the manifest. Restarting recursion still requires compatible backend state. +Legacy `.abc`/`.npz` files may be imported only with explicit observable, +component, convention, and backend metadata; the loader never guesses. + +Writes use a temporary file in the same directory followed by `os.replace`. +Only the MPI master writes manifests/checkpoints and all ranks synchronize +around state transitions. + +## Backward compatibility and cleanup + +Keep these equilibrium calls numerically unchanged during the transition: + +- `prepare_raman(pol_vec_in=..., pol_vec_out=...)`; +- `prepare_raman(unpolarized=i)` with normalized components; +- `prepare_unpolarized_raman(index=i)` with raw components; +- `get_prefactors_unpolarized_raman(i)`; +- `prepare_ir(effective_charges=..., pol_vec=...)`. + +They become thin wrappers around shared vector builders and the existing +backend-specific Gamma-vector preparation hook. Bare `prepare_raman()` stays +polarized XX for compatibility, but documentation points to `Spectroscopy`. + +Remove duplicated equilibrium public implementations from `QSpaceLanczos`. +The base class builds unit-cell vectors and calls +`_prepare_gamma_cartesian_perturbation`; real space tiles the vector and +q-space applies the existing `sqrt(N_cell)` Gamma normalization. + +Quarantine these broken/ambiguous advanced entry points: + +- `prepare_unpolarized_raman_FT`; +- `prepare_anharmonic_raman_FT`; +- `prepare_anharmonic_raman_FT_2ph`; +- any alias that can add an explicit two-phonon Raman perturbation. + +Their implementations are copied to private +`Modules/_TwoPhononSpectroscopyLegacy.py` and are not exported. Public methods +raise `NotImplementedError("two-phonon Raman is disabled: the previous +implementation is unvalidated")`. The private backup is deliberately not +callable. The same audit is applied to position-dependent effective-charge +IR methods; a method is retained only if characterization tests establish its +physics and backend behavior. Otherwise it is quarantined with the same +policy rather than exposed through `Spectroscopy`. + +Pre-hotfix saved Raman data keeps the earlier migration rules: real-space +normalized channels 0--3 require reruns; old q-space coefficients contain the +right direction but require a verified modulus correction; raw-convention +files are unaffected. + +## Implementation phases + +### Phase 1: API and symmetry algebra foundation + +1. Add characterization tests for the current equilibrium polarized Raman, + normalized/raw unpolarized Raman, and polarized IR vectors in real and + q-space. +2. Add `Spectroscopy.py` immutable perturbation definitions, input validation, + normalized Raman component table, vector construction, and result weights. +3. Implement point-group actions, vector-based equivalence detection, + stabilizers, group multiplication, cosets, and reconstruction maps as pure + NumPy code with cubic, tetragonal, and no-symmetry tests. +4. Add a fake backend and test orchestration expansion: cubic IR needs one + run; cubic unpolarized Raman needs three; triclinic cases do not overreduce. +5. Export `Spectroscopy` and document the provisional API. + +### Phase 2: equilibrium backend consolidation + +1. Add `_prepare_gamma_cartesian_perturbation` to the real/q-space engines. +2. Move all vector definitions to the shared module and make old methods thin + wrappers. +3. Delete duplicated q-space public Raman/IR preparation code. +4. Verify exact `psi` and `perturbation_modulus` parity for real, q-space, + and atom-Fourier interpolation. + +### Phase 3: restartable multi-perturbation execution + +1. Implement backend factory, one-engine-per-representative execution, and + explicit run options. +2. Add atomic schema-versioned manifests and per-run checkpoints. +3. Implement strict restart validation and load-only analysis. +4. Test interruption after arbitrary iterations and after arbitrary + perturbations, including MPI master-only writes. + +### Phase 4: subgroup/coset acceleration + +1. Introduce a backend-neutral reduction object and full-average reference + tests. +2. Refactor real-space perturbed-average computation into compute/project + hooks; select coset symmetry matrices in Julia; apply invariant and + character-weighted subgroup projectors. +3. Implement the q-space version, retaining representation matrices and + correctly transforming all momentum-pair blocks. +4. Validate each Lanczos application and final coefficients against the full + group for IR and Raman representatives in multiple space groups. +5. Enable automatically only for proven one-dimensional stabilizer actions; + record the achieved reduction and fallback reason in the manifest. + +### Phase 5: spectrum assembly + +1. Combine representative Green functions using orbit multiplicities and the + canonical Raman invariant weights. +2. Add response, Stokes, and anti-Stokes Raman outputs and detailed-balance + tests. +3. Implement ionic IR susceptibility, then dielectric function with the + electronic tensor, after a unit audit and harmonic-reference validation. +4. Add absorption/optical-conductivity helpers with explicit units. +5. Reproduce a corrected CsSnI3 Raman calculation and a harmonic IR reference. + +### Phase 6: quarantine and documentation migration + +1. Copy the unvalidated two-phonon source to the private backup module. +2. Replace every public two-phonon entry point with `NotImplementedError` and + test that derived classes fail identically. +3. Audit/quarantine unvalidated position-dependent IR code. +4. Update examples and CsSnI3 scripts to the new driver while retaining tests + that their old equilibrium calls still work. + +## Correctness gates + +No symmetry speedup is accepted merely because spectra look similar. Each +backend must satisfy all of the following against the unreduced calculation: + +- prepared Cartesian vector, `psi`, and perturbation modulus; +- a single application of harmonic and anharmonic `L` separately; +- returned `f_pert` and every `d2v_pert` block before final assembly; +- Lanczos coefficients and Green function over a frequency grid; +- rotation invariance of the seven-channel Raman sum; +- detailed balance between Stokes and anti-Stokes; +- cubic orbit reduction and low-symmetry non-reduction; +- restart equivalence at bitwise-identical options, or documented numerical + tolerance where MPI reductions change summation order. + +If the scalar stabilizer/character assumptions do not hold, the calculation +falls back to the full symmetry average and records why. + +## Code-quality gates + +- Every physical formula and Cartesian perturbation definition has one source + of truth. Compatibility methods delegate to it and contain no copied + branches or weight tables. +- `Spectroscopy` owns workflow state; Lanczos owns operator application. The + driver does not reproduce Lanczos recursion, continued fractions, MPI + distribution, or backend normalization. +- A backend override is allowed only for a different data representation + (real-space tiling, q-space projection, or fine/coarse interpolation). All + validation, observable definitions, orbit analysis, manifest handling, and + spectrum assembly remain backend-neutral. +- Symmetry matrices and group metadata are built once and passed through small + immutable objects. Real/q-space adapters consume them rather than running + independent spglib analyses with separate conventions. +- Private helpers are short and composable; no new public method is added to + `DynamicalLanczos` unless it is a necessary numerical-engine capability. +- Tests use shared fixtures and parametrization across backends instead of + copying complete real-space and q-space test bodies. +- Dead compatibility code is removed after delegation. The quarantined + two-phonon source is a non-imported archival snapshot, not a second live + implementation. +- The branch must pass formatting/static checks used by the project and leave + no newly introduced mutable NumPy defaults, assertion-based user input + validation, or broad exception handlers. + +## Original first branch milestone + +The first implementation milestone on `feature/symmetry-spectroscopy` is +deliberately non-invasive: + +1. add the immutable perturbation and Raman-component definitions; +2. add pure symmetry-orbit/stabilizer/coset analysis with tests; +3. add a skeletal `Spectroscopy` request registry and manifest schema; +4. leave existing Lanczos preparation and `apply_anharmonic_FT` behavior + unchanged until the characterization suite is in place. + +This gives a reviewable API and proves the group algebra before touching the +performance-critical real/q-space kernels. + +### Implementation status on `feature/symmetry-spectroscopy` + +Implemented: + +- immutable optical perturbation definitions, one canonical Raman-component + table, and shared Raman/IR Cartesian-vector builders; +- the public `Spectroscopy` driver for polarized, unpolarized, tensor, and + explicit-vector Raman/IR requests; +- spglib point-group construction with atom permutations and filtering of + operations that do not preserve an anisotropic finite supercell mesh; +- global request deduplication, sign-aware perturbation orbits, stabilizers, + and right-coset planning (cubic IR axes reduce to one Lanczos run; the seven + Raman invariants reduce to three symmetry orbits); +- explicit treatment of symmetry-forbidden Raman channels: a zero contracted + optical vertex is recorded as a zero contribution and does not start an + undefined zero-norm Lanczos recursion; +- a single real/q-space Gamma preparation hook used by all compatibility and + high-level paths; the duplicate QSpace Raman/IR API was removed; +- real-space, q-space, distributed q-space, and atom-Fourier execution + through one workflow layer without copying Lanczos recursions or continued + fractions; +- stabilizer/coset reduction inside the shared anharmonic Julia kernels: one + ensemble transformation is evaluated per right coset and the returned force + and second-derivative blocks are character-projected over the stabilizer; +- exact mapping between unit-cell Cartesian rotations and each backend's + native symmetry ordering, including CellConstructor's fractional + real-space convention; +- atomic native status, backend-neutral results, portable `.abc` files, + strict manifest fingerprints, incremental resume, early-recursion + convergence, and load-only analysis; +- weighted response, Stokes and anti-Stokes Raman spectra, ionic IR + susceptibility, and projected dielectric functions including the electronic + tensor and explicit volume/unit conversion; +- inert private backup of the former configuration-dependent two-phonon Raman + source, with every public entry point raising `NotImplementedError`; +- dedicated user/API documentation and tests for legacy delegation, symmetry + algebra, restart/load, every backend, spectrum assembly, and disabled code. +- complete removal of the obsolete KPM backend from source, installation, + command-line tools, documentation, and tests; +- a standalone `spectroscopy_report/` LaTeX report, independent of the + interpolation report, with reproducible benchmark data and vector figures. + +Validation completed: + +- the reduced real-space and q-space anharmonic applications agree with their + full 48-operation group averages to numerical precision while evaluating + only three ensemble representatives for the cubic IR fixture; +- a cubic 3x3x3 q-space test compares the full-replica Julia path with + `configure_qspace_perturbation_symmetry()` at all 27 q points (Gamma plus + 26 non-TRI points), both before and after a first application has generated + a nonzero two-phonon sector; +- the isolated-kernel speedup grows from about 1.9x/1.6x at 10 configurations + to 12.7x/11.4x at 640 configurations (real/q-space), demonstrating that the + fixed stabilizer projector is amortized toward the ideal 48/3 ratio; +- a 256-step controlled cubic benchmark reduces unpolarized Raman from seven + requested components (three nonzero legacy calculations) to one independent + calculation and unpolarized IR from three calculations to one; the legacy + and reconstructed spectra agree within about `3.2e-14` relative error; +- the final focused spectroscopy/Raman suite passes 47 tests; +- the complete non-heavy repository suite passes 138 tests with 8 expected + skips, covering the unchanged Lanczos, q-space, Hessian, + interpolation/atom-Fourier, and restart paths. + +Intentionally outside this one-phonon API: + +- configuration-dependent/two-phonon Raman and IR vertices remain unsupported + until their observable definitions, units, and symmetry transformations are + independently derived and validated; +- absorption-coefficient and optical-conductivity unit conventions are not + guessed by this layer; users can obtain the complex dielectric response and + apply the convention appropriate to their electromagnetic unit system. diff --git a/requirements.txt b/requirements.txt index 852afe0e..c06e801f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ spglib cellconstructor python-sscha mpi4py -julia +juliacall diff --git a/scripts/tdscha-plot.py b/scripts/tdscha-plot.py index 88a2f01e..4e88cb8a 100644 --- a/scripts/tdscha-plot.py +++ b/scripts/tdscha-plot.py @@ -13,7 +13,6 @@ import sscha import tdscha, tdscha.DynamicalLanczos as DL -import tdscha.QSpaceKPM as QKPM import sscha.Ensemble HEADER = """ @@ -33,9 +32,7 @@ Usage: tdscha-plot.py [w_start w_end [smearing]] -Pass a .abc, .npz, or .kpm file resulting from a linear response calculation. -- .abc / .npz : use Lanczos continued fraction -- .kpm : use KPM spectral function +Pass a .abc or .npz file resulting from a Lanczos calculation. Optionally you can pass a range of frequencies (cm-1) and the smearing. """) exit() @@ -43,23 +40,15 @@ fname = sys.argv[1] assert os.path.exists(fname), "Error, file {} does not exist".format(fname) - use_kpm = fname.endswith(".kpm") - - if use_kpm: - print("Loading KPM file {}".format(fname)) - kpm = QKPM.QSpaceKPM(None) - kpm.load_kpm(fname) - lanc = None + print("Loading file {}".format(fname)) + lanc = DL.Lanczos() + if fname.endswith(".abc"): + lanc.load_abc(fname) + elif fname.endswith(".npz"): + lanc.load_status(fname) else: - print("Loading file {}".format(fname)) - lanc = DL.Lanczos() - if fname.endswith(".abc"): - lanc.load_abc(fname) - elif fname.endswith(".npz"): - lanc.load_status(fname) - else: - print("ERROR, the specified file must be a .abc, .npz, or .kpm file.") - exit() + print("ERROR, the specified file must be a .abc or .npz file.") + exit() w_start = 0 w_end = 5000 @@ -76,19 +65,13 @@ w_ry = w / CC.Units.RY_TO_CM smearing /= CC.Units.RY_TO_CM - if use_kpm: - # KPM spectral function does not use smearing parameter - spectrum = kpm.get_spectral_function_KPM(w_ry, regularization="jackson") - else: - gf = lanc.get_green_function_continued_fraction(w_ry, smearing = smearing, use_terminator=False) - spectrum = - np.imag(gf) + gf = lanc.get_green_function_continued_fraction( + w_ry, smearing=smearing, use_terminator=False) + spectrum = -np.imag(gf) # Print some info about the calculation print() - if use_kpm: - print("Number of KPM moments: {}".format(kpm.kpm_n_moments)) - else: - print("Number of poles: {}".format(len(lanc.a_coeffs))) + print("Number of poles: {}".format(len(lanc.a_coeffs))) plt.plot(w, spectrum) plt.xlabel("Frequency [cm-1]") @@ -96,4 +79,3 @@ plt.tight_layout() plt.show() - diff --git a/spectroscopy_report/README.md b/spectroscopy_report/README.md new file mode 100644 index 00000000..616dcbb4 --- /dev/null +++ b/spectroscopy_report/README.md @@ -0,0 +1,18 @@ +# TD-SCHA spectroscopy implementation report + +This is the standalone Raman/IR report. It is intentionally separate from +`report/interpolation/` and has its own source, benchmark data, and figures, +including a configuration-scaling plot for the symmetry-reduced kernel. + +Regenerate the evidence: + +```bash +micromamba run -n sscha python spectroscopy_report/benchmark_spectroscopy.py +``` + +Compile from this directory: + +```bash +lualatex -interaction=nonstopmode -halt-on-error tdscha_spectroscopy_report.tex +lualatex -interaction=nonstopmode -halt-on-error tdscha_spectroscopy_report.tex +``` diff --git a/spectroscopy_report/benchmark_spectroscopy.py b/spectroscopy_report/benchmark_spectroscopy.py new file mode 100644 index 00000000..8e2a445b --- /dev/null +++ b/spectroscopy_report/benchmark_spectroscopy.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +"""Reproduce the numerical evidence used by the spectroscopy report. + +The benchmark deliberately uses only data shipped in ``tests/test_julia/data``. +The cubic Raman derivative is synthetic and symmetry-covariant: it isolates a +triply equivalent off-diagonal Raman sector while making the four forbidden +Placzek channels exactly zero. This gives a transparent test of orbit +reconstruction rather than claiming a material-specific Raman prediction. +""" + +from __future__ import annotations + +from contextlib import redirect_stdout +import csv +import io +import json +from pathlib import Path +from statistics import median +import tempfile +import time + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +import cellconstructor as CC +from cellconstructor.Units import RY_TO_CM +import sscha.Ensemble + +import tdscha.DynamicalLanczos as DL +import tdscha.Spectroscopy as SP +from tdscha import _SpectroscopyWorkflow as workflow + + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / "tests" / "test_julia" / "data" +HERE = Path(__file__).resolve().parent +OUTPUT = HERE / "data" +FIGURES = HERE / "figures" +# Long enough that symmetry-reduced recurrence work is visible above the +# intentionally conservative atomic-checkpoint overhead, while remaining a +# small two-atom/ten-configuration benchmark. +N_STEPS = 256 +N_KERNEL_REPEATS = 7 +KERNEL_CONFIGURATION_COUNTS = (10, 40, 160, 640) +SMEARING_RY = 8.0e-5 + + +def load_ensemble(): + dyn = CC.Phonons.Phonons(str(DATA / "dyn_gen_pop1_"), 3) + ensemble = sscha.Ensemble.Ensemble(dyn, 250.0) + ensemble.load_bin(str(DATA), 1) + return ensemble + + +def cubic_charges(n_atoms): + """Return a neutral isotropic Born-charge pattern for the two-atom cell.""" + if n_atoms != 2: + raise ValueError("The bundled benchmark is expected to have two atoms") + charges = np.zeros((n_atoms, 3, 3)) + charges[0] = np.eye(3) + charges[1] = -np.eye(3) + return charges + + +def cubic_raman_tensor(n_atoms): + """Return a controlled T2-like Raman derivative in Cartesian coordinates. + + The normalized xy, xz and yz Placzek channels prepare, respectively, + opposite x, y and z displacements of the two sublattices. The trace and + three diagonal-deviatoric channels vanish identically. + """ + if n_atoms != 2: + raise ValueError("The bundled benchmark is expected to have two atoms") + tensor = np.zeros((3, 3, 3 * n_atoms)) + patterns = ( + np.array([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]), + np.array([0.0, 1.0, 0.0, 0.0, -1.0, 0.0]), + np.array([0.0, 0.0, 1.0, 0.0, 0.0, -1.0]), + ) + for (first, second), pattern in zip(((0, 1), (0, 2), (1, 2)), + patterns): + # The normalized coefficient has sqrt(3)/2 on each symmetric entry. + # Equal tensor entries therefore contract to ``pattern``. + tensor[first, second] = pattern / np.sqrt(3.0) + tensor[second, first] = pattern / np.sqrt(3.0) + return tensor + + +def relative_linf(reference, candidate): + scale = max(float(np.max(np.abs(reference))), np.finfo(float).tiny) + return float(np.max(np.abs(candidate - reference)) / scale) + + +def prepare_direct_engine(ensemble): + engine = DL.Lanczos(ensemble) + engine.init(use_symmetries=True) + return engine + + +def _tile_engine_configurations(engine, backend, target): + """Tile a fixed ensemble to isolate kernel scaling with sample count.""" + original = int(engine.N) + if target % original: + raise ValueError("target configuration count must divide the fixture") + factor = target // original + engine.rho = np.tile(engine.rho, factor) + if backend == "real": + engine.X = np.tile(engine.X, (factor, 1)) + engine.Y = np.tile(engine.Y, (factor, 1)) + else: + engine.X_q = np.tile(engine.X_q, (1, factor, 1)) + engine.Y_q = np.tile(engine.Y_q, (1, factor, 1)) + engine.N = target + engine.N_eff = float(np.sum(engine.rho)) + + +def timed_kernel(ensemble, backend, run_spec, n_configurations, + repeats=N_KERNEL_REPEATS): + vector = run_spec.as_array() + full = workflow.create_backend(ensemble, backend, {}) + workflow.prepare_engine(full, vector, True) + reduced = workflow.create_backend(ensemble, backend, {}) + workflow.prepare_engine(reduced, vector, True, run_spec, 1.0e-8) + _tile_engine_configurations(full, backend, n_configurations) + _tile_engine_configurations(reduced, backend, n_configurations) + + sink = io.StringIO() + with redirect_stdout(sink): + full_reference = full.apply_anharmonic_FT() + reduced_reference = reduced.apply_anharmonic_FT() + full_times = [] + reduced_times = [] + for _ in range(repeats): + start = time.perf_counter() + with redirect_stdout(sink): + full_value = full.apply_anharmonic_FT() + full_times.append(time.perf_counter() - start) + + start = time.perf_counter() + with redirect_stdout(sink): + reduced_value = reduced.apply_anharmonic_FT() + reduced_times.append(time.perf_counter() - start) + + # Compare both the warm-up call and final repeated call to guard against + # accidental mutation hidden by timing loops. + error = max(relative_linf(full_reference, reduced_reference), + relative_linf(full_value, reduced_value)) + active = int(reduced._spectroscopy_symmetry_count(reduced.n_syms)) + return { + "backend": backend, + "configurations": n_configurations, + "full_group_order": int(full.n_syms), + "coset_representatives": active, + "stabilizer_order": int( + len(reduced._spectroscopy_stabilizer_indices)), + "full_median_seconds": median(full_times), + "reduced_median_seconds": median(reduced_times), + "speedup": median(full_times) / median(reduced_times), + "relative_linf_error": error, + "repeats": repeats, + } + + +def direct_raman_legacy(ensemble, frequencies): + weights = SP.get_unpolarized_raman_weights("normalized") + total = np.zeros_like(frequencies) + active = [] + elapsed = 0.0 + sink = io.StringIO() + for index in range(7): + # The old API cannot safely run a zero perturbation. Detecting and + # skipping it here is the manual workaround used for the reference. + probe = SP.build_raman_vector( + ensemble.current_dyn.raman_tensor, + SP.get_raman_component(index).coefficients("normalized")) + if np.linalg.norm(probe) <= 1.0e-12: + continue + active.append(index) + start = time.perf_counter() + with redirect_stdout(sink): + engine = prepare_direct_engine(ensemble) + engine.prepare_raman(unpolarized=index) + engine.run_FT(N_STEPS, verbose=False) + elapsed += time.perf_counter() - start + total += weights[index] * (-np.imag( + engine.get_green_function_continued_fraction( + frequencies, use_terminator=False, smearing=SMEARING_RY))) + return total, elapsed, active + + +def direct_ir_legacy(ensemble, charges, frequencies): + total = np.zeros_like(frequencies) + elapsed = 0.0 + sink = io.StringIO() + for direction in np.eye(3): + start = time.perf_counter() + with redirect_stdout(sink): + engine = prepare_direct_engine(ensemble) + engine.prepare_ir(effective_charges=charges, pol_vec=direction) + engine.run_FT(N_STEPS, verbose=False) + elapsed += time.perf_counter() - start + total += (-np.imag(engine.get_green_function_continued_fraction( + frequencies, use_terminator=False, smearing=SMEARING_RY))) / 3.0 + return total, elapsed + + +def new_raman(ensemble, frequencies, workdir): + job = SP.Spectroscopy( + ensemble, backend="real", workdir=workdir, use_symmetries=True) + job.add_raman_unpolarized("powder") + plan = job.plan_calculations() + sink = io.StringIO() + start = time.perf_counter() + with redirect_stdout(sink): + job.run(N_STEPS, save_each=N_STEPS, verbose=False) + elapsed = time.perf_counter() - start + spectrum = job.raman_spectrum( + "powder", frequencies, kind="response", use_terminator=False, + smearing=SMEARING_RY) + return spectrum, elapsed, plan + + +def new_ir(ensemble, charges, frequencies, workdir): + job = SP.Spectroscopy( + ensemble, backend="real", workdir=workdir, use_symmetries=True) + job.add_ir_unpolarized("powder", effective_charges=charges) + plan = job.plan_calculations() + sink = io.StringIO() + start = time.perf_counter() + with redirect_stdout(sink): + job.run(N_STEPS, save_each=N_STEPS, verbose=False) + elapsed = time.perf_counter() - start + spectrum = job.response( + "powder", frequencies, use_terminator=False, + smearing=SMEARING_RY) + return spectrum, elapsed, plan + + +def save_spectra(frequencies, raman_legacy, raman_new, ir_legacy, ir_new): + path = OUTPUT / "spectra.csv" + with path.open("w", newline="", encoding="utf-8") as stream: + writer = csv.writer(stream) + writer.writerow(("frequency_ry", "frequency_cm-1", "raman_legacy", + "raman_new", "ir_legacy", "ir_new")) + for row in zip(frequencies, frequencies * RY_TO_CM, raman_legacy, + raman_new, ir_legacy, ir_new): + writer.writerow(tuple(float(value) for value in row)) + + +def plot_spectrum(path, frequency_cm, legacy, new, ylabel, title): + scale = max(float(np.max(np.abs(legacy))), np.finfo(float).tiny) + figure, axis = plt.subplots(figsize=(6.4, 3.8)) + axis.plot(frequency_cm, legacy / scale, color="#222222", linewidth=2.2, + label="legacy independent calculations") + axis.plot(frequency_cm, new / scale, color="#d95f02", linewidth=1.3, + linestyle="--", label="symmetry-aware API") + axis.set_xlabel(r"frequency shift (cm$^{-1}$)") + axis.set_ylabel(ylabel + " (normalized)") + axis.set_title(title) + axis.legend(frameon=False) + axis.grid(alpha=0.2) + figure.tight_layout() + figure.savefig(path) + plt.close(figure) + + +def plot_timings(path, summary): + labels = ("Raman\nworkflow", "IR\nworkflow", + "real kernel", "q-space kernel") + legacy = (summary["raman"]["legacy_seconds"], + summary["ir"]["legacy_seconds"], + summary["kernels"][0]["full_median_seconds"], + summary["kernels"][1]["full_median_seconds"]) + reduced = (summary["raman"]["new_seconds"], + summary["ir"]["new_seconds"], + summary["kernels"][0]["reduced_median_seconds"], + summary["kernels"][1]["reduced_median_seconds"]) + positions = np.arange(len(labels)) + width = 0.36 + figure, axis = plt.subplots(figsize=(6.8, 3.9)) + axis.bar(positions - width / 2, legacy, width, color="#777777", + label="legacy/full group") + axis.bar(positions + width / 2, reduced, width, color="#1b9e77", + label="new/reduced") + axis.set_yscale("log") + axis.set_ylabel("wall time (s, log scale)") + axis.set_xticks(positions, labels) + axis.grid(axis="y", alpha=0.2) + axis.legend(frameon=False) + figure.tight_layout() + figure.savefig(path) + plt.close(figure) + + +def plot_kernel_scaling(path, scaling): + figure, axis = plt.subplots(figsize=(6.4, 3.8)) + for backend, marker in (("real", "o"), ("qspace", "s")): + rows = [row for row in scaling if row["backend"] == backend] + axis.plot( + [row["configurations"] for row in rows], + [row["speedup"] for row in rows], marker=marker, + linewidth=1.8, label=backend) + axis.axhline(16.0, color="#555555", linestyle="--", linewidth=1.0, + label=r"representative limit $48/3$") + axis.set_xscale("log", base=2) + axis.set_xlabel("configurations (tiled benchmark)") + axis.set_ylabel("full / reduced kernel time") + axis.grid(alpha=0.2) + axis.legend(frameon=False) + figure.tight_layout() + figure.savefig(path) + plt.close(figure) + + +def save_latex_macros(summary): + """Keep numerical statements in the report tied to generated JSON data.""" + raman = summary["raman"] + ir = summary["ir"] + real, qspace = summary["kernels"] + commands = { + "BenchSteps": summary["system"]["lanczos_steps"], + "RamanRequested": raman["requested_components"], + "RamanNonzero": raman["legacy_nonzero_runs"], + "RamanIndependent": raman["new_independent_runs"], + "RamanError": "{:.2e}".format(raman["relative_linf_error"]), + "RamanLegacyTime": "{:.3f}".format(raman["legacy_seconds"]), + "RamanNewTime": "{:.3f}".format(raman["new_seconds"]), + "RamanSpeedup": "{:.2f}".format(raman["speedup"]), + "IRIndependent": ir["new_independent_runs"], + "IRError": "{:.2e}".format(ir["relative_linf_error"]), + "IRLegacyTime": "{:.3f}".format(ir["legacy_seconds"]), + "IRNewTime": "{:.3f}".format(ir["new_seconds"]), + "IRSpeedup": "{:.2f}".format(ir["speedup"]), + "GroupOrder": real["full_group_order"], + "StabilizerOrder": real["stabilizer_order"], + "CosetCount": real["coset_representatives"], + "KernelConfigurations": real["configurations"], + "RealKernelSpeedup": "{:.2f}".format(real["speedup"]), + "RealKernelError": "{:.2e}".format(real["relative_linf_error"]), + "QKernelSpeedup": "{:.2f}".format(qspace["speedup"]), + "QKernelError": "{:.2e}".format(qspace["relative_linf_error"]), + } + with (OUTPUT / "benchmark_values.tex").open( + "w", encoding="utf-8") as stream: + stream.write("% Generated by benchmark_spectroscopy.py; do not edit.\n") + for name, value in commands.items(): + stream.write("\\newcommand{{\\{}}}{{{}}}\n".format(name, value)) + + +def main(): + OUTPUT.mkdir(parents=True, exist_ok=True) + FIGURES.mkdir(parents=True, exist_ok=True) + frequencies = np.linspace(2.0e-4, 8.0e-3, 900) + ensemble = load_ensemble() + n_atoms = ensemble.current_dyn.structure.N_atoms + charges = cubic_charges(n_atoms) + ensemble.current_dyn.raman_tensor = cubic_raman_tensor(n_atoms) + + # Construct one cubic IR representative for isolated kernel timings. The + # real-space run also warms the Julia bridge before end-to-end timings. + probe = SP.Spectroscopy(ensemble, backend="real", use_symmetries=True) + probe.add_ir_polarized([1, 0, 0], "ir_x", effective_charges=charges) + probe.plan_calculations() + run_spec = next(iter(probe._run_specs.values())) + kernel_scaling = [ + timed_kernel(ensemble, backend, run_spec, n_configurations) + for backend in ("real", "qspace") + for n_configurations in KERNEL_CONFIGURATION_COUNTS] + kernels = [next( + row for row in reversed(kernel_scaling) + if row["backend"] == backend) + for backend in ("real", "qspace")] + + with tempfile.TemporaryDirectory(prefix="tdscha-spectroscopy-") as temp: + temp = Path(temp) + raman_legacy, raman_legacy_time, active_raman = ( + direct_raman_legacy(ensemble, frequencies)) + raman_new, raman_new_time, raman_plan = new_raman( + ensemble, frequencies, temp / "raman") + ir_legacy, ir_legacy_time = direct_ir_legacy( + ensemble, charges, frequencies) + ir_new, ir_new_time, ir_plan = new_ir( + ensemble, charges, frequencies, temp / "ir") + + summary = { + "system": { + "name": "bundled cubic SnTe ensemble with controlled optical vertices", + "temperature_K": float(ensemble.current_T), + "supercell": [int(value) for value in + ensemble.current_dyn.GetSupercell()], + "ensemble_configurations": int(ensemble.N), + "lanczos_steps": N_STEPS, + "smearing_ry": SMEARING_RY, + }, + "raman": { + "requested_components": int( + raman_plan["n_requested_components"]), + "legacy_nonzero_runs": len(active_raman), + "legacy_active_indices": active_raman, + "new_independent_runs": int(raman_plan["n_independent_runs"]), + "zero_components": sum( + component["run_id"] is None + for component in raman_plan["request_components"]["powder"]), + "legacy_seconds": raman_legacy_time, + "new_seconds": raman_new_time, + "speedup": raman_legacy_time / raman_new_time, + "relative_linf_error": relative_linf(raman_legacy, raman_new), + }, + "ir": { + "requested_components": int(ir_plan["n_requested_components"]), + "legacy_runs": 3, + "new_independent_runs": int(ir_plan["n_independent_runs"]), + "legacy_seconds": ir_legacy_time, + "new_seconds": ir_new_time, + "speedup": ir_legacy_time / ir_new_time, + "relative_linf_error": relative_linf(ir_legacy, ir_new), + }, + "kernels": kernels, + "kernel_scaling": kernel_scaling, + } + save_spectra(frequencies, raman_legacy, raman_new, + ir_legacy, ir_new) + with (OUTPUT / "benchmark_summary.json").open( + "w", encoding="utf-8") as stream: + json.dump(summary, stream, indent=2, sort_keys=True) + stream.write("\n") + save_latex_macros(summary) + + plot_spectrum( + FIGURES / "raman_legacy_vs_symmetry.pdf", frequencies * RY_TO_CM, + raman_legacy, raman_new, "Raman response", + "Cubic unpolarized Raman reconstruction") + plot_spectrum( + FIGURES / "ir_legacy_vs_symmetry.pdf", frequencies * RY_TO_CM, + ir_legacy, ir_new, "IR response", + "Cubic unpolarized IR reconstruction") + plot_timings(FIGURES / "timings.pdf", summary) + plot_kernel_scaling(FIGURES / "kernel_scaling.pdf", kernel_scaling) + print(json.dumps(summary, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/spectroscopy_report/data/benchmark_summary.json b/spectroscopy_report/data/benchmark_summary.json new file mode 100644 index 00000000..9e464311 --- /dev/null +++ b/spectroscopy_report/data/benchmark_summary.json @@ -0,0 +1,162 @@ +{ + "ir": { + "legacy_runs": 3, + "legacy_seconds": 14.579686361015774, + "new_independent_runs": 1, + "new_seconds": 1.477487770956941, + "relative_linf_error": 3.045542529229973e-14, + "requested_components": 3, + "speedup": 9.867889702784332 + }, + "kernel_scaling": [ + { + "backend": "real", + "configurations": 10, + "coset_representatives": 3, + "full_group_order": 48, + "full_median_seconds": 0.0017279270105063915, + "reduced_median_seconds": 0.0009242960950359702, + "relative_linf_error": 1.6770509138759796e-15, + "repeats": 7, + "speedup": 1.8694518128837783, + "stabilizer_order": 16 + }, + { + "backend": "real", + "configurations": 40, + "coset_representatives": 3, + "full_group_order": 48, + "full_median_seconds": 0.004928822047077119, + "reduced_median_seconds": 0.0012764170533046126, + "relative_linf_error": 3.1444704635174573e-15, + "repeats": 7, + "speedup": 3.861451109820665, + "stabilizer_order": 16 + }, + { + "backend": "real", + "configurations": 160, + "coset_representatives": 3, + "full_group_order": 48, + "full_median_seconds": 0.019442813005298376, + "reduced_median_seconds": 0.002200945047661662, + "relative_linf_error": 1.5512720953352614e-14, + "repeats": 7, + "speedup": 8.833847544696718, + "stabilizer_order": 16 + }, + { + "backend": "real", + "configurations": 640, + "coset_representatives": 3, + "full_group_order": 48, + "full_median_seconds": 0.06837712693959475, + "reduced_median_seconds": 0.005384374060668051, + "relative_linf_error": 8.46910711507296e-14, + "repeats": 7, + "speedup": 12.699178431728617, + "stabilizer_order": 16 + }, + { + "backend": "qspace", + "configurations": 10, + "coset_representatives": 3, + "full_group_order": 48, + "full_median_seconds": 0.0016310089267790318, + "reduced_median_seconds": 0.001033519976772368, + "relative_linf_error": 8.446762592530517e-16, + "repeats": 7, + "speedup": 1.5781106930052695, + "stabilizer_order": 16 + }, + { + "backend": "qspace", + "configurations": 40, + "coset_representatives": 3, + "full_group_order": 48, + "full_median_seconds": 0.003951585968025029, + "reduced_median_seconds": 0.0012220609933137894, + "relative_linf_error": 3.3555602228412186e-15, + "repeats": 7, + "speedup": 3.2335423433406136, + "stabilizer_order": 16 + }, + { + "backend": "qspace", + "configurations": 160, + "coset_representatives": 3, + "full_group_order": 48, + "full_median_seconds": 0.014473250019364059, + "reduced_median_seconds": 0.002020983025431633, + "relative_linf_error": 6.918848075501458e-15, + "repeats": 7, + "speedup": 7.161490144764043, + "stabilizer_order": 16 + }, + { + "backend": "qspace", + "configurations": 640, + "coset_representatives": 3, + "full_group_order": 48, + "full_median_seconds": 0.05224829004146159, + "reduced_median_seconds": 0.004582665977068245, + "relative_linf_error": 2.704263947674245e-14, + "repeats": 7, + "speedup": 11.401287002568614, + "stabilizer_order": 16 + } + ], + "kernels": [ + { + "backend": "real", + "configurations": 640, + "coset_representatives": 3, + "full_group_order": 48, + "full_median_seconds": 0.06837712693959475, + "reduced_median_seconds": 0.005384374060668051, + "relative_linf_error": 8.46910711507296e-14, + "repeats": 7, + "speedup": 12.699178431728617, + "stabilizer_order": 16 + }, + { + "backend": "qspace", + "configurations": 640, + "coset_representatives": 3, + "full_group_order": 48, + "full_median_seconds": 0.05224829004146159, + "reduced_median_seconds": 0.004582665977068245, + "relative_linf_error": 2.704263947674245e-14, + "repeats": 7, + "speedup": 11.401287002568614, + "stabilizer_order": 16 + } + ], + "raman": { + "legacy_active_indices": [ + 4, + 5, + 6 + ], + "legacy_nonzero_runs": 3, + "legacy_seconds": 14.64542654901743, + "new_independent_runs": 1, + "new_seconds": 1.482069363933988, + "relative_linf_error": 3.0425828183172135e-14, + "requested_components": 7, + "speedup": 9.88174164139172, + "zero_components": 4 + }, + "system": { + "ensemble_configurations": 10, + "lanczos_steps": 256, + "name": "bundled cubic SnTe ensemble with controlled optical vertices", + "smearing_ry": 8e-05, + "supercell": [ + 2, + 2, + 2 + ], + "temperature_K": 250.0 + } +} diff --git a/spectroscopy_report/data/benchmark_values.tex b/spectroscopy_report/data/benchmark_values.tex new file mode 100644 index 00000000..d6d30366 --- /dev/null +++ b/spectroscopy_report/data/benchmark_values.tex @@ -0,0 +1,22 @@ +% Generated by benchmark_spectroscopy.py; do not edit. +\newcommand{\BenchSteps}{256} +\newcommand{\RamanRequested}{7} +\newcommand{\RamanNonzero}{3} +\newcommand{\RamanIndependent}{1} +\newcommand{\RamanError}{3.04e-14} +\newcommand{\RamanLegacyTime}{14.645} +\newcommand{\RamanNewTime}{1.482} +\newcommand{\RamanSpeedup}{9.88} +\newcommand{\IRIndependent}{1} +\newcommand{\IRError}{3.05e-14} +\newcommand{\IRLegacyTime}{14.580} +\newcommand{\IRNewTime}{1.477} +\newcommand{\IRSpeedup}{9.87} +\newcommand{\GroupOrder}{48} +\newcommand{\StabilizerOrder}{16} +\newcommand{\CosetCount}{3} +\newcommand{\KernelConfigurations}{640} +\newcommand{\RealKernelSpeedup}{12.70} +\newcommand{\RealKernelError}{8.47e-14} +\newcommand{\QKernelSpeedup}{11.40} +\newcommand{\QKernelError}{2.70e-14} diff --git a/spectroscopy_report/data/spectra.csv b/spectroscopy_report/data/spectra.csv new file mode 100644 index 00000000..9510c644 --- /dev/null +++ b/spectroscopy_report/data/spectra.csv @@ -0,0 +1,901 @@ +frequency_ry,frequency_cm-1,raman_legacy,raman_new,ir_legacy,ir_new +0.0002,21.94747206953807,18317.82119776754,18317.821197767757,872.2771998936922,872.2771998937026 +0.00020867630700778644,22.89958709813872,18663.95809181839,18663.9580918183,888.759909134209,888.7599091342045 +0.00021735261401557287,23.85170212673937,19138.286015826336,19138.28601582601,911.3469531345875,911.3469531345717 +0.0002260289210233593,24.80381715534002,19767.56920598173,19767.569205981257,941.3128193324633,941.3128193324408 +0.00023470522803114573,25.755932183940672,20571.930199494625,20571.930199494058,979.6157237854584,979.6157237854313 +0.00024338153503893216,26.70804721254132,21562.537150552867,21562.53715055222,1026.7874833596602,1026.7874833596293 +0.00025205784204671857,27.66016224114197,22737.700950519375,22737.70095051862,1082.7476643104465,1082.7476643104105 +0.000260734149054505,28.61227726974262,24076.985932247113,24076.98593224635,1146.523139630815,1146.5231396307786 +0.00026941045606229143,29.564392298343268,25533.789618294755,25533.789618293893,1215.8947437283214,1215.8947437282807 +0.00027808676307007786,30.51650732694392,27028.191192153106,27028.191192152186,1287.056723435862,1287.0567234358184 +0.0002867630700778643,31.46862235554457,28443.75774262902,28443.757742628084,1354.4646544109057,1354.4646544108612 +0.0002954393770856507,32.42073738414522,29633.619265709713,29633.619265708807,1411.1247269385578,1411.1247269385146 +0.00030411568409343716,33.37285241274587,30440.390993510213,30440.390993509336,1449.5424282623912,1449.5424282623494 +0.0003127919911012236,34.32496744134652,30729.173509882523,30729.173509881773,1463.2939766610727,1463.2939766610368 +0.00032146829810901,35.277082469947175,30423.749444843263,30423.749444842637,1448.7499735639649,1448.7499735639349 +0.00033014460511679645,36.229197498547826,29529.6575259003,29529.657525899805,1406.1741679000143,1406.1741678999908 +0.0003388209121245829,37.18131252714847,28131.789205318517,28131.789205318142,1339.609009777072,1339.6090097770543 +0.0003474972191323693,38.13342755574912,26367.877325887086,26367.87732588681,1255.613205994623,1255.61320599461 +0.00035617352614015574,39.085542584349774,24391.842210698087,24391.842210697905,1161.516295747528,1161.5162957475193 +0.0003648498331479422,40.037657612950426,22342.788600778764,22342.788600778644,1063.9423143227984,1063.9423143227925 +0.0003735261401557286,40.98977264155108,20327.887744456944,20327.887744456853,967.9946544979498,967.9946544979454 +0.00038220244716351504,41.94188767015173,18418.610155668597,18418.610155668546,877.0766740794572,877.0766740794544 +0.00039087875417130147,42.894002698752374,16655.312817802354,16655.312817802303,793.1101341810646,793.1101341810619 +0.0003995550611790879,43.846117727353025,15055.065384554437,15055.0653845544,716.9078754549732,716.9078754549714 +0.00040823136818687433,44.79823275595368,13619.415472900375,13619.415472900346,648.543593947637,648.5435939476354 +0.0004169076751946607,45.75034778455432,12340.635846596686,12340.635846596651,587.6493260284135,587.649326028412 +0.0004255839822024472,46.70246281315498,11206.168056460281,11206.168056460254,533.6270503076324,533.6270503076312 +0.00043426028921023357,47.654577841755625,10201.514205825242,10201.514205825219,485.78639075358296,485.7863907535818 +0.00044293659621802005,48.60669287035628,9311.977279021807,9311.977279021787,443.4274894772289,443.42748947722794 +0.00045161290322580643,49.55880789895692,8523.620797912023,8523.620797912003,405.8867046624773,405.8867046624763 +0.0004602892102335929,50.51092292755758,7823.732693097865,7823.732693097841,372.5586996713269,372.5586996713257 +0.0004689655172413793,51.463037956158225,7200.9916758104355,7200.991675810416,342.90436551478274,342.9043655147817 +0.0004776418242491658,52.41515298475888,6645.465634647522,6645.465634647504,316.4507445070249,316.450744507024 +0.00048631813125695215,53.36726801335953,6148.522758927793,6148.522758927774,292.7867980441806,292.7867980441797 +0.0004949944382647386,54.31938304196018,5702.703627618826,5702.703627618809,271.5573156008965,271.55731560089566 +0.000503670745272525,55.271498070560824,5301.581853194771,5301.581853194754,252.4562787235605,252.45627872355973 +0.0005123470522803115,56.22361309916148,4939.628175147836,4939.62817514782,235.22038929275408,235.22038929275328 +0.0005210233592880979,57.17572812776213,4612.085329286412,4612.085329286395,219.62311091840053,219.62311091839976 +0.0005296996662958844,58.127843156362786,4314.856652420294,4314.85665242028,205.4693644009664,205.46936440096567 +0.0005383759733036707,59.07995818496343,4044.408966767979,4044.408966767964,192.59090317942758,192.59090317942682 +0.0005470522803114572,60.03207321356408,3797.689035290452,3797.6890352904384,180.84233501383105,180.84233501383042 +0.0005557285873192436,60.98418824216473,3572.05230111689,3572.052301116879,170.0977286246138,170.0977286246133 +0.0005644048943270301,61.936303270765386,3365.202426082614,3365.2024260826015,160.24773457536256,160.247734575362 +0.0005730812013348165,62.88841829936603,3175.140149297446,3175.1401492974373,151.19714996654506,151.1971499665446 +0.000581757508342603,63.84053332796669,3000.1200938410216,3000.120093841011,142.86286161147723,142.86286161147672 +0.0005904338153503893,64.79264835656733,2838.614299546228,2838.6142995462183,135.17210950220135,135.17210950220087 +0.0005991101223581758,65.74476338516799,2689.2814201296205,2689.281420129612,128.0610200061724,128.061020006172 +0.0006077864293659622,66.69687841376863,2550.9406768808153,2550.940676880807,121.47336556575311,121.47336556575272 +0.0006164627363737486,67.64899344236927,2422.549800872769,2422.549800872762,115.35951432727472,115.35951432727437 +0.000625139043381535,68.60110847096993,2303.1863183441305,2303.186318344123,109.67553896876811,109.67553896876774 +0.0006338153503893214,69.55322349957058,2192.0316393635158,2192.031639363508,104.38245901731027,104.38245901730988 +0.0006424916573971079,70.50533852817124,2088.3574992873055,2088.3574992872987,99.4455952041574,99.44559520415706 +0.0006511679644048943,71.45745355677188,1991.5143776182176,1991.5143776182117,94.83401798181987,94.8340179818196 +0.0006598442714126808,72.40956858537254,1900.9215815686794,1900.9215815686728,90.52007531279426,90.52007531279395 +0.0006685205784204672,73.36168361397318,1816.0587337747202,1816.0587337747143,86.47898732260572,86.47898732260545 +0.0006771968854282536,74.31379864257384,1736.4584468681326,1736.4584468681269,82.68849746991107,82.6884974699108 +0.00068587319243604,75.26591367117449,1661.7000034669716,1661.7000034669668,79.12857159366533,79.12857159366509 +0.0006945494994438265,76.21802869977513,1591.403889845591,1591.4038898455856,75.7811376116948,75.78113761169456 +0.0007032258064516129,77.17014372837578,1525.2270561586372,1525.2270561586324,72.62985981707796,72.62985981707774 +0.0007119021134593994,78.12225875697644,1462.8587965061145,1462.8587965061106,69.65994269076735,69.65994269076717 +0.0007205784204671857,79.07437378557708,1404.0171590755976,1404.0171590755945,66.85795995598086,66.85795995598068 +0.0007292547274749722,80.02648881417774,1348.44581069086,1348.445810690857,64.21170527099333,64.21170527099319 +0.0007379310344827586,80.97860384277838,1295.911291834508,1295.9112918345043,61.71006151592894,61.71006151592877 +0.0007466073414905451,81.93071887137904,1246.2006080068472,1246.2006080068436,59.342886095564154,59.34288609556398 +0.0007552836484983315,82.88283389997969,1199.119111472194,1199.1191114721908,57.100910070104476,57.10091007010432 +0.0007639599555061178,83.83494892858033,1154.4886343042149,1154.4886343042124,54.97564925258166,54.97564925258154 +0.0007726362625139043,84.78706395718099,1112.1458394015558,1112.1458394015526,52.959325685788386,52.95932568578822 +0.0007813125695216907,85.73917898578163,1071.9407609908144,1071.9407609908114,51.04479814241974,51.044798142419594 +0.0007899888765294772,86.6912940143823,1033.7355102199413,1033.7355102199388,49.22550048666387,49.22550048666375 +0.0007986651835372636,87.64340904298294,997.4031248979969,997.4031248979941,47.49538689990462,47.495386899904474 +0.00080734149054505,88.59552407158358,962.8265453614575,962.8265453614551,45.84888311245036,45.84888311245024 +0.0008160177975528364,89.54763910018423,929.8977009293342,929.8977009293319,44.280842901396866,44.28084290139675 +0.0008246941045606229,90.49975412878489,898.5166935206915,898.516693520689,42.78650921527102,42.78650921527091 +0.0008333704115684093,91.45186915738553,868.5910668080508,868.5910668080489,41.36147937181194,41.36147937181185 +0.0008420467185761958,92.40398418598619,840.0351508178439,840.0351508178419,40.00167384846876,40.001673848468656 +0.0008507230255839822,93.35609921458683,812.7694732055757,812.7694732055741,38.70330824788456,38.70330824788448 +0.0008593993325917686,94.30821424318749,786.7202295628125,786.7202295628107,37.46286807441964,37.46286807441955 +0.000868075639599555,95.26032927178814,761.8188060841596,761.8188060841582,36.27708600400761,36.27708600400753 +0.0008767519466073415,96.2124443003888,738.001348758909,738.0013487589074,35.14292136947186,35.14292136947178 +0.0008854282536151279,97.16455932898944,715.2083739741461,715.2083739741445,34.057541617816476,34.0575416178164 +0.0008941045606229144,98.1166743575901,693.3844160406686,693.3844160406672,33.01830552574613,33.01830552574606 +0.0009027808676307007,99.06878938619074,672.4777076943408,672.4777076943393,32.0227479854448,32.022747985444724 +0.0009114571746384872,100.02090441479139,652.4398900954515,652.4398900954502,31.0685661950215,31.06856619502144 +0.0009201334816462736,100.97301944339203,633.225749257505,633.2257492575036,30.153607107500243,30.15360710750017 +0.0009288097886540601,101.92513447199269,614.7929761931673,614.792976193166,29.27585600919844,29.27585600919838 +0.0009374860956618465,102.87724950059334,597.1019483762302,597.1019483762288,28.43342611315382,28.433426113153757 +0.000946162402669633,103.829364529194,580.1155303905914,580.1155303905904,27.62454906621864,27.624549066218588 +0.0009548387096774193,104.78147955779464,563.7988918757455,563.7988918757444,26.847566279797405,26.847566279797352 +0.0009635150166852058,105.7335945863953,548.1193410876435,548.1193410876425,26.1009210041735,26.100921004173447 +0.0009721913236929922,106.68570961499594,533.0461725779065,533.0461725779053,25.383151075138407,25.383151075138347 +0.0009808676307007788,107.63782464359662,518.5505276565987,518.5505276565976,24.69288226936184,24.69288226936179 +0.0009895439377085652,108.58993967219726,504.60526644694534,504.6052664469444,24.028822211759298,24.028822211759255 +0.0009982202447163515,109.5420547007979,491.1848504669692,491.1848504669684,23.38975478414139,23.389754784141353 +0.001006896551724138,110.49416972939855,478.2652347851927,478.2652347851921,22.774534989771084,22.774534989771052 +0.0010155728587319245,111.44628475799921,465.82376889706467,465.823768897064,22.182084233193557,22.182084233193525 +0.0010242491657397109,112.39839978659985,453.83910555733587,453.83910555733513,21.611385978920755,21.61138597892072 +0.0010329254727474973,113.3505148152005,442.29111688248236,442.29111688248156,21.061481756308684,21.061481756308645 +0.0010416017797552836,114.30262984380114,431.1608171077934,431.1608171077927,20.531467481323492,20.53146748132346 +0.00105027808676307,115.25474487240179,420.430291446848,420.43029144684715,20.020490068897523,20.020490068897484 +0.0010589543937708566,116.20685990100246,410.0826305578228,410.0826305578222,19.527744312277278,19.527744312277246 +0.001067630700778643,117.1589749296031,400.10187017214304,400.1018701721424,19.052470008197286,19.052470008197258 +0.0010763070077864294,118.11108995820375,390.47293548717886,390.47293548717835,18.59394930891328,18.593949308913253 +0.0010849833147942157,119.0632049868044,381.181589966645,381.18158996664465,18.151504284125952,18.151504284125934 +0.0010936596218020023,120.01532001540507,372.2143882306066,372.21438823060623,17.724494677647932,17.724494677647915 +0.0011023359288097887,120.96743504400571,363.55863275214165,363.5586327521412,17.312315845340077,17.31231584534006 +0.001111012235817575,121.91955007260636,355.2023341101371,355.2023341101366,16.91439686238748,16.914396862387456 +0.0011196885428253615,122.871665101207,347.1341745779418,347.13417457794134,16.5301987894258,16.53019878942578 +0.001128364849833148,123.82378012980766,339.34347485600233,339.3434748560019,16.159213088381062,16.159213088381044 +0.0011370411568409344,124.7758951584083,331.8201637836238,331.82016378362346,15.800960180172563,15.800960180172542 +0.0011457174638487208,125.72801018700895,324.5547508909871,324.5547508909868,15.454988137666053,15.454988137666035 +0.0011543937708565072,126.68012521560959,317.538301677934,317.53830167793365,15.120871508473048,15.120871508473028 +0.0011630700778642938,127.63224024421027,310.76241553117467,310.76241553117444,14.798210263389272,14.798210263389258 +0.0011717463848720802,128.5843552728109,304.21920621693766,304.21920621693727,14.486628867473222,14.4866288674732 +0.0011804226918798665,129.53647030141155,297.9012849120895,297.90128491208924,14.185775472004263,14.185775472004249 +0.0011890989988876531,130.48858533001223,291.80174576393574,291.8017457639355,13.895321226854087,13.895321226854069 +0.0011977753058954395,131.44070035861287,285.9141539977951,285.91415399779487,13.61495971418072,13.614959714180708 +0.0012064516129032259,132.39281538721352,280.2325366226954,280.23253662269514,13.344406505842638,13.344406505842626 +0.0012151279199110123,133.34493041581416,274.75137581987394,274.7513758198737,13.083398848565425,13.083398848565414 +0.0012238042269187988,134.29704544441483,269.4656051370566,269.4656051370564,12.831695482716981,12.83169548271697 +0.0012324805339265852,135.24916047301548,264.37060865474916,264.37060865474893,12.589076602607104,12.589076602607093 +0.0012411568409343716,136.20127550161612,259.4622233401996,259.4622233401994,12.355343968580936,12.355343968580922 +0.001249833147942158,137.15339053021677,254.7367448617378,254.73674486173758,12.130321183892278,12.130321183892265 +0.0012585094549499446,138.1055055588174,250.19093720257217,250.190937202572,11.913854152503436,11.91385415250343 +0.001267185761957731,139.05762058741806,245.82204649091264,245.82204649091238,11.705811737662508,11.705811737662493 +0.0012758620689655173,140.0097356160187,241.62781955496845,241.62781955496823,11.506086645474687,11.506086645474678 +0.0012845383759733037,140.96185064461935,237.60652781993582,237.60652781993565,11.314596562854087,11.314596562854078 +0.0012932146829810903,141.91396567322002,233.75699729308832,233.75699729308815,11.131285585385159,11.131285585385148 +0.0013018909899888767,142.86608070182066,230.07864553676103,230.07864553676092,10.956125977941003,10.956125977940996 +0.001310567296996663,143.8181957304213,226.57152671232618,226.57152671232603,10.789120319634579,10.789120319634574 +0.0013192436040044494,144.77031075902195,223.23638599694766,223.23638599694755,10.630304095092747,10.63030409509274 +0.0013279199110122358,145.7224257876226,220.07472493542554,220.07472493542537,10.479748806448834,10.479748806448827 +0.0013365962180200224,146.67454081622327,217.08887959880389,217.08887959880383,10.337565695181137,10.337565695181135 +0.0013452725250278088,147.62665584482392,214.28211378665804,214.282113786658,10.203910180317049,10.203910180317047 +0.0013539488320355952,148.57877087342456,211.65872993719134,211.65872993719137,10.078987139866255,10.078987139866255 +0.0013626251390433815,149.5308859020252,209.22420090180472,209.22420090180466,9.963057185800226,9.963057185800222 +0.0013713014460511681,150.48300093062588,206.98532629587186,206.98532629587183,9.856444109327231,9.85644410932723 +0.0013799777530589545,151.43511595922652,204.95041774048465,204.95041774048474,9.759543701927843,9.759543701927845 +0.0013886540600667409,152.38723098782717,203.1295179246339,203.12951792463394,9.672834186887327,9.672834186887329 +0.0013973303670745273,153.3393460164278,201.5346589692297,201.53465896922972,9.59688852234427,9.596888522344273 +0.0014060066740823138,154.29146104502848,200.18016592348636,200.1801659234864,9.53238885349935,9.532388853499354 +0.0014146829810901002,155.24357607362913,199.08301112006916,199.08301112006916,9.48014338666996,9.480143386669958 +0.0014233592880978866,156.19569110222977,198.26322412494235,198.26322412494244,9.441105910711542,9.441105910711542 +0.001432035595105673,157.14780613083042,197.74435941127854,197.74435941127862,9.416398067203739,9.416398067203744 +0.0014407119021134596,158.0999211594311,197.5540184626061,197.55401846260622,9.407334212505052,9.40733421250506 +0.001449388209121246,159.05203618803174,197.72441283886562,197.72441283886587,9.415448230422172,9.415448230422184 +0.0014580645161290323,160.00415121663238,198.2929368146694,198.29293681466967,9.442520800698542,9.442520800698556 +0.0014667408231368187,160.95626624523302,199.30268803955815,199.30268803955846,9.490604192359912,9.490604192359926 +0.0014754171301446053,161.90838127383367,200.80282603719598,200.80282603719635,9.562039335104568,9.562039335104588 +0.0014840934371523917,162.8604963024343,202.84858361297756,202.84858361297793,9.659456362522743,9.659456362522759 +0.001492769744160178,163.81261133103496,205.5006385531685,205.50063855316893,9.785744693008022,9.785744693008043 +0.0015014460511679644,164.7647263596356,208.823413039976,208.82341303997651,9.943972049522667,9.943972049522692 +0.001510122358175751,165.71684138823628,212.88171981122215,212.8817198112228,10.13722475291534,10.137224752915373 +0.0015187986651835374,166.66895641683692,217.73509469108478,217.73509469108544,10.36833784243261,10.368337842432638 +0.0015274749721913238,167.62107144543756,223.42932148307378,223.4293214830746,10.639491499193989,10.63949149919403 +0.0015361512791991102,168.5731864740382,229.98537937580153,229.98537937580244,10.951684732181025,10.951684732181068 +0.0015448275862068967,169.52530150263888,237.38770615675264,237.38770615675367,11.304176483654889,11.304176483654937 +0.0015535038932146831,170.47741653123953,245.57636604997322,245.5763660499745,11.694112669046344,11.694112669046405 +0.0015621802002224695,171.42953155984017,254.45044464351605,254.4504446435174,12.11668784016743,12.116687840167494 +0.0015708565072302559,172.38164658844082,263.88987210184644,263.8898721018478,12.56618438580221,12.566184385802277 +0.0015795328142380425,173.3337616170415,273.7959887007753,273.7959887007767,13.037904223846443,13.03790422384651 +0.0015882091212458288,174.28587664564213,284.13701192711875,284.13701192712045,13.530333901291367,13.530333901291447 +0.0015968854282536152,175.23799167424278,294.97129355613737,294.97129355613913,14.046252074101776,14.046252074101861 +0.0016055617352614016,176.19010670284342,306.42249066246166,306.4224906624634,14.591547174402937,14.59154717440302 +0.001614238042269188,177.14222173144407,318.60119656861076,318.60119656861247,15.171485550886228,15.171485550886306 +0.0016229143492769746,178.09433676004474,331.49458243427347,331.49458243427523,15.785456306393975,15.785456306394057 +0.001631590656284761,179.04645178864538,344.8625286324973,344.86252863249877,16.42202517297606,16.42202517297613 +0.0016402669632925473,179.99856681724603,358.18197751540356,358.1819775154049,17.056284643590647,17.05628464359071 +0.0016489432703003337,180.95068184584667,370.67556316267456,370.67556316267576,17.651217293460693,17.65121729346075 +0.0016576195773081203,181.90279687444735,381.4422933283181,381.44229332831895,18.16391872991991,18.16391872991995 +0.0016662958843159067,182.854911903048,389.66642343015644,389.6664234301569,18.555543972864594,18.555543972864612 +0.001674972191323693,183.80702693164864,394.82392774786473,394.823927747865,18.801139416564986,18.801139416565 +0.0016836484983314794,184.75914196024928,396.7784967671058,396.7784967671055,18.894214131766944,18.89421413176693 +0.001692324805339266,185.71125698884995,395.706479704101,395.70647970410016,18.843165700195286,18.843165700195247 +0.0017010011123470524,186.6633720174506,391.89579890593586,391.8957989059347,18.661704709806468,18.661704709806415 +0.0017096774193548388,187.61548704605124,385.54657383997494,385.5465738399736,18.359360659046427,18.35936065904636 +0.0017183537263626252,188.5676020746519,376.69560913210705,376.6956091321054,17.93788614914795,17.937886149147875 +0.0017270300333704117,189.51971710325253,365.30146623193866,365.30146623193684,17.395307915806605,17.395307915806516 +0.0017357063403781981,190.47183213185318,351.4228625273767,351.4228625273748,16.734422025113176,16.734422025113084 +0.0017443826473859845,191.42394716045382,335.3678006075511,335.367800607549,15.969895267026242,15.969895267026141 +0.0017530589543937709,192.37606218905447,317.7199471710852,317.719947171083,15.129521293861202,15.129521293861094 +0.0017617352614015575,193.32817721765514,299.23726410919556,299.23726410919346,14.249393529009314,14.249393529009211 +0.0017704115684093438,194.28029224625578,280.6934460964336,280.69344609643167,13.366354576020651,13.366354576020557 +0.0017790878754171302,195.23240727485643,262.74393686880927,262.7439368688075,12.51161604137187,12.511616041371783 +0.0017877641824249166,196.18452230345707,245.85826698734087,245.85826698733928,11.707536523206707,11.707536523206631 +0.0017964404894327032,197.13663733205775,230.31530735598466,230.3153073559832,10.96739558838022,10.967395588380153 +0.0018051167964404896,198.0887523606584,216.23609964970888,216.23609964970757,10.296957126176613,10.296957126176551 +0.001813793103448276,199.04086738925903,203.62915138155546,203.6291513815543,9.696626256264546,9.69662625626449 +0.0018224694104560623,199.99298241785968,192.43261461709255,192.4326146170916,9.163457838909169,9.163457838909123 +0.001831145717463849,200.94509744646035,182.54700358734408,182.54700358734323,8.692714456540195,8.692714456540152 +0.0018398220244716353,201.897212475061,173.8577730136236,173.8577730136229,8.278941572077315,8.278941572077281 +0.0018484983314794217,202.84932750366164,166.2495342660148,166.2495342660142,7.916644488857848,7.916644488857818 +0.001857174638487208,203.80144253226229,159.61424052199163,159.61424052199118,7.600678120094841,7.600678120094818 +0.0018658509454949944,204.75355756086293,153.85538991886511,153.85538991886472,7.326447138993576,7.326447138993557 +0.001874527252502781,205.7056725894636,148.88977921969808,148.88977921969777,7.089989486652291,7.089989486652274 +0.0018832035595105674,206.65778761806425,144.64785275035527,144.64785275035504,6.887992988112156,6.887992988112144 +0.0018918798665183538,207.6099026466649,141.07331252610027,141.07331252610007,6.717776786957155,6.717776786957146 +0.0019005561735261401,208.56201767526554,138.12238648799934,138.1223864879992,6.577256499428539,6.5772564994285325 +0.0019092324805339267,209.5141327038662,135.76296749507844,135.76296749507839,6.464903214051356,6.464903214051352 +0.0019179087875417131,210.46624773246685,133.97370777410367,133.9737077741037,6.379700370195414,6.3797003701954145 +0.0019265850945494995,211.4183627610675,132.74305832448675,132.7430583244868,6.32109801545175,6.321098015451753 +0.0019352614015572859,212.37047778966814,132.06816441541477,132.06816441541488,6.288960210257846,6.288960210257852 +0.0019439377085650725,213.3225928182688,131.95346176179038,131.95346176179058,6.2834981791328754,6.283498179132884 +0.0019526140155728588,214.27470784686943,132.40877595943942,132.40877595943977,6.305179807592355,6.305179807592369 +0.001961290322580645,215.22682287547008,133.44675274479943,133.44675274479977,6.354607273561877,6.354607273561894 +0.0019699666295884314,216.17893790407072,135.0796222938045,135.07962229380496,6.4323629663716435,6.432362966371663 +0.001978642936596218,217.1310529326714,137.31573960624263,137.31573960624314,6.53884474315441,6.538844743154435 +0.0019873192436040046,218.08316796127204,140.15709722609375,140.15709722609432,6.674147486956846,6.674147486956872 +0.001995995550611791,219.03528298987268,143.5998462535425,143.59984625354323,6.838087916835358,6.838087916835391 +0.0020046718576195773,219.98739801847333,147.6400027839788,147.64000278397964,7.030476323046608,7.030476323046649 +0.0020133481646273637,220.93951304707397,152.28474505297962,152.28474505298053,7.251654526332363,7.2516545263324055 +0.00202202447163515,221.89162807567462,157.5655238946794,157.5655238946805,7.503120185460924,7.5031201854609755 +0.0020307007786429365,222.84374310427526,163.54453712912982,163.54453712913096,7.7878351013871345,7.787835101387188 +0.002039377085650723,223.7958581328759,170.3049716283241,170.30497162832546,8.10976055372972,8.109760553729782 +0.0020480533926585096,224.7479731614766,177.9199478236162,177.91994782361763,8.472378467791248,8.472378467791316 +0.002056729699666296,225.70008819007725,186.4023744985975,186.4023744985991,8.876303547552261,8.876303547552336 +0.0020654060066740824,226.6522032186779,195.6432095210311,195.64320952103287,9.316343310525292,9.316343310525372 +0.0020740823136818688,227.60431824727854,205.34865699351485,205.34865699351664,9.77850747588166,9.778507475881742 +0.002082758620689655,228.5564332758792,214.99196435990746,214.99196435990933,10.237712588567021,10.23771258856711 +0.0020914349276974415,229.50854830447983,223.80491770525097,223.80491770525288,10.65737703358338,10.65737703358347 +0.002100111234705228,230.46066333308048,230.84111603525886,230.84111603526068,10.992434096917089,10.992434096917176 +0.0021087875417130143,231.41277836168112,235.13178940362542,235.1317894036273,11.196751876363116,11.196751876363205 +0.002117463848720801,232.36489339028182,235.912980892198,235.9129808921997,11.233951471057047,11.233951471057129 +0.0021261401557285875,233.31700841888247,232.8449684102831,232.84496841028457,11.087855638584909,11.08785563858498 +0.002134816462736374,234.2691234474831,226.11926110017185,226.11926110017313,10.767583861912945,10.767583861913007 +0.0021434927697441602,235.22123847608376,216.3954091959292,216.39540919593037,10.304543295044247,10.304543295044304 +0.0021521690767519466,236.1733535046844,204.60363325073888,204.6036332507399,9.74303015479709,9.743030154797138 +0.002160845383759733,237.12546853328504,191.71350437310443,191.7135043731053,9.129214493957353,9.129214493957395 +0.0021695216907675193,238.0775835618857,178.55954410094154,178.5595441009423,8.502835433378168,8.502835433378205 +0.002178197997775306,239.02969859048636,165.75885635722113,165.7588563572217,7.893278874153388,7.893278874153415 +0.0021868743047830925,239.981813619087,153.706007684744,153.70600768474458,7.319333699273525,7.319333699273551 +0.002195550611790879,240.93392864768765,142.6109773216898,142.61097732169026,6.790998920080467,6.790998920080488 +0.0022042269187986653,241.8860436762883,132.55037521492514,132.55037521492557,6.311922629282149,6.311922629282168 +0.0022129032258064517,242.83815870488894,123.514594068322,123.5145940683224,5.881647336586761,5.881647336586781 +0.002221579532814238,243.79027373348958,115.44423187856984,115.44423187857025,5.497344375169992,5.4973443751700115 +0.0022302558398220244,244.74238876209023,108.25529195991642,108.25529195991678,5.155013902853162,5.155013902853179 +0.002238932146829811,245.69450379069087,101.85521417235967,101.85521417235995,4.850248293921889,4.8502482939219025 +0.0022476084538375976,246.64661881929158,96.15230201266667,96.15230201266692,4.578681048222222,4.578681048222235 +0.002256284760845384,247.59873384789222,91.06076264590887,91.06076264590914,4.336226792662327,4.336226792662339 +0.0022649610678531704,248.55084887649286,86.50300686655783,86.50300686655808,4.11919080316942,4.119190803169431 +0.0022736373748609567,249.5029639050935,82.41033188665685,82.41033188665703,3.924301518412231,3.924301518412239 +0.002282313681868743,250.45507893369415,78.72270975082611,78.72270975082634,3.7487004643250534,3.748700464325064 +0.0022909899888765295,251.4071939622948,75.38812626603287,75.38812626603305,3.589910774572994,3.589910774573002 +0.002299666295884316,252.35930899089544,72.36173316053625,72.36173316053642,3.4457968171683926,3.4457968171684006 +0.0023083426028921022,253.3114240194961,69.60496151874474,69.6049615187449,3.314521977083083,3.31452197708309 +0.002317018909899889,254.2635390480968,67.08467467558265,67.08467467558278,3.194508317884888,3.1945083178848943 +0.0023256952169076754,255.21565407669743,64.77239747813898,64.77239747813911,3.0843998799113805,3.0843998799113863 +0.002334371523915462,256.1677691052981,62.64363526934255,62.64363526934267,2.983030250921074,2.9830302509210793 +0.002343047830923248,257.1198841338987,60.677283123467035,60.67728312346715,2.889394434450811,2.889394434450817 +0.0023517241379310346,258.07199916249937,58.855119383842876,58.855119383842975,2.8026247325639466,2.8026247325639515 +0.002360400444938821,259.0241141911,57.16137472918241,57.161374729182505,2.721970225199162,2.721970225199167 +0.0023690767519466073,259.97622921970066,55.58236720407777,55.582367204077855,2.6467793906703694,2.646779390670374 +0.0023777530589543937,260.9283442483013,54.10619393019899,54.10619393019908,2.5764854252475713,2.576485425247575 +0.0023864293659621805,261.880459276902,52.722471009359516,52.722471009359595,2.5105938575885482,2.5105938575885522 +0.002395105672969967,262.83257430550265,51.42211412253145,51.42211412253154,2.4486721010729258,2.44867210107293 +0.0024037819799777533,263.7846893341033,50.19715334883625,50.19715334883634,2.3903406356588692,2.3903406356588732 +0.0024124582869855396,264.73680436270394,49.040576688546146,49.040576688546224,2.3352655565974354,2.335265556597439 +0.002421134593993326,265.6889193913046,47.94619763544782,47.94619763544788,2.283152268354658,2.2831522683546606 +0.0024298109010011124,266.6410344199052,46.90854289456648,46.90854289456655,2.233740137836499,2.2337401378365023 +0.0024384872080088988,267.59314944850587,45.92275698336356,45.92275698336361,2.186797951588741,2.1867979515887437 +0.002447163515016685,268.5452644771065,44.984520997089845,44.9845209970899,2.1421200474804687,2.1421200474804714 +0.0024558398220244715,269.49737950570716,44.089983273700255,44.0899832737003,2.0995230130333455,2.0995230130333473 +0.0024645161290322583,270.4494945343078,43.2357000727933,43.23570007279334,2.0588428606092046,2.058842860609207 +0.0024731924360400447,271.40160956290845,42.41858469794272,42.41858469794277,2.019932604663939,2.0199326046639414 +0.002481868743047831,272.3537245915091,41.635863752880276,41.635863752880326,1.9826601787085845,1.9826601787085867 +0.0024905450500556175,273.30583962010974,40.88503943825771,40.885039438257756,1.9469066399170336,1.9469066399170356 +0.002499221357063404,274.2579546487104,40.163856974826246,40.163856974826274,1.9125646178488687,1.9125646178488702 +0.00250789766407119,275.210069677311,39.47027638727552,39.47027638727557,1.879536970822644,1.8795369708226457 +0.0025165739710789766,276.16218470591167,38.80244800603923,38.802448006039256,1.847735619335201,1.8477356193352028 +0.002525250278086763,277.1142997345123,38.15869114655403,38.15869114655406,1.8170805307882874,1.8170805307882887 +0.0025339265850945498,278.066414763113,37.537475510422155,37.53747551042218,1.7874988338296265,1.7874988338296278 +0.002542602892102336,279.01852979171366,36.93740492369475,36.93740492369479,1.758924043985464,1.758924043985466 +0.0025512791991101225,279.9706448203143,36.35720308654302,36.35720308654305,1.731295385073477,1.7312953850734787 +0.002559955506117909,280.92275984891495,35.79570105795442,35.79570105795444,1.7045571932359245,1.7045571932359258 +0.0025686318131256953,281.8748748775156,35.25182624044939,35.25182624044942,1.6786583924023522,1.678658392402353 +0.0025773081201334817,282.82698990611624,34.72459266453554,34.724592664535564,1.6535520316445496,1.6535520316445507 +0.002585984427141268,283.7791049347169,34.21309240182559,34.21309240182562,1.6291948762774093,1.6291948762774102 +0.0025946607341490544,284.7312199633175,33.71648796037193,33.71648796037195,1.605547045731997,1.6055470457319974 +0.0026033370411568412,285.68333499191823,33.23400553657735,33.23400553657736,1.582571692217969,1.5825716922179693 +0.0026120133481646276,286.6354500205189,32.76492901565881,32.764929015658836,1.5602347150313718,1.560234715031373 +0.002620689655172414,287.5875650491195,32.30859462759059,32.30859462759061,1.5385045060757423,1.538504506075743 +0.0026293659621802004,288.53968007772016,31.864386178165493,31.864386178165507,1.5173517227697855,1.517351722769786 +0.0026380422691879867,289.4917951063208,31.431730785645527,31.43173078564555,1.4967490850307392,1.4967490850307406 +0.002646718576195773,290.44391013492145,31.010095062724204,31.010095062724233,1.4766711934630574,1.4766711934630588 +0.0026553948832035595,291.3960251635221,30.598981691438652,30.598981691438663,1.4570943662589835,1.457094366258984 +0.002664071190211346,292.34814019212274,30.197926345457336,30.19792634545735,1.4379964926408257,1.437996492640826 +0.0026727474972191327,293.30025522072344,29.806494920001313,29.80649492000133,1.4193569009524436,1.4193569009524443 +0.002681423804226919,294.2523702493241,29.42428103467727,29.42428103467729,1.4011562397465367,1.4011562397465376 +0.0026901001112347054,295.20448527792473,29.050903778831163,29.050903778831184,1.3833763704205315,1.3833763704205324 +0.002698776418242492,296.1566003065254,28.68600567277457,28.686005672774584,1.3660002701321223,1.366000270132123 +0.002707452725250278,297.108715335126,28.329250821475515,28.329250821475526,1.3490119438797865,1.3490119438797867 +0.0027161290322580646,298.06083036372667,27.98032324011701,27.980323240117016,1.3323963447674767,1.3323963447674771 +0.002724805339265851,299.0129453923273,27.638925333369308,27.638925333369322,1.3161393015890146,1.3161393015890153 +0.0027334816462736373,299.96506042092795,27.304776512348216,27.304776512348226,1.3002274529689626,1.3002274529689632 +0.0027421579532814237,300.9171754495286,26.977611935086674,26.977611935086685,1.2846481873850795,1.2846481873850801 +0.0027508342602892105,301.8692904781293,26.65718135796647,26.657181357966472,1.2693895884745938,1.2693895884745938 +0.002759510567296997,302.82140550672995,26.343248086975144,26.34324808697515,1.2544403850940542,1.2544403850940549 +0.0027681868743047833,303.7735205353306,26.03558801889492,26.035588018894934,1.239789905661663,1.2397899056616635 +0.0027768631813125696,304.72563556393123,25.73398876362205,25.733988763622058,1.2254280363629546,1.2254280363629553 +0.002785539488320356,305.6777505925319,25.4382488397733,25.43824883977331,1.2113451828463475,1.211345182846348 +0.0027942157953281424,306.6298656211325,25.148176936581677,25.148176936581685,1.197532235075318,1.1975322350753184 +0.0028028921023359288,307.58198064973317,24.863591235827574,24.863591235827577,1.1839805350394084,1.1839805350394084 +0.002811568409343715,308.5340956783338,24.584318788210258,24.58431878821027,1.1706818470576312,1.1706818470576317 +0.002820244716351502,309.4862107069345,24.310194939146108,24.31019493914611,1.1576283304355288,1.157628330435529 +0.0028289210233592883,310.43832573553516,24.04106279949593,24.04106279949594,1.144812514261711,1.1448125142617116 +0.0028375973303670747,311.3904407641358,23.77677275718071,23.776772757180716,1.1322272741514625,1.1322272741514627 +0.002846273637374861,312.34255579273645,23.51718202605179,23.517182026051792,1.1198658107643709,1.119865810764371 +0.0028549499443826475,313.2946708213371,23.26215422874249,23.262154228742492,1.1077216299401185,1.1077216299401185 +0.002863626251390434,314.24678584993774,23.011559010550506,23.01155901055051,1.095788524311929,1.095788524311929 +0.00287230255839822,315.1989008785384,22.765271681686713,22.76527168168672,1.0840605562707957,1.0840605562707961 +0.0028809788654060066,316.151015907139,22.523172885483273,22.523172885483277,1.0725320421658702,1.0725320421658702 +0.0028896551724137934,317.10313093573967,22.28514829038238,22.285148290382384,1.0611975376372562,1.0611975376372564 +0.0028983314794215798,318.0552459643403,22.05108830373242,22.05108830373242,1.050051823987258,1.050051823987258 +0.002907007786429366,319.00736099294096,21.820887805602098,21.8208878056021,1.039089895504862,1.039089895504862 +0.0029156840934371525,319.9594760215416,21.59444590098837,21.59444590098838,1.0283069476661126,1.0283069476661133 +0.002924360400444939,320.91159105014225,21.37166568894147,21.37166568894148,1.01769836614007,1.0176983661400705 +0.0029330367074527253,321.8637060787429,21.152454047264676,21.152454047264676,1.0072597165364132,1.0072597165364132 +0.0029417130144605117,322.81582110734354,20.93672143156539,20.93672143156539,0.996986734836447,0.996986734836447 +0.002950389321468298,323.7679361359442,20.724381687543122,20.72438168754313,0.9868753184544343,0.9868753184544345 +0.002959065628476085,324.7200511645449,20.51535187549706,20.51535187549707,0.9769215178808124,0.9769215178808128 +0.0029677419354838712,325.67216619314553,20.309552106124258,20.30955210612427,0.9671215288630599,0.9671215288630604 +0.0029764182424916576,326.6242812217462,20.106905386759095,20.106905386759106,0.9574716850837666,0.9574716850837668 +0.002985094549499444,327.5763962503468,19.907337477277064,19.907337477277068,0.9479684512989078,0.9479684512989079 +0.0029937708565072304,328.52851127894746,19.71077675495087,19.710776754950878,0.9386084169024225,0.9386084169024227 +0.0030024471635150167,329.4806263075481,19.517154087606517,19.517154087606528,0.9293882898860246,0.9293882898860251 +0.003011123470522803,330.43274133614875,19.326402714480665,19.32640271448066,0.9203048911657459,0.9203048911657457 +0.0030197997775305895,331.3848563647494,19.13845813422943,19.138458134229435,0.9113551492490206,0.9113551492490207 +0.003028476084538376,332.33697139335004,18.95325799958343,18.953257999583432,0.9025360952182586,0.9025360952182585 +0.0030371523915461627,333.28908642195074,18.770742018184144,18.770742018184148,0.8938448580087688,0.893844858008769 +0.003045828698553949,334.2412014505514,18.59085185917383,18.590851859173835,0.8852786599606586,0.8852786599606588 +0.0030545050055617354,335.19331647915203,18.413531065144397,18.4135310651444,0.8768348126259236,0.8768348126259237 +0.003063181312569522,336.1454315077527,18.23872496908203,18.23872496908203,0.86851071281343,0.86851071281343 +0.003071857619577308,337.0975465363533,18.066380615971756,18.066380615971752,0.860303838855798,0.8603038388557978 +0.0030805339265850946,338.04966156495396,17.896446688752203,17.896446688752206,0.8522117470834383,0.8522117470834384 +0.003089210233592881,339.0017765935546,17.728873438333977,17.72887343833398,0.8442320684920941,0.8442320684920943 +0.0030978865406006673,339.95389162215525,17.563612617416943,17.56361261741695,0.8363625055912831,0.8363625055912831 +0.003106562847608454,340.90600665075596,17.400617417860932,17.40061741786093,0.8286008294219491,0.8286008294219491 +0.0031152391546162405,341.8581216793566,17.239842411382934,17.239842411382938,0.8209448767325207,0.8209448767325207 +0.003123915461624027,342.81023670795724,17.081243493369996,17.08124349337,0.8133925473033332,0.8133925473033332 +0.0031325917686318133,343.7623517365579,16.92477782961257,16.92477782961257,0.8059418014101224,0.8059418014101222 +0.0031412680756395996,344.71446676515853,16.770403805777004,16.770403805777,0.7985906574179525,0.7985906574179524 +0.003149944382647386,345.6665817937592,16.6180809794487,16.6180809794487,0.7913371894975572,0.7913371894975569 +0.0031586206896551724,346.6186968223598,16.467770034589208,16.467770034589215,0.7841795254566291,0.7841795254566292 +0.0031672969966629588,347.57081185096047,16.31943273826193,16.319432738261927,0.7771158446791395,0.7771158446791394 +0.0031759733036707456,348.52292687956117,16.17303189949047,16.173031899490475,0.770144376166213,0.7701443761662131 +0.003184649610678532,349.4750419081618,16.028531330123755,16.028531330123766,0.7632633966725599,0.7632633966725602 +0.0031933259176863183,350.42715693676246,15.885895807589865,15.885895807589867,0.7564712289328508,0.7564712289328508 +0.0032020022246941047,351.3792719653631,15.745091039429159,15.745091039429163,0.7497662399728171,0.7497662399728173 +0.003210678531701891,352.33138699396375,15.606083629504072,15.606083629504074,0.7431468395001939,0.743146839500194 +0.0032193548387096775,353.2835020225644,15.468841045790105,15.468841045790104,0.7366114783709573,0.7366114783709573 +0.003228031145717464,354.23561705116504,15.333331589658531,15.333331589658544,0.7301586471265967,0.7301586471265973 +0.00323670745272525,355.1877320797657,15.199524366567461,15.199524366567461,0.7237868745984506,0.7237868745984504 +0.003245383759733037,356.1398471083664,15.06738925808298,15.06738925808298,0.7174947265753799,0.7174947265753799 +0.0032540600667408234,357.091962136967,14.936896895157519,14.936896895157517,0.7112808045313104,0.7112808045313104 +0.0032627363737486098,358.04407716556767,14.808018632596582,14.808018632596582,0.7051437444093611,0.7051437444093611 +0.003271412680756396,358.9961921941683,14.680726524650105,14.680726524650108,0.6990822154595289,0.699082215459529 +0.0032800889877641825,359.94830722276896,14.554993301668041,14.554993301668045,0.6930949191270496,0.6930949191270497 +0.003288765294771969,360.9004222513696,14.430792347763822,14.43079234776382,0.6871805879887534,0.6871805879887534 +0.0032974416017797553,361.85253727997025,14.308097679432784,14.30809767943279,0.6813379847348945,0.6813379847348947 +0.0033061179087875417,362.8046523085709,14.186883925075833,14.186883925075835,0.6755659011940873,0.6755659011940873 +0.003314794215795328,363.75676733717154,14.06712630538156,14.067126305381564,0.6698631573991218,0.6698631573991222 +0.003323470522803115,364.7088823657722,13.94880061452304,13.948800614523044,0.6642286006915733,0.6642286006915735 +0.0033321468298109012,365.6609973943728,13.831883202127887,13.831883202127885,0.6586611048632328,0.6586611048632327 +0.0033408231368186876,366.6131124229735,13.716350955982737,13.716350955982737,0.6531595693325113,0.6531595693325112 +0.003349499443826474,367.5652274515741,13.602181285435645,13.602181285435645,0.6477229183540782,0.6477229183540782 +0.0033581757508342604,368.51734248017476,13.489352105461856,13.489352105461856,0.6423501002600884,0.6423501002600884 +0.0033668520578420467,369.4694575087754,13.377841821360509,13.377841821360507,0.6370400867314527,0.6370400867314527 +0.003375528364849833,370.42157253737605,13.267629314051653,13.267629314051653,0.6317918720976977,0.6317918720976978 +0.0033842046718576195,371.3736875659767,13.158693925944679,13.158693925944682,0.6266044726640323,0.6266044726640325 +0.0033928809788654063,372.3258025945774,13.051015447350927,13.051015447350927,0.6214769260643298,0.6214769260643298 +0.0034015572858731927,373.27791762317804,12.944574103414805,12.94457410341481,0.6164082906388002,0.6164082906388004 +0.003410233592880979,374.2300326517787,12.839350541538884,12.839350541538884,0.6113976448351849,0.6113976448351849 +0.0034189098998887654,375.18214768037933,12.73532581928028,12.735325819280288,0.6064440866323942,0.6064440866323946 +0.003427586206896552,376.13426270898,12.632481392696457,12.632481392696457,0.6015467329855455,0.6015467329855455 +0.003436262513904338,377.0863777375806,12.530799105119849,12.530799105119849,0.5967047192914213,0.5967047192914213 +0.0034449388209121246,378.03849276618126,12.430261176341983,12.43026117634198,0.5919171988734276,0.5919171988734276 +0.003453615127919911,378.9906077947819,12.330850192188652,12.330850192188656,0.587183342485174,0.5871833424851741 +0.0034622914349276977,379.9427228233826,12.232549094468702,12.232549094468705,0.582502337831843,0.582502337831843 +0.003470967741935484,380.89483785198325,12.135341171279839,12.13534117127984,0.5778733891085637,0.5778733891085639 +0.0034796440489432705,381.8469528805839,12.039210047655967,12.03921004765597,0.5732957165550461,0.5732957165550462 +0.003488320355951057,382.79906790918454,11.944139676541045,11.944139676541045,0.568768556025764,0.568768556025764 +0.0034969966629588433,383.7511829377852,11.850114330075481,11.850114330075481,0.5642911585750229,0.564291158575023 +0.0035056729699666296,384.70329796638583,11.757118591181632,11.75711859118163,0.5598627900562682,0.5598627900562682 +0.003514349276974416,385.6554129949865,11.665137345435763,11.665137345435763,0.5554827307350364,0.5554827307350364 +0.0035230255839822024,386.6075280235871,11.574155773214347,11.574155773214345,0.5511502749149688,0.5511502749149688 +0.0035317018909899888,387.55964305218777,11.484159342103316,11.484159342103315,0.5468647305763483,0.5468647305763483 +0.0035403781979977756,388.51175808078847,11.39513379955935,11.395133799559348,0.5426254190266357,0.5426254190266355 +0.003549054505005562,389.4638731093891,11.307065165812787,11.307065165812787,0.5384316745625136,0.5384316745625136 +0.0035577308120133483,390.41598813798976,11.21993972700241,11.21993972700241,0.5342828441429719,0.5342828441429719 +0.0035664071190211347,391.3681031665904,11.133744028532627,11.133744028532629,0.5301782870729822,0.5301782870729823 +0.003575083426028921,392.32021819519105,11.048464868644222,11.048464868644226,0.5261173746973439,0.526117374697344 +0.0035837597330367075,393.2723332237917,10.96408929219005,10.964089292190048,0.5220994901042881,0.5220994901042879 +0.003592436040044494,394.22444825239234,10.880604584607692,10.88060458460769,0.5181240278384615,0.5181240278384615 +0.00360111234705228,395.176563280993,10.797998266081297,10.797998266081303,0.5141903936229189,0.5141903936229192 +0.003609788654060067,396.1286783095937,10.716258085885267,10.716258085885265,0.5102980040897747,0.5102980040897744 +0.0036184649610678534,397.0807933381943,10.6353720169027,10.6353720169027,0.5064462865191761,0.5064462865191761 +0.0036271412680756398,398.03290836679497,10.55532825031203,10.555328250312028,0.5026346785862872,0.502634678586287 +0.003635817575083426,398.9850233953956,10.476115190435316,10.476115190435316,0.4988626281159674,0.4988626281159674 +0.0036444938820912125,399.93713842399626,10.397721449742212,10.397721449742212,0.49512959284486724,0.4951295928448672 +0.003653170189098999,400.8892534525969,10.320135844003703,10.320135844003705,0.49143504019065254,0.49143504019065265 +0.0036618464961067853,401.84136848119755,10.243347387590074,10.243347387590074,0.4877784470280987,0.4877784470280987 +0.0036705228031145717,402.7934835097982,10.167345288907761,10.167345288907761,0.48415929947179814,0.4841592994717981 +0.0036791991101223585,403.7455985383989,10.092118945970055,10.092118945970054,0.48057709266524073,0.4805770926652406 +0.003687875417130145,404.69771356699954,10.017657942096768,10.017657942096768,0.47703133057603664,0.4770313305760365 +0.0036965517241379312,405.6498285956002,9.943952041738132,9.943952041738132,0.47352152579705387,0.4735215257970538 +0.0037052280311457176,406.6019436242008,9.87099118641861,9.870991186418612,0.4700471993532672,0.4700471993532672 +0.003713904338153504,407.5540586528015,9.798765490796285,9.798765490796285,0.46660788051410884,0.46660788051410884 +0.0037225806451612903,408.5061736814021,9.72726523883371,9.72726523883371,0.46320310661112896,0.46320310661112896 +0.0037312569521690767,409.45828871000276,9.656480880076408,9.656480880076407,0.4598324228607813,0.4598324228607813 +0.003739933259176863,410.4104037386034,9.586403026035171,9.586403026035171,0.456495382192151,0.456495382192151 +0.00374860956618465,411.36251876720405,9.517022446668665,9.517022446668665,0.4531915450794603,0.4531915450794602 +0.0037572858731924363,412.3146337958047,9.448330066962841,9.44833006696284,0.4499204793791829,0.4499204793791828 +0.0037659621802002227,413.26674882440534,9.380316963603818,9.380316963603816,0.4466817601716103,0.44668176017161026 +0.003774638487208009,414.218863853006,9.312974361741208,9.312974361741208,0.44347496960672417,0.44347496960672417 +0.0037833147942157954,415.17097888160663,9.246293631838704,9.246293631838704,0.440299696754224,0.440299696754224 +0.003791991101223582,416.1230939102073,9.180266286609083,9.180266286609083,0.4371555374575754,0.4371555374575754 +0.003800667408231368,417.0752089388079,9.114883978030822,9.114883978030822,0.4340420941919439,0.4340420941919438 +0.0038093437152391546,418.02732396740856,9.050138494443646,9.050138494443646,0.43095897592588794,0.4309589759258879 +0.003818020022246941,418.9794389960092,8.986021757720389,8.98602175772039,0.4279057979866852,0.42790579798668515 +0.0038266963292547277,419.9315540246099,8.922525820512746,8.922525820512746,0.4248821819291783,0.42488218192917826 +0.003835372636262514,420.88366905321055,8.859642863568489,8.85964286356849,0.42188775540802326,0.4218877554080233 +0.0038440489432703005,421.8357840818112,8.797365193117876,8.797365193117876,0.41892215205323224,0.41892215205323213 +0.003852725250278087,422.78789911041184,8.73568523832704,8.735685238327042,0.41598501134890664,0.4159850113489067 +0.0038614015572858732,423.7400141390125,8.674595548816253,8.674595548816251,0.4130759785150596,0.4130759785150596 +0.0038700778642936596,424.69212916761313,8.614088792240995,8.614088792240995,0.41019470439242844,0.41019470439242833 +0.003878754171301446,425.6442441962138,8.554157751933957,8.554157751933959,0.40734084533018844,0.40734084533018844 +0.0038874304783092324,426.5963592248144,8.494795324605933,8.494795324605933,0.40451406307647303,0.404514063076473 +0.003896106785317019,427.5484742534151,8.435994518103925,8.435994518103927,0.40171402467161554,0.40171402467161554 +0.0039047830923248056,428.50058928201577,8.377748449224693,8.377748449224693,0.39894040234403305,0.39894040234403294 +0.0039134593993325915,429.45270431061635,8.32005034158195,8.32005034158195,0.39619287340866427,0.39619287340866427 +0.003922135706340378,430.404819339217,8.262893523525712,8.262893523525713,0.393471120167891,0.39347112016789104 +0.003930812013348164,431.35693436781764,8.206271426112195,8.206271426112194,0.39077482981486644,0.39077482981486633 +0.003939488320355951,432.3090493964183,8.150177581122689,8.150177581122689,0.3881036943391757,0.38810369433917563 +0.003948164627363737,433.26116442501893,8.094605619130062,8.094605619130062,0.3854574104347649,0.38545741043476484 +0.003956840934371523,434.2132794536196,8.039549267611388,8.039549267611388,0.38283567941006613,0.3828356794100661 +0.003965517241379311,435.16539448222034,7.985002349105397,7.985002349105397,0.380238207100257,0.380238207100257 +0.003974193548387097,436.117509510821,7.93095877941348,7.930958779413479,0.3776647037815943,0.37766470378159417 +0.003982869855394883,437.0696245394216,7.877412565842866,7.877412565842867,0.3751148840877555,0.3751148840877556 +0.00399154616240267,438.02173956802227,7.824357805490925,7.824357805490926,0.3725884669281393,0.3725884669281393 +0.004000222469410456,438.9738545966229,7.771788683569223,7.771788683569223,0.37008517540805824,0.37008517540805824 +0.0040088987764182425,439.92596962522356,7.719699471766415,7.7196994717664165,0.36760473675078165,0.36760473675078176 +0.004017575083426029,440.8780846538242,7.66808452664867,7.66808452664867,0.3651468822213652,0.3651468822213652 +0.004026251390433815,441.83019968242485,7.616938288096732,7.61693828809673,0.3627113470522253,0.36271134705222524 +0.004034927697441602,442.7823147110255,7.56625527777849,7.56625527777849,0.3602978703704043,0.3602978703704043 +0.004043604004449388,443.73442973962614,7.516030097656153,7.516030097656152,0.35790619512648353,0.3579061951264835 +0.004052280311457174,444.6865447682268,7.466257428526973,7.466257428526974,0.35553606802509397,0.35553606802509397 +0.004060956618464961,445.6386597968274,7.416932028596696,7.416932028596696,0.35318723945698555,0.3531872394569855 +0.004069632925472747,446.59077482542807,7.368048732084774,7.368048732084774,0.35085946343260827,0.35085946343260827 +0.0040783092324805335,447.5428898540287,7.319602447860529,7.319602447860529,0.34855249751716805,0.34855249751716805 +0.00408698553948832,448.49500488262936,7.271588158109381,7.271588158109381,0.3462661027671134,0.3462661027671134 +0.004095661846496106,449.44711991123,7.224000917028365,7.2240009170283646,0.3440000436680174,0.3440000436680174 +0.004104338153503893,450.39923493983065,7.176835849550131,7.17683584955013,0.3417540880738158,0.3417540880738157 +0.004113014460511679,451.3513499684313,7.130088150094739,7.130088150094739,0.3395280071473685,0.3395280071473685 +0.004121690767519465,452.30346499703194,7.083753081348365,7.083753081348364,0.33732157530230306,0.33732157530230306 +0.004130367074527252,453.2555800256326,7.037825973068411,7.037825973068411,0.3351345701461148,0.3351345701461148 +0.004139043381535038,454.2076950542332,6.99230222091412,6.9923022209141195,0.3329667724244819,0.3329667724244819 +0.004147719688542825,455.159810082834,6.947177285302219,6.947177285302219,0.3308179659667723,0.3308179659667724 +0.004156395995550612,456.11192511143463,6.9024466902868085,6.9024466902868085,0.3286879376327052,0.32868793763270515 +0.004165072302558398,457.0640401400353,6.858106022462936,6.858106022462936,0.32657647726013983,0.32657647726013983 +0.0041737486095661845,458.0161551686359,6.814150929893225,6.814150929893224,0.32448337761396306,0.32448337761396306 +0.004182424916573971,458.96827019723656,6.77057712105699,6.77057712105699,0.32240843433604716,0.32240843433604716 +0.004191101223581757,459.9203852258372,6.72738036382127,6.72738036382127,0.320351445896251,0.320351445896251 +0.004199777530589544,460.87250025443785,6.68455648443318,6.68455648443318,0.3183122135444371,0.3183122135444371 +0.00420845383759733,461.8246152830385,6.642101366533103,6.642101366533105,0.31629054126348105,0.31629054126348116 +0.004217130144605116,462.77673031163914,6.6000109501881985,6.600010950188198,0.31428623572324754,0.31428623572324754 +0.004225806451612903,463.7288453402398,6.558281230945649,6.558281230945648,0.31229910623550705,0.31229910623550705 +0.004234482758620689,464.68096036884043,6.516908258905273,6.516908258905273,0.3103289647097749,0.3103289647097749 +0.0042431590656284756,465.6330753974411,6.475888137810923,6.475888137810923,0.30837562561004395,0.30837562561004395 +0.004251835372636262,466.5851904260417,6.435217024160261,6.43521702416026,0.30643890591239337,0.30643890591239337 +0.004260511679644048,467.53730545464236,6.394891126332462,6.394891126332462,0.3045186250634506,0.3045186250634505 +0.004269187986651835,468.489420483243,6.354906703733413,6.354906703733413,0.3026146049396863,0.3026146049396863 +0.004277864293659621,469.44153551184365,6.315260065957924,6.315260065957924,0.30072666980752016,0.30072666980752016 +0.004286540600667408,470.39365054044436,6.275947571968665,6.275947571968664,0.2988546462842221,0.2988546462842221 +0.004295216907675195,471.345765569045,6.236965629291312,6.236965629291312,0.2969983632995863,0.2969983632995863 +0.004303893214682981,472.29788059764564,6.198310693225548,6.198310693225547,0.2951576520583594,0.2951576520583594 +0.0043125695216907674,473.2499956262463,6.1599792660716055,6.1599792660716055,0.2933323460034098,0.2933323460034098 +0.004321245828698554,474.20211065484693,6.1219678963718955,6.121967896371895,0.29152228077961406,0.29152228077961406 +0.00432992213570634,475.1542256834476,6.084273178167416,6.084273178167416,0.28972729419844845,0.28972729419844834 +0.004338598442714127,476.1063407120482,6.04689175026861,6.0468917502686095,0.2879472262032671,0.2879472262032671 +0.004347274749721913,477.05845574064887,6.0098202955402735,6.009820295540274,0.2861819188352511,0.2861819188352511 +0.004355951056729699,478.0105707692495,5.97305554020027,5.973055540200269,0.2844312162000129,0.28443121620001277 +0.004364627363737486,478.96268579785016,5.9365942531316565,5.9365942531316565,0.28269496443484077,0.28269496443484077 +0.004373303670745272,479.9148008264508,5.900433245207979,5.900433245207979,0.2809730116765704,0.2809730116765704 +0.0043819799777530585,480.86691585505145,5.864569368631379,5.864569368631379,0.27926520803006566,0.27926520803006566 +0.004390656284760845,481.8190308836521,5.828999516283267,5.828999516283268,0.2775714055372984,0.2775714055372985 +0.004399332591768631,482.77114591225273,5.793720621087248,5.793720621087247,0.27589145814701177,0.27589145814701177 +0.004408008898776418,483.7232609408534,5.758729655384024,5.758729655384023,0.2742252216849535,0.2742252216849535 +0.004416685205784204,484.675375969454,5.724023630318037,5.724023630318036,0.2725725538246684,0.2725725538246684 +0.00442536151279199,485.62749099805467,5.689599595235539,5.6895995952355385,0.2709333140588352,0.2709333140588352 +0.004434037819799778,486.5796060266554,5.655454637093872,5.655454637093872,0.2693073636711367,0.2693073636711367 +0.004442714126807564,487.53172105525607,5.621585879881718,5.621585879881717,0.26769456570865324,0.26769456570865324 +0.00445139043381535,488.4838360838567,5.587990484050013,5.587990484050013,0.2660947849547625,0.2660947849547625 +0.004460066740823137,489.43595111245736,5.554665645953372,5.554665645953371,0.2645078879025415,0.2645078879025415 +0.004468743047830923,490.388066141058,5.521608597301743,5.521608597301742,0.2629337427286544,0.2629337427286544 +0.0044774193548387095,491.34018116965865,5.488816604622086,5.488816604622086,0.26137221926771836,0.26137221926771836 +0.004486095661846496,492.2922961982593,5.4562869687298505,5.45628696872985,0.2598231889871357,0.2598231889871357 +0.004494771968854282,493.24441122685994,5.424017024210036,5.424017024210035,0.25828652496238264,0.25828652496238264 +0.004503448275862069,494.1965262554606,5.3920041389076525,5.3920041389076525,0.25676210185274534,0.25676210185274534 +0.004512124582869855,495.1486412840612,5.360245713427357,5.360245713427357,0.2552497958774932,0.2552497958774932 +0.004520800889877641,496.1007563126619,5.3287391806420565,5.328739180642057,0.2537494847924789,0.25374948479247894 +0.004529477196885428,497.0528713412625,5.29748200521034,5.297482005210339,0.252261047867159,0.252261047867159 +0.004538153503893214,498.00498636986316,5.266471683102486,5.266471683102486,0.25078436586202313,0.25078436586202313 +0.0045468298109010005,498.9571013984638,5.235705741134915,5.235705741134915,0.24931932100642448,0.2493193210064245 +0.004555506117908787,499.90921642706445,5.205181736512883,5.205181736512883,0.24786579697680397,0.24786579697680391 +0.004564182424916573,500.8613314556651,5.174897256381245,5.174897256381245,0.24642367887529737,0.24642367887529737 +0.0045728587319243605,501.81344648426585,5.144849917383141,5.14484991738314,0.24499285320872094,0.24499285320872094 +0.004581535038932147,502.7655615128665,5.115037365226402,5.115037365226402,0.24357320786792389,0.24357320786792386 +0.004590211345939933,503.71767654146714,5.085457274257558,5.085457274257557,0.24216463210750275,0.24216463210750272 +0.00459888765294772,504.6697915700678,5.056107347043254,5.056107347043254,0.24076701652586918,0.24076701652586918 +0.004607563959955506,505.62190659866843,5.026985313958944,5.026985313958943,0.23938025304566396,0.23938025304566396 +0.004616240266963292,506.5740216272691,4.998088932784713,4.998088932784713,0.2380042348945101,0.23800423489451009 +0.004624916573971079,507.5261366558697,4.96941598830806,4.96941598830806,0.2366388565860981,0.23663885658609807 +0.004633592880978865,508.47825168447037,4.940964291933548,4.940964291933547,0.2352840139015975,0.23528401390159745 +0.0046422691879866515,509.430366713071,4.912731681299093,4.912731681299093,0.23393960387138538,0.23393960387138535 +0.004650945494994438,510.38248174167165,4.884716019898881,4.88471601989888,0.23260552475708957,0.2326055247570895 +0.004659621802002224,511.3345967702723,4.8569151967126665,4.8569151967126665,0.2312816760339365,0.23128167603393646 +0.004668298109010011,512.286711798873,4.829327125841383,4.829327125841383,0.22996795837339917,0.22996795837339917 +0.004676974416017797,513.2388268274735,4.801949746148934,4.801949746148933,0.22866427362613972,0.22866427362613967 +0.004685650723025583,514.1909418560742,4.774781020910027,4.7747810209100265,0.2273705248052394,0.22737052480523934 +0.00469432703003337,515.1430568846748,4.747818937463935,4.747818937463935,0.2260866160697112,0.22608661606971114 +0.004703003337041156,516.0951719132755,4.721061506874084,4.721061506874084,0.2248124527082897,0.22481245270828967 +0.0047116796440489425,517.0472869418761,4.694506763593319,4.694506763593319,0.22354794112349138,0.22354794112349136 +0.00472035595105673,517.9994019704769,4.668152765134768,4.668152765134767,0.22229298881594134,0.22229298881594128 +0.004729032258064516,518.9515169990775,4.641997591748199,4.641997591748199,0.2210475043689618,0.2210475043689618 +0.0047377085650723025,519.9036320276782,4.616039346101688,4.616039346101688,0.21981139743341377,0.21981139743341374 +0.004746384872080089,520.8557470562788,4.5902761529686344,4.5902761529686344,0.21858457871279213,0.2185845787127921 +0.004755061179087875,521.8078620848795,4.564706158919868,4.564706158919868,0.21736695994856514,0.21736695994856509 +0.004763737486095662,522.7599771134801,4.539327532020859,4.539327532020858,0.21615845390575517,0.2161584539057551 +0.004772413793103448,523.7120921420808,4.5141384615338715,4.514138461533871,0.21495897435875577,0.21495897435875572 +0.004781090100111234,524.6642071706814,4.489137157625013,4.489137157625013,0.2137684360773816,0.21376843607738158 +0.004789766407119021,525.6163221992821,4.464321851076029,4.464321851076029,0.21258675481314424,0.2125867548131442 +0.004798442714126807,526.5684372278827,4.4396907930008025,4.4396907930008025,0.21141384728575252,0.2114138472857525 +0.0048071190211345935,527.5205522564834,4.415242254566447,4.415242254566447,0.2102496311698308,0.2102496311698308 +0.00481579532814238,528.472667285084,4.390974526718907,4.390974526718907,0.2090940250818527,0.20909402508185265 +0.004824471635150166,529.4247823136847,4.366885919912975,4.366885919912974,0.20794694856728452,0.2079469485672845 +0.004833147942157953,530.3768973422852,4.342974763846663,4.342974763846663,0.20680832208793637,0.2068083220879363 +0.004841824249165739,531.329012370886,4.319239407199831,4.31923940719983,0.20567806700951574,0.20567806700951569 +0.004850500556173525,532.2811273994865,4.29567821737697,4.29567821737697,0.20455610558937953,0.20455610558937948 +0.004859176863181313,533.2332424280874,4.272289580254124,4.272289580254124,0.2034423609644821,0.2034423609644821 +0.004867853170189099,534.1853574566879,4.2490718999298105,4.2490718999298105,0.20233675713951477,0.20233675713951474 +0.004876529477196885,535.1374724852886,4.226023598479896,4.226023598479896,0.20123921897523317,0.20123921897523314 +0.004885205784204672,536.0895875138892,4.203143115716354,4.203143115716353,0.20014967217696922,0.20014967217696922 +0.004893882091212458,537.0417025424899,4.1804289089498194,4.1804289089498194,0.19906804328332472,0.19906804328332472 +0.0049025583982202445,537.9938175710905,4.157879452755896,4.157879452755896,0.1979942596550427,0.19799425965504264 +0.004911234705228031,538.9459325996912,4.1354932387451075,4.135493238745107,0.19692824946405274,0.1969282494640527 +0.004919911012235817,539.8980476282918,4.113268775336455,4.113268775336456,0.19586994168268834,0.19586994168268834 +0.004928587319243604,540.8501626568925,4.091204587534531,4.091204587534531,0.19481926607307287,0.19481926607307284 +0.00493726362625139,541.8022776854931,4.06929921671007,4.0692992167100694,0.19377615317667002,0.19377615317666996 +0.004945939933259176,542.7543927140938,4.047551220383926,4.047551220383926,0.19274053430399646,0.19274053430399646 +0.004954616240266963,543.7065077426944,4.025959172014393,4.025959172014393,0.19171234152449496,0.1917123415244949 +0.004963292547274749,544.6586227712951,4.004521660787811,4.004521660787811,0.1906915076565624,0.1906915076565624 +0.0049719688542825356,545.6107377998957,3.983237291412382,3.9832372914123817,0.18967796625773248,0.18967796625773245 +0.004980645161290322,546.5628528284964,3.962104683915192,3.9621046839151917,0.18867165161500915,0.1886716516150091 +0.004989321468298108,547.514967857097,3.941122473442296,3.941122473442296,0.18767249873534741,0.18767249873534741 +0.004997997775305895,548.4670828856977,3.920289310061909,3.9202893100619085,0.18668044333628137,0.18668044333628137 +0.005006674082313682,549.4191979142984,3.899603858570546,3.899603858570546,0.1856954218366927,0.18569542183669266 +0.005015350389321468,550.371312942899,3.8790647983021778,3.8790647983021778,0.18471737134772276,0.18471737134772276 +0.005024026696329255,551.3234279714997,3.858670822940206,3.858670822940207,0.18374622966381932,0.18374622966381937 +0.005032703003337041,552.2755430001002,3.838420640332348,3.838420640332348,0.18278193525392133,0.18278193525392133 +0.0050413793103448274,553.227658028701,3.8183129723082856,3.818312972308285,0.1818244272527755,0.1818244272527755 +0.005050055617352614,554.1797730573015,3.798346554500067,3.7983465545000676,0.18087364545238416,0.18087364545238416 +0.0050587319243604,555.1318880859022,3.7785201361652145,3.778520136165214,0.17992953029358164,0.17992953029358164 +0.005067408231368187,556.0840031145028,3.758832480012469,3.758832480012469,0.17899202285773663,0.1789920228577366 +0.005076084538375973,557.0361181431035,3.739282362030154,3.7392823620301536,0.17806106485857875,0.17806106485857875 +0.005084760845383759,557.9882331717041,3.719868571317101,3.719868571317101,0.17713659863414766,0.17713659863414766 +0.005093437152391546,558.9403482003048,3.700589909916074,3.700589909916074,0.17621856713886067,0.17621856713886067 +0.005102113459399332,559.8924632289054,3.681445192649691,3.681445192649691,0.17530691393569955,0.17530691393569955 +0.0051107897664071185,560.8445782575061,3.662433246958754,3.662433246958754,0.1744015831885121,0.1744015831885121 +0.005119466073414905,561.7966932861067,3.6435529127429844,3.643552912742984,0.1735025196544278,0.1735025196544278 +0.005128142380422691,562.7488083147074,3.6248030422040953,3.6248030422040953,0.1726096686763855,0.17260966867638544 +0.005136818687430478,563.700923343308,3.60618249969118,3.6061824996911795,0.17172297617577048,0.17172297617577045 +0.005145494994438265,564.6530383719088,3.5876901615483545,3.5876901615483545,0.17084238864515977,0.17084238864515972 +0.005154171301446051,565.6051534005094,3.5693249159646636,3.569324915964663,0.16996785314117444,0.16996785314117444 +0.005162847608453838,566.5572684291101,3.5510856628261274,3.551085662826127,0.16909931727743463,0.16909931727743463 +0.005171523915461624,567.5093834577107,3.532971313569996,3.5329713135699956,0.16823672921761884,0.16823672921761884 +0.00518020022246941,568.4614984863114,3.5149807910410855,3.514980791041085,0.16738003766862314,0.16738003766862308 +0.005188876529477197,569.413613514912,3.497113029350218,3.497113029350218,0.1665291918738199,0.1665291918738199 +0.005197552836484983,570.3657285435127,3.479366973734704,3.479366973734704,0.16568414160641448,0.16568414160641448 +0.0052062291434927695,571.3178435721132,3.4617415804208314,3.4617415804208314,0.16484483716289672,0.16484483716289675 +0.005214905450500556,572.269958600714,3.444235816488359,3.4442358164883586,0.16401122935658852,0.16401122935658852 +0.005223581757508342,573.2220736293145,3.4268486597369248,3.4268486597369248,0.16318326951128212,0.16318326951128212 +0.005232258064516129,574.1741886579152,3.409579098554396,3.4095790985543957,0.16236090945497123,0.16236090945497123 +0.005240934371523915,575.1263036865158,3.392426131787093,3.3924261317870927,0.1615441015136711,0.1615441015136711 +0.005249610678531701,576.0784187151165,3.375388768611864,3.375388768611864,0.16073279850532685,0.16073279850532685 +0.005258286985539488,577.0305337437171,3.358466028409988,3.358466028409988,0.15992695373380894,0.15992695373380894 +0.005266963292547274,577.9826487723178,3.341656940642869,3.341656940642869,0.15912652098299376,0.15912652098299376 +0.0052756395995550605,578.9347638009184,3.3249605447294823,3.324960544729482,0.15833145451092773,0.15833145451092773 +0.005284315906562847,579.8868788295191,3.3083758899255717,3.3083758899255717,0.15754170904407483,0.15754170904407483 +0.005292992213570634,580.8389938581198,3.2919020352045476,3.291902035204547,0.1567572397716451,0.1567572397716451 +0.0053016685205784205,581.7911088867205,3.275538049140051,3.2755380491400516,0.15597800234000245,0.15597800234000245 +0.005310344827586207,582.7432239153211,3.2592830097901855,3.259283009790185,0.15520395284715166,0.15520395284715166 +0.005319021134593993,583.6953389439218,3.243136004583362,3.243136004583362,0.15443504783730294,0.15443504783730294 +0.00532769744160178,584.6474539725224,3.2270961302057453,3.2270961302057453,0.15367124429551168,0.15367124429551168 +0.005336373748609566,585.5995690011231,3.211162492490277,3.211162492490277,0.15291249964239412,0.15291249964239412 +0.005345050055617352,586.5516840297237,3.195334206307235,3.195334206307235,0.15215877172891595,0.15215877172891595 +0.005353726362625139,587.5037990583244,3.17961039545633,3.17961039545633,0.1514100188312538,0.1514100188312538 +0.005362402669632925,588.455914086925,3.1639901925602807,3.1639901925602802,0.15066619964572764,0.15066619964572764 +0.0053710789766407115,589.4080291155257,3.1484727389598834,3.148472738959883,0.14992727328380395,0.14992727328380395 +0.005379755283648498,590.3601441441263,3.1330571846105126,3.133057184610512,0.14919319926716726,0.14919319926716726 +0.005388431590656284,591.312259172727,3.117742687980068,3.117742687980068,0.14846393752286036,0.14846393752286036 +0.005397107897664071,592.2643742013275,3.102528415948308,3.1025284159483077,0.14773944837849085,0.14773944837849085 +0.005405784204671857,593.2164892299282,3.087413543707581,3.0874135437075805,0.14701969255750383,0.14701969255750383 +0.005414460511679643,594.1686042585288,3.0723972546649057,3.0723972546649057,0.1463046311745193,0.1463046311745193 +0.00542313681868743,595.1207192871295,3.057478740345398,3.057478740345399,0.14559422573073322,0.14559422573073327 +0.005431813125695217,596.0728343157302,3.0426572002970147,3.0426572002970143,0.14488843810938162,0.14488843810938162 +0.005440489432703003,597.0249493443308,3.027931841996593,3.027931841996593,0.14418723057126637,0.1441872305712663 +0.00544916573971079,597.9770643729315,3.013301880757168,3.013301880757168,0.14349056575034136,0.1434905657503413 +0.005457842046718576,598.9291794015321,2.9987665396365437,2.9987665396365433,0.14279840664935922,0.14279840664935922 +0.0054665183537263625,599.8812944301328,2.9843250493471176,2.9843250493471176,0.14211071663557703,0.14211071663557703 +0.005475194660734149,600.8334094587334,2.969976648166895,2.9699766481668948,0.1414274594365188,0.1414274594365188 +0.005483870967741935,601.7855244873341,2.9557205818517387,2.9557205818517387,0.14074859913579707,0.14074859913579707 +0.005492547274749722,602.7376395159347,2.9415561035487614,2.941556103548761,0.14007410016898864,0.14007410016898864 +0.005501223581757508,603.6897545445354,2.9274824737109215,2.9274824737109215,0.1394039273195677,0.1394039273195677 +0.005509899888765294,604.641869573136,2.9134989600127237,2.9134989600127237,0.1387380457148916,0.1387380457148916 +0.005518576195773081,605.5939846017367,2.899604837267079,2.899604837267079,0.13807642082224184,0.13807642082224184 +0.005527252502780867,606.5460996303373,2.8857993873432557,2.8857993873432553,0.13741901844491694,0.13741901844491689 +0.0055359288097886535,607.498214658938,2.8720818990859334,2.8720818990859334,0.1367658047183778,0.13676580471837774 +0.00554460511679644,608.4503296875386,2.8584516682353285,2.8584516682353276,0.13611674610644423,0.13611674610644414 +0.005553281423804226,609.4024447161393,2.844907997348378,2.844907997348376,0.13547180939754183,0.13547180939754172 +0.005561957730812013,610.3545597447398,2.8314501957209766,2.831450195720975,0.13483096170099887,0.13483096170099879 +0.005570634037819799,611.3066747733405,2.8180775793112254,2.8180775793112236,0.13419417044339169,0.1341941704433916 +0.005579310344827586,612.2587898019412,2.804789470663717,2.8047894706637155,0.13356140336493888,0.13356140336493882 +0.005587986651835373,613.210904830542,2.79158519883479,2.7915851988347886,0.13293262851594237,0.1329326285159423 +0.005596662958843159,614.1630198591425,2.778464099318789,2.7784640993187875,0.13230781425327565,0.1323078142532756 +0.005605339265850945,615.1151348877432,2.7654255139752757,2.7654255139752744,0.13168692923691788,0.13168692923691783 +0.005614015572858732,616.0672499163438,2.752468790957205,2.7524687909572036,0.13106994242653355,0.13106994242653347 +0.005622691879866518,617.0193649449445,2.7395932846400326,2.7395932846400313,0.1304568230780968,0.13045682307809672 +0.0056313681868743045,617.9714799735451,2.7267983555517405,2.726798355551739,0.1298475407405591,0.12984754074055901 +0.005640044493882091,618.9235950021458,2.7140833703038005,2.7140833703038,0.12924206525256193,0.1292420652525619 +0.005648720800889877,619.8757100307464,2.7014477015229947,2.7014477015229934,0.12864036673919022,0.12864036673919016 +0.005657397107897664,620.8278250593471,2.688890727784155,2.6888907277841536,0.12804241560876928,0.12804241560876922 +0.00566607341490545,621.7799400879477,2.676411833543755,2.676411833543753,0.1274481825497026,0.12744818254970253 +0.005674749721913236,622.7320551165484,2.6640104090743595,2.664010409074358,0.12685763852735044,0.12685763852735038 +0.005683426028921023,623.684170145149,2.6516858503999314,2.6516858503999297,0.12627075478094912,0.12627075478094904 +0.005692102335928809,624.6362851737497,2.63943755923196,2.639437559231958,0.1256875028205695,0.12568750282056942 +0.0057007786429365956,625.5884002023503,2.627264942906411,2.62726494290641,0.12510785442411482,0.12510785442411476 +0.005709454949944382,626.540515230951,2.615167414321495,2.6151674143214936,0.12453178163435691,0.12453178163435683 +0.005718131256952169,627.4926302595517,2.6031443918762096,2.603144391876208,0.12395925675600997,0.12395925675600988 +0.0057268075639599555,628.4447452881524,2.5911952994097054,2.5911952994097045,0.12339025235284312,0.12339025235284307 +0.005735483870967742,629.396860316753,2.5793195661413755,2.5793195661413737,0.12282474124482741,0.12282474124482731 +0.005744160177975528,630.3489753453537,2.5675166266117504,2.56751662661175,0.12226269650532146,0.12226269650532141 +0.005752836484983315,631.3010903739543,2.5557859206241247,2.5557859206241234,0.12170409145829165,0.12170409145829159 +0.005761512791991101,632.253205402555,2.544126893186921,2.54412689318692,0.12114889967556766,0.12114889967556762 +0.005770189098998887,633.2053204311555,2.5325389944567904,2.532538994456789,0.12059709497413287,0.1205970949741328 +0.005778865406006674,634.1574354597562,2.5210216796824345,2.521021679682433,0.12004865141344925,0.12004865141344921 +0.00578754171301446,635.1095504883568,2.5095744091491277,2.5095744091491263,0.1195035432928156,0.11950354329281553 +0.0057962180200222466,636.0616655169575,2.498196648123941,2.49819664812394,0.1189617451487591,0.11896174514875904 +0.005804894327030033,637.0137805455581,2.4868878668016587,2.486887866801658,0.11842323175245995,0.11842323175245989 +0.005813570634037819,637.9658955741588,2.475647540251364,2.475647540251363,0.11788797810720782,0.11788797810720775 +0.005822246941045606,638.9180106027594,2.4644751483637117,2.4644751483637104,0.11735595944589103,0.11735595944589096 +0.005830923248053392,639.8701256313601,2.4533701757988267,2.453370175798825,0.11682715122851553,0.11682715122851547 +0.0058395995550611784,640.8222406599607,2.4423321119348937,2.442332111934893,0.11630152913975686,0.11630152913975679 +0.005848275862068965,641.7743556885614,2.4313604508173574,2.431360450817356,0.11577906908654084,0.11577906908654076 +0.005856952169076751,642.726470717162,2.4204546911087634,2.4204546911087625,0.1152597471956554,0.11525974719565535 +0.0058656284760845384,643.6785857457627,2.409614336039223,2.409614336039222,0.11474353981139157,0.11474353981139152 +0.005874304783092325,644.6307007743634,2.398838893357495,2.3988388933574942,0.11423042349321408,0.114230423493214 +0.005882981090100111,645.582815802964,2.3881278752826622,2.3881278752826613,0.1137203750134601,0.11372037501346005 +0.005891657397107898,646.5349308315647,2.3774807984564084,2.377480798456407,0.11321337135506707,0.113213371355067 +0.005900333704115684,647.4870458601653,2.3668971838958957,2.3668971838958943,0.11270938970932834,0.11270938970932828 +0.00590901001112347,648.439160888766,2.3563765569472013,2.3563765569472,0.11220840747367625,0.11220840747367618 +0.005917686318131257,649.3912759173666,2.34591844723934,2.3459184472393386,0.11171040224949237,0.1117104022494923 +0.005926362625139043,650.3433909459673,2.3355223886388505,2.335522388638849,0.11121535183994526,0.11121535183994519 +0.0059350389321468295,651.2955059745678,2.3251879192049234,2.3251879192049225,0.11072323424785349,0.11072323424785344 +0.005943715239154616,652.2476210031685,2.314914581145099,2.314914581145098,0.11023402767357615,0.11023402767357608 +0.005952391546162402,653.1997360317691,2.3047019207714845,2.3047019207714836,0.10974771051292784,0.10974771051292777 +0.005961067853170189,654.1518510603698,2.294549488457518,2.2945494884575175,0.10926426135511991,0.10926426135511988 +0.005969744160177975,655.1039660889704,2.284456838595257,2.284456838595255,0.10878365898072652,0.10878365898072645 +0.005978420467185761,656.0560811175711,2.2744235295531703,2.274423529553169,0.10830588235967478,0.10830588235967471 +0.005987096774193548,657.0081961461717,2.264449123634469,2.264449123634468,0.10783091064926043,0.10783091064926037 +0.005995773081201334,657.9603111747724,2.2545331870359084,2.254533187035908,0.10735872319218612,0.10735872319218609 +0.006004449388209121,658.9124262033731,2.244675289807117,2.244675289807117,0.10688929951462464,0.10688929951462459 +0.006013125695216908,659.8645412319738,2.234875005810396,2.234875005810394,0.10642261932430455,0.10642261932430448 +0.006021802002224694,660.8166562605744,2.2251319126810065,2.225131912681005,0.10595866250861935,0.10595866250861928 +0.0060304783092324805,661.7687712891751,2.2154455917879425,2.2154455917879416,0.10549740913275918,0.10549740913275912 +0.006039154616240267,662.7208863177757,2.205815628195165,2.2058156281951637,0.10503883943786499,0.10503883943786493 +0.006047830923248053,663.6730013463764,2.1962416106233023,2.1962416106233014,0.10458293383920486,0.1045829338392048 +0.00605650723025584,664.625116374977,2.1867231314118065,2.1867231314118056,0.10412967292437175,0.10412967292437168 +0.006065183537263626,665.5772314035777,2.177259786481568,2.1772597864815673,0.10367903745150325,0.1036790374515032 +0.006073859844271412,666.5293464321783,2.167851175297972,2.1678511752979706,0.10323100834752247,0.10323100834752241 +0.006082536151279199,667.481461460779,2.1584969008343844,2.1584969008343835,0.10278556670639927,0.10278556670639921 +0.006091212458286985,668.4335764893796,2.1491965695360897,2.149196569536089,0.10234269378743284,0.1023426937874328 +0.0060998887652947715,669.3856915179803,2.1399497912846406,2.1399497912846397,0.10190237101355432,0.10190237101355426 +0.006108565072302558,670.3378065465809,2.130756179362637,2.1307561793626357,0.10146457996964939,0.1014645799696493 +0.006117241379310344,671.2899215751816,2.1216153504189217,2.1216153504189212,0.10102930240090102,0.101029302400901 +0.006125917686318131,672.2420366037821,2.112526924434179,2.112526924434178,0.10059652021115137,0.10059652021115131 +0.006134593993325917,673.1941516323828,2.1034905246869493,2.1034905246869484,0.10016621546128329,0.10016621546128324 +0.006143270300333703,674.1462666609834,2.094505777720032,2.0945057777200313,0.0997383703676206,0.09973837036762054 +0.006151946607341491,675.0983816895842,2.085572313307291,2.0855723133072903,0.0993129673003472,0.09931296730034714 +0.006160622914349277,676.0504967181848,2.076689764420851,2.0766897644208497,0.09888998878194527,0.09888998878194522 +0.006169299221357063,677.0026117467855,2.06785776719866,2.0678577671986584,0.09846941748565047,0.0984694174856504 +0.00617797552836485,677.9547267753861,2.0590759609124545,2.0590759609124536,0.0980512362339264,0.09805123623392636 +0.006186651835372636,678.9068418039868,2.050343987936084,2.050343987936083,0.09763542799695638,0.09763542799695632 +0.0061953281423804225,679.8589568325874,2.041661493714206,2.041661493714205,0.09722197589115267,0.09722197589115261 +0.006204004449388209,680.8110718611881,2.0330281267313453,2.033028126731345,0.09681086317768313,0.09681086317768309 +0.006212680756395995,681.7631868897887,2.0244435384813184,2.024443538481317,0.09640207326101516,0.0964020732610151 +0.006221357063403782,682.7153019183894,2.015907383436997,2.015907383436997,0.09599558968747605,0.09599558968747603 +0.006230033370411568,683.66741694699,2.007419319020443,2.0074193190204426,0.09559139614383061,0.09559139614383058 +0.006238709677419354,684.6195319755907,1.9989790055733663,1.9989790055733652,0.09518947645587458,0.09518947645587451 +0.006247385984427141,685.5716470041913,1.990586106327938,1.990586106327937,0.09478981458704466,0.09478981458704462 +0.006256062291434927,686.523762032792,1.9822402873779363,1.9822402873779352,0.09439239463704459,0.09439239463704452 +0.0062647385984427135,687.4758770613926,1.9739412176502174,1.9739412176502165,0.09399720084048654,0.09399720084048649 +0.0062734149054505,688.4279920899933,1.965688568876524,1.9656885688765229,0.09360421756554876,0.09360421756554871 +0.006282091212458286,689.3801071185939,1.9574820155656094,1.9574820155656085,0.09321342931264806,0.09321342931264802 +0.0062907675194660735,690.3322221471946,1.9493212349756823,1.9493212349756817,0.09282482071312773,0.0928248207131277 +0.00629944382647386,691.2843371757953,1.9412059070871703,1.9412059070871694,0.09243837652796048,0.09243837652796044 +0.006308120133481646,692.2364522043958,1.9331357145757848,1.9331357145757844,0.09205408164646595,0.09205408164646592 +0.006316796440489433,693.1885672329965,1.9251103427859033,1.9251103427859029,0.091671921085043,0.09167192108504299 +0.006325472747497219,694.1406822615971,1.9171294797042453,1.9171294797042444,0.09129187998591645,0.0912918799859164 +0.006334149054505005,695.0927972901978,1.9091928159338554,1.909192815933855,0.09091394361589789,0.09091394361589786 +0.006342825361512792,696.0449123187984,1.9013000446683725,1.901300044668372,0.09053809736516061,0.09053809736516058 +0.006351501668520578,696.9970273473991,1.8934508616665964,1.893450861666596,0.0901643267460284,0.09016432674602838 +0.0063601779755283645,697.9491423759997,1.8856449652273395,1.8856449652273384,0.08979261739177806,0.08979261739177802 +0.006368854282536151,698.9012574046004,1.877882056164562,1.8778820561645615,0.08942295505545532,0.0894229550554553 +0.006377530589543937,699.853372433201,1.870161837782787,1.8701618377827862,0.08905532560870413,0.0890553256087041 +0.006386206896551724,700.8054874618017,1.8624840158527947,1.862484015852794,0.08868971504060927,0.08868971504060921 +0.00639488320355951,701.7576024904023,1.8548482985875847,1.8548482985875843,0.08832610945655166,0.08832610945655162 +0.006403559510567296,702.709717519003,1.8472543966186112,1.8472543966186106,0.08796449507707672,0.0879644950770767 +0.006412235817575083,703.6618325476036,1.8397020229722847,1.8397020229722842,0.08760485823677545,0.08760485823677544 +0.006420912124582869,704.6139475762043,1.8321908930467345,1.832190893046734,0.08724718538317786,0.08724718538317781 +0.0064295884315906555,705.5660626048049,1.8247207245888282,1.8247207245888277,0.0868914630756585,0.08689146307565845 +0.006438264738598443,706.5181776334057,1.8172912376714554,1.8172912376714545,0.08653767798435501,0.08653767798435498 +0.006446941045606229,707.4702926620063,1.8099021546710534,1.8099021546710528,0.08618581688909778,0.08618581688909774 +0.0064556173526140155,708.422407690607,1.8025532002453946,1.8025532002453937,0.08583586667835212,0.08583586667835208 +0.006464293659621802,709.3745227192076,1.795244101311607,1.795244101311606,0.08548781434817175,0.08548781434817171 +0.006472969966629588,710.3266377478083,1.7879745870244474,1.7879745870244463,0.08514164700116417,0.08514164700116411 +0.006481646273637375,711.2787527764089,1.780744388754813,1.7807443887548127,0.08479735184546727,0.08479735184546726 +0.006490322580645161,712.2308678050096,1.7735532400684906,1.7735532400684897,0.08445491619373764,0.0844549161937376 +0.006498998887652947,713.1829828336101,1.7664008767051365,1.7664008767051358,0.08411432746214935,0.08411432746214931 +0.006507675194660734,714.1350978622108,1.759287036557491,1.7592870365574904,0.08377557316940433,0.0837755731694043 +0.00651635150166852,715.0872128908114,1.7522114596508245,1.7522114596508245,0.08343864093575355,0.08343864093575354 +0.0065250278086763066,716.0393279194121,1.7451738881225989,1.7451738881225987,0.08310351848202852,0.0831035184820285 +0.006533704115684093,716.9914429480127,1.738174066202364,1.7381740662023635,0.08277019362868399,0.08277019362868397 +0.006542380422691879,717.9435579766134,1.7312117401918665,1.731211740191866,0.08243865429485078,0.08243865429485076 +0.006551056729699666,718.895673005214,1.7242866584453793,1.7242866584453784,0.08210888849739902,0.08210888849739897 +0.006559733036707452,719.8477880338147,1.7173985713502424,1.7173985713502415,0.08178088435001155,0.0817808843500115 +0.0065684093437152384,720.7999030624153,1.7105472313076189,1.7105472313076184,0.08145463006226757,0.08145463006226755 +0.006577085650723026,721.7520180910161,1.7037323927134596,1.7037323927134584,0.08113011393873616,0.08113011393873612 +0.006585761957730812,722.7041331196167,1.6969538119396779,1.6969538119396774,0.0808073243780799,0.08080732437807989 +0.0065944382647385984,723.6562481482174,1.6902112473155166,1.6902112473155162,0.08048624987216746,0.08048624987216743 +0.006603114571746385,724.608363176818,1.6835044591091322,1.6835044591091317,0.08016687900519678,0.08016687900519676 +0.006611790878754171,725.5604782054187,1.6768332095093696,1.6768332095093692,0.07984920045282715,0.0798492004528271 +0.006620467185761958,726.5125932340193,1.6701972626077335,1.6701972626077324,0.07953320298132063,0.07953320298132058 +0.006629143492769744,727.46470826262,1.6635963843805592,1.6635963843805581,0.07921887544669329,0.07921887544669323 +0.00663781979977753,728.4168232912206,1.657030342671367,1.6570303426713662,0.07890620679387461,0.07890620679387458 +0.006646496106785317,729.3689383198213,1.6504989071734202,1.6504989071734195,0.07859518605587715,0.07859518605587712 +0.006655172413793103,730.3210533484219,1.6440018494124522,1.6440018494124518,0.07828580235297392,0.07828580235297389 +0.0066638487208008895,731.2731683770226,1.6375389427295968,1.6375389427295963,0.07797804489188555,0.07797804489188553 +0.006672525027808676,732.2252834056231,1.6311099622644862,1.6311099622644858,0.07767190296497553,0.07767190296497552 +0.006681201334816462,733.1773984342238,1.624714684938541,1.62471468493854,0.07736736594945434,0.07736736594945429 +0.006689877641824249,734.1295134628244,1.6183528894384311,1.6183528894384305,0.07706442330659195,0.07706442330659193 +0.006698553948832035,735.0816284914251,1.6120243561997185,1.612024356199718,0.07676306458093897,0.07676306458093896 +0.006707230255839821,736.0337435200257,1.6057288673906702,1.6057288673906691,0.07646327939955572,0.07646327939955568 +0.006715906562847608,736.9858585486264,1.5994662068962424,1.5994662068962424,0.07616505747124964,0.07616505747124963 +0.006724582869855395,737.9379735772271,1.5932361603022422,1.5932361603022418,0.07586838858582105,0.07586838858582104 +0.006733259176863181,738.8900886058277,1.587038514879647,1.5870385148796466,0.07557326261331652,0.07557326261331651 +0.006741935483870968,739.8422036344284,1.5808730595690972,1.5808730595690967,0.07527966950329033,0.07527966950329032 +0.006750611790878754,740.794318663029,1.5747395849655477,1.5747395849655472,0.0749875992840737,0.07498759928407367 +0.0067592880978865405,741.7464336916297,1.56863788330309,1.5686378833030892,0.0746970420620519,0.07469704206205187 +0.006767964404894327,742.6985487202303,1.5625677484399207,1.5625677484399203,0.0744079880209486,0.07440798802094858 +0.006776640711902113,743.650663748831,1.5565289758434822,1.5565289758434817,0.07412042742111821,0.07412042742111817 +0.0067853170189099,744.6027787774316,1.5505213625757492,1.5505213625757488,0.0738343505988452,0.07383435059884517 +0.006793993325917686,745.5548938060323,1.5445447072786767,1.5445447072786762,0.07354974796565127,0.07354974796565125 +0.006802669632925472,746.5070088346329,1.5385988101597958,1.5385988101597954,0.07326661000760934,0.0732666100076093 +0.006811345939933259,747.4591238632336,1.5326834729779617,1.5326834729779615,0.07298492728466485,0.07298492728466482 +0.006820022246941045,748.4112388918342,1.5267984990292525,1.5267984990292516,0.07270469042996441,0.07270469042996437 +0.0068286985539488315,749.3633539204349,1.5209436931330138,1.5209436931330131,0.07242589014919112,0.0724258901491911 +0.006837374860956618,750.3154689490354,1.5151188616180455,1.515118861618045,0.07214851721990692,0.0721485172199069 +0.006846051167964404,751.2675839776361,1.50932381230894,1.5093238123089394,0.0718725624909019,0.07187256249090188 +0.006854727474972191,752.2196990062367,1.5035583545125544,1.5035583545125537,0.07159801688155021,0.07159801688155018 +0.006863403781979977,753.1718140348374,1.4978222990046293,1.4978222990046286,0.07132487138117281,0.07132487138117277 +0.006872080088987764,754.1239290634381,1.4921154580165408,1.4921154580165399,0.0710531170484067,0.07105311704840667 +0.006880756395995551,755.0760440920388,1.4864376452221966,1.486437645222196,0.07078274501058078,0.07078274501058075 +0.006889432703003337,756.0281591206394,1.4807886757250577,1.4807886757250572,0.07051374646309798,0.07051374646309796 +0.006898109010011123,756.9802741492401,1.4751683660453063,1.475168366045306,0.0702461126688241,0.07024611266882409 +0.00690678531701891,757.9323891778407,1.4695765341071332,1.4695765341071327,0.06997983495748253,0.06997983495748251 +0.006915461624026696,758.8845042064414,1.464012999226168,1.464012999226167,0.06971490472505561,0.06971490472505558 +0.0069241379310344825,759.836619235042,1.4584775820970295,1.4584775820970288,0.06945131343319189,0.06945131343319184 +0.006932814238042269,760.7887342636427,1.452970104781007,1.4529701047810064,0.06918905260861938,0.06918905260861935 +0.006941490545050055,761.7408492922433,1.4474903906938725,1.447490390693872,0.06892811384256536,0.06892811384256534 +0.006950166852057842,762.692964320844,1.4420382645938077,1.4420382645938072,0.06866848879018131,0.0686684887901813 +0.006958843159065628,763.6450793494446,1.436613552569464,1.4366135525694637,0.06841016916997447,0.06841016916997444 +0.006967519466073414,764.5971943780453,1.4312160820281394,1.4312160820281388,0.06815314676324473,0.06815314676324469 +0.006976195773081201,765.5493094066459,1.4258456816840763,1.425845681684076,0.06789741341352744,0.06789741341352744 +0.006984872080088987,766.5014244352466,1.4205021815468817,1.4205021815468812,0.06764296102604198,0.06764296102604196 +0.0069935483870967735,767.4535394638472,1.4151854129100605,1.4151854129100596,0.06738978156714573,0.0673897815671457 +0.00700222469410456,768.4056544924479,1.4098952083396674,1.409895208339667,0.06713786706379368,0.06713786706379367 +0.007010901001112347,769.3577695210486,1.4046314016630768,1.4046314016630763,0.06688720960300365,0.06688720960300364 +0.0070195773081201335,770.3098845496493,1.3993938279578608,1.3993938279578604,0.06663780133132671,0.06663780133132668 +0.00702825361512792,771.2619995782499,1.3941823235407846,1.3941823235407842,0.06638963445432308,0.06638963445432305 +0.007036929922135706,772.2141146068506,1.388996725956909,1.3889967259569085,0.06614270123604328,0.06614270123604327 +0.007045606229143493,773.1662296354511,1.3838368739688056,1.383836873968805,0.06589699399851455,0.06589699399851452 +0.007054282536151279,774.1183446640518,1.3787026075458817,1.3787026075458813,0.06565250512123247,0.06565250512123244 +0.007062958843159065,775.0704596926524,1.3735937678538082,1.3735937678538077,0.06540922704065753,0.0654092270406575 +0.007071635150166852,776.0225747212531,1.3685101972440574,1.3685101972440572,0.06516715224971703,0.065167152249717 +0.007080311457174638,776.9746897498537,1.3634517392435475,1.3634517392435468,0.06492627329731178,0.06492627329731174 +0.0070889877641824245,777.9268047784544,1.3584182385443822,1.3584182385443817,0.06468658278782771,0.0646865827878277 +0.007097664071190211,778.878919807055,1.3534095409937046,1.3534095409937041,0.0644480733806526,0.06444807338065256 +0.007106340378197997,779.8310348356557,1.348425493583643,1.3484254935836426,0.06421073778969728,0.06421073778969727 +0.007115016685205784,780.7831498642563,1.3434659444413635,1.343465944441363,0.06397456878292207,0.06397456878292204 +0.00712369299221357,781.735264892857,1.3385307428192181,1.3385307428192177,0.06373955918186752,0.06373955918186751 +0.007132369299221356,782.6873799214576,1.3336197390849909,1.3336197390849902,0.06350570186119003,0.06350570186119 +0.007141045606229143,783.6394949500583,1.328732784712244,1.3287327847122437,0.0632729897482021,0.06327298974820206 +0.007149721913236929,784.5916099786589,1.3238697322707569,1.3238697322707567,0.06304141582241699,0.06304141582241699 +0.007158398220244716,785.5437250072596,1.3190304354170608,1.3190304354170608,0.06281097311509815,0.06281097311509812 +0.007167074527252503,786.4958400358603,1.3142147488850686,1.3142147488850682,0.06258165470881279,0.06258165470881277 +0.007175750834260289,787.4479550644609,1.3094225284767946,1.3094225284767944,0.06235345373699022,0.06235345373699021 +0.0071844271412680755,788.4000700930616,1.304653631053168,1.3046536310531676,0.06212636338348419,0.06212636338348418 +0.007193103448275862,789.3521851216622,1.2999079145249377,1.2999079145249375,0.061900376882139896,0.06190037688213988 +0.007201779755283648,790.3043001502629,1.295185237843664,1.2951852378436635,0.06167548751636495,0.061675487516364924 +0.007210456062291435,791.2564151788634,1.2904854609928,1.2904854609927998,0.06145168861870476,0.061451688618704745 +0.007219132369299221,792.2085302074642,1.285808444978863,1.2858084449788623,0.06122897357042205,0.061228973570422014 +0.007227808676307007,793.1606452360647,1.2811540518226887,1.2811540518226883,0.061007335801080406,0.061007335801080385 +0.007236484983314794,794.1127602646654,1.2765221445507753,1.2765221445507748,0.060786768788132155,0.06078676878813212 +0.00724516129032258,795.064875293266,1.2719125871867067,1.2719125871867065,0.060567266056509854,0.06056726605650984 +0.0072538375973303666,796.0169903218667,1.2673252447426677,1.2673252447426675,0.060348821178222274,0.06034882117822226 +0.007262513904338153,796.9691053504673,1.2627599832110312,1.2627599832110308,0.06013142777195386,0.06013142777195384 +0.007271190211345939,797.921220379068,1.2582166695560384,1.2582166695560377,0.0599150795026685,0.059915079502668465 +0.007279866518353726,798.8733354076686,1.2536951717055536,1.2536951717055533,0.05969977008121684,0.059699770081216824 +0.007288542825361512,799.8254504362693,1.249195358542899,1.2491953585428988,0.05948549326394757,0.05948549326394755 +0.007297219132369299,800.77756546487,1.2447170998987738,1.2447170998987735,0.05927224285232256,0.05927224285232255 +0.007305895439377086,801.7296804934707,1.2402602665432467,1.2402602665432465,0.059060012692535566,0.05906001269253555 +0.007314571746384872,802.6817955220713,1.2358247301778267,1.235824730177826,0.058848796675134596,0.058848796675134575 +0.0073232480533926584,803.633910550672,1.231410363427611,1.2314103634276106,0.05863858873464815,0.05863858873464812 +0.007331924360400445,804.5860255792726,1.2270170398335136,1.2270170398335134,0.058429382849214936,0.05842938284921493 +0.007340600667408231,805.5381406078733,1.2226446338445616,1.2226446338445611,0.05822117304021722,0.0582211730402172 +0.0073492769744160176,806.4902556364739,1.2182930208102687,1.2182930208102685,0.05801395337191756,0.058013953371917545 +0.007357953281423804,807.4423706650746,1.2139620769730834,1.2139620769730834,0.05780771795109922,0.0578077179510992 +0.00736662958843159,808.3944856936752,1.209651679460909,1.2096516794609085,0.05760246092670995,0.057602460926709925 +0.007375305895439377,809.3466007222759,1.2053617062796915,1.2053617062796915,0.05739817648950912,0.05739817648950911 +0.007383982202447163,810.2987157508765,1.201092036306089,1.2010920363060886,0.05719485887171852,0.0571948588717185 +0.0073926585094549494,811.2508307794772,1.1968425492801993,1.1968425492801988,0.05699250234667616,0.056992502346676126 +0.007401334816462736,812.2029458080777,1.192613125798364,1.1926131257983639,0.056791101228493526,0.056791101228493505 +0.007410011123470522,813.1550608366784,1.1884036473060395,1.1884036473060393,0.05659064987171617,0.05659064987171616 +0.007418687430478309,814.107175865279,1.1842139960907359,1.1842139960907354,0.056391142670987424,0.0563911426709874 +0.007427363737486095,815.0592908938797,1.1800440552750258,1.1800440552750253,0.05619257406071551,0.05619257406071548 +0.007436040044493881,816.0114059224803,1.175893708809614,1.1758937088096137,0.05599493851474352,0.05599493851474351 +0.007444716351501669,816.9635209510811,1.171762841466479,1.171762841466479,0.0557982305460228,0.0557982305460228 +0.007453392658509455,817.9156359796817,1.1676513388320768,1.1676513388320764,0.055602444706289375,0.05560244470628934 +0.007462068965517241,818.8677510082824,1.1635590873006079,1.1635590873006079,0.05540757558574323,0.05540757558574322 +0.007470745272525028,819.819866036883,1.1594859740673522,1.1594859740673518,0.055213617812731056,0.05521361781273103 +0.007479421579532814,820.7719810654837,1.1554318871220617,1.1554318871220617,0.055020566053431516,0.05502056605343151 +0.0074880978865406005,821.7240960940843,1.1513967152424225,1.151396715242422,0.054828415011543924,0.0548284150115439 +0.007496774193548387,822.676211122685,1.147380347987568,1.1473803479875675,0.05463715942797942,0.054637159427979407 +0.007505450500556173,823.6283261512856,1.143382675691666,1.1433826756916656,0.05444679408055553,0.0544467940805555 +0.00751412680756396,824.5804411798863,1.1394035894575565,1.1394035894575563,0.05425731378369317,0.054257313783693153 +0.007522803114571746,825.5325562084869,1.1354429811504536,1.1354429811504532,0.05406871338811684,0.05406871338811681 +0.007531479421579532,826.4846712370876,1.1315007433917041,1.1315007433917035,0.05388098778055733,0.053880987780557305 +0.007540155728587319,827.4367862656882,1.127576769552608,1.1275767695526078,0.05369413188345752,0.0536941318834575 +0.007548832035595105,828.3889012942889,1.1236709537482956,1.1236709537482952,0.053508140654680736,0.05350814065468073 +0.0075575083426028915,829.3410163228895,1.1197831908316578,1.1197831908316576,0.053323009087221804,0.0533230090872218 +0.007566184649610678,830.2931313514902,1.1159133763873417,1.1159133763873412,0.05313873220892103,0.053138732208921 +0.007574860956618464,831.2452463800907,1.1120614067257906,1.1120614067257903,0.0529553050821805,0.052955305082180486 +0.0075835372636262515,832.1973614086914,1.1082271788773523,1.1082271788773523,0.05277272280368345,0.05277272280368344 +0.007592213570634038,833.1494764372922,1.104410590586435,1.1044105905864348,0.05259098050411595,0.05259098050411593 +0.007600889877641824,834.1015914658927,1.1006115403057146,1.100611540305714,0.05241007334789117,0.052410073347891134 +0.007609566184649611,835.0537064944934,1.096829927190404,1.0968299271904036,0.05222999653287638,0.052229996532876355 +0.007618242491657397,836.005821523094,1.0930656510925703,1.09306565109257,0.052050745290122394,0.05205074529012238 +0.007626918798665183,836.9579365516947,1.0893186125555054,1.089318612555505,0.051872314883595505,0.05187231488359546 +0.00763559510567297,837.9100515802953,1.0855887128081494,1.085588712808149,0.051694700609911874,0.05169470060991185 +0.007644271412680756,838.862166608896,1.0818758537595645,1.0818758537595639,0.051517897798074495,0.05151789779807446 +0.0076529477196885425,839.8142816374966,1.0781799379934625,1.0781799379934618,0.0513419018092125,0.05134190180921247 +0.007661624026696329,840.7663966660973,1.074500868762779,1.0745008687627784,0.05116670803632281,0.051166708036322776 +0.007670300333704115,841.7185116946979,1.0708385499843014,1.070838549984301,0.05099231190401435,0.05099231190401432 +0.007678976640711902,842.6706267232986,1.0671928862333415,1.0671928862333415,0.05081870886825436,0.05081870886825435 +0.007687652947719688,843.6227417518992,1.0635637827384645,1.0635637827384645,0.05064589441611736,0.05064589441611736 +0.007696329254727474,844.5748567804999,1.0599511453762573,1.0599511453762571,0.05047386406553606,0.05047386406553605 +0.007705005561735261,845.5269718091005,1.0563548806661525,1.0563548806661522,0.050302613365054885,0.05030261336505487 +0.007713681868743047,846.4790868377012,1.0527748957652947,1.0527748957652945,0.050132137893585456,0.05013213789358544 +0.0077223581757508335,847.4312018663018,1.0492110984634575,1.0492110984634573,0.04996243326016464,0.049962433260164626 +0.007731034482758621,848.3833168949026,1.045663397178003,1.0456633971780025,0.049793495103714426,0.049793495103714405 +0.007739710789766407,849.3354319235032,1.0421317009488915,1.0421317009488913,0.04962531909280436,0.049625319092804335 +0.0077483870967741935,850.2875469521039,1.0386159194337337,1.0386159194337334,0.049457900925415896,0.04945790092541588 +0.00775706340378198,851.2396619807045,1.0351159629028885,1.0351159629028879,0.04929123632870898,0.04929123632870895 +0.007765739710789766,852.1917770093052,1.031631742234605,1.0316317422346049,0.04912532105879072,0.0491253210587907 +0.007774416017797553,853.1438920379057,1.0281631689102126,1.0281631689102122,0.04896015090048632,0.0489601509004863 +0.007783092324805339,854.0960070665064,1.0247101550093476,1.0247101550093471,0.04879572166711178,0.04879572166711176 +0.007791768631813125,855.048122095107,1.0212726132052274,1.0212726132052274,0.048632029200248936,0.04863202920024892 +0.007800444938820912,856.0002371237077,1.0178504567599707,1.0178504567599704,0.048469069369522416,0.0484690693695224 +0.007809121245828698,856.9523521523083,1.0144435995199514,1.0144435995199512,0.048306838072378636,0.04830683807237862 +0.007817797552836485,857.904467180909,1.0110519559112023,1.011051955911202,0.04814533123386678,0.04814533123386676 +0.007826473859844271,858.8565822095096,1.007675440934856,1.0076754409348556,0.04798454480642171,0.0479845448064217 +0.007835150166852057,859.8086972381103,1.0043139701626291,1.0043139701626291,0.04782447476964901,0.047824474769649 +0.007843826473859844,860.7608122667109,1.0009674597323441,1.0009674597323441,0.04766511713011162,0.04766511713011162 +0.00785250278086763,861.7129272953116,0.9976358263434966,0.9976358263434966,0.047506467921118886,0.047506467921118886 +0.007861179087875416,862.6650423239122,0.9943189872528548,0.9943189872528547,0.047348523202516896,0.04734852320251688 +0.007869855394883205,863.617157352513,0.991016860270107,0.991016860270107,0.04719127906048129,0.04719127906048128 +0.007878531701890991,864.5692723811137,0.9877293637535417,0.9877293637535416,0.04703473160731151,0.0470347316073115 +0.007887208008898777,865.5213874097143,0.9844564166057703,0.9844564166057702,0.046878876981227155,0.046878876981227155 +0.007895884315906564,866.473502438315,0.9811979382694828,0.9811979382694827,0.04672371134616585,0.04672371134616584 +0.00790456062291435,867.4256174669156,0.9779538487232471,0.977953848723247,0.04656923089158319,0.04656923089158319 +0.007913236929922136,868.3777324955163,0.9747240684773437,0.9747240684773437,0.04641543183225447,0.04641543183225447 +0.007921913236929923,869.3298475241169,0.9715085185696375,0.9715085185696375,0.046262310408077975,0.046262310408077975 +0.00793058954393771,870.2819625527176,0.9683071205614839,0.9683071205614839,0.04610986288388019,0.04610986288388018 +0.007939265850945496,871.2340775813182,0.9651197965336747,0.9651197965336746,0.04595808554922261,0.0459580855492226 +0.007947942157953282,872.1861926099189,0.9619464690824191,0.961946469082419,0.045806974718210436,0.04580697471821042 +0.007956618464961068,873.1383076385194,0.9587870613153612,0.9587870613153611,0.04565652672930291,0.04565652672930291 +0.007965294771968855,874.0904226671202,0.955641496847626,0.9556414968476259,0.045506737945125046,0.045506737945125046 +0.007973971078976641,875.0425376957207,0.9525096997979123,0.9525096997979122,0.04535760475228154,0.04535760475228154 +0.007982647385984427,875.9946527243214,0.9493915947846097,0.9493915947846094,0.04520912356117189,0.045209123561171884 +0.007991323692992214,876.946767752922,0.9462871069219545,0.9462871069219543,0.045061290805807355,0.04506129080580735 +0.008,877.8988827815227,0.9431961618162192,0.9431961618162192,0.04491410294362949,0.04491410294362949 diff --git a/spectroscopy_report/figures/ir_legacy_vs_symmetry.pdf b/spectroscopy_report/figures/ir_legacy_vs_symmetry.pdf new file mode 100644 index 00000000..7184ccb8 Binary files /dev/null and b/spectroscopy_report/figures/ir_legacy_vs_symmetry.pdf differ diff --git a/spectroscopy_report/figures/kernel_scaling.pdf b/spectroscopy_report/figures/kernel_scaling.pdf new file mode 100644 index 00000000..80920142 Binary files /dev/null and b/spectroscopy_report/figures/kernel_scaling.pdf differ diff --git a/spectroscopy_report/figures/raman_legacy_vs_symmetry.pdf b/spectroscopy_report/figures/raman_legacy_vs_symmetry.pdf new file mode 100644 index 00000000..b4b13643 Binary files /dev/null and b/spectroscopy_report/figures/raman_legacy_vs_symmetry.pdf differ diff --git a/spectroscopy_report/figures/timings.pdf b/spectroscopy_report/figures/timings.pdf new file mode 100644 index 00000000..c54a71b0 Binary files /dev/null and b/spectroscopy_report/figures/timings.pdf differ diff --git a/spectroscopy_report/tdscha_spectroscopy_report.pdf b/spectroscopy_report/tdscha_spectroscopy_report.pdf new file mode 100644 index 00000000..43096375 Binary files /dev/null and b/spectroscopy_report/tdscha_spectroscopy_report.pdf differ diff --git a/spectroscopy_report/tdscha_spectroscopy_report.tex b/spectroscopy_report/tdscha_spectroscopy_report.tex new file mode 100644 index 00000000..6357f223 --- /dev/null +++ b/spectroscopy_report/tdscha_spectroscopy_report.tex @@ -0,0 +1,1064 @@ +\documentclass[11pt,a4paper]{article} + +\usepackage[a4paper,margin=2.3cm]{geometry} +\usepackage{amsmath,amssymb,bm,mathtools} +\usepackage{booktabs,array,longtable} +\usepackage{graphicx} +\usepackage{xcolor} +\usepackage{listings} +\usepackage{microtype} +\usepackage{hyperref} +\usepackage[capitalise,noabbrev]{cleveref} + +\hypersetup{ + colorlinks=true, + linkcolor=blue!55!black, + citecolor=green!35!black, + urlcolor=blue!60!black, + pdftitle={Symmetry-Aware Raman and Infrared Spectroscopy in TD-SCHA} +} + +\definecolor{codeblue}{RGB}{34,82,126} +\definecolor{codegray}{RGB}{245,246,247} +\lstset{ + basicstyle=\ttfamily\small, + keywordstyle=\color{codeblue}\bfseries, + commentstyle=\color{green!35!black}, + backgroundcolor=\color{codegray}, + frame=single, + framesep=5pt, + showstringspaces=false, + breaklines=true, + columns=fullflexible, + language=Python +} + +\newcommand{\dd}{\mathrm{d}} +\newcommand{\ii}{\mathrm{i}} +\newcommand{\Tr}{\operatorname{Tr}} +\newcommand{\Span}{\operatorname{span}} +\newcommand{\Oh}{\mathrm{O_h}} +\newcommand{\epsinf}{\bm\varepsilon_\infty} +\newcommand{\angstrom}{\text{\AA}} +\input{data/benchmark_values.tex} + +\title{\textbf{Symmetry-Aware Raman and Infrared Spectroscopy in TD-SCHA}\\ +\large API unification, group-theoretical reduction, validation, and audit} +\author{TD-SCHA development report} +\date{5 August 2026} + +\begin{document} +\maketitle + +\begin{abstract} +This report documents a new, backend-neutral optical-spectroscopy layer for +TD-SCHA. It replaces a confusing collection of Raman and infrared (IR) +preparation routines with one restartable \texttt{Spectroscopy} workflow for +polarized and unpolarized observables. The physical perturbations are defined +once; real-space, reciprocal-space, and atom-Fourier Lanczos classes only +implement representation-specific numerical hooks. A finite-group orbit +analysis removes symmetry-equivalent external perturbations. Within every +remaining Lanczos application, a stabilizer projector and a right-coset +decomposition reduce the expensive symmetrized ensemble average without +changing it. + +On the bundled cubic two-atom, ten-configuration test ensemble, the controlled +unpolarized Raman benchmark has \RamanRequested{} requested invariant +components, of which \RamanNonzero{} are nonzero and only +\RamanIndependent{} is independent. Unpolarized IR similarly falls from +three runs to \IRIndependent{}. New and legacy spectra agree to relative +$L^\infty$ errors of \RamanError{} (Raman) and \IRError{} (IR). For +\BenchSteps{} Lanczos steps, measured end-to-end speedups are +\RamanSpeedup$\times$ and \IRSpeedup$\times$, respectively. At +\KernelConfigurations{} configurations, the isolated anharmonic kernels +agree with the full group below $10^{-13}$ relative error and are +\RealKernelSpeedup$\times$ faster in real space and \QKernelSpeedup$\times$ +faster in q space. A configuration-scaling study explains why the same +ratios are only about 1.9 and 1.6 at ten configurations. + +The audit also removes the obsolete kernel-polynomial-method (KPM) backend, +quarantines unvalidated two-phonon optical code behind +\texttt{NotImplementedError}, and fixes zero-valued, symmetry-forbidden Raman +channels so they contribute exactly zero without launching an invalid Lanczos +recursion. This is a stand-alone spectroscopy report: it is separate from, +and does not replace, the interpolation report. +\end{abstract} + +\tableofcontents +\newpage + +\section{Scope and design outcome} + +The stable user-facing object is +\texttt{tdscha.Spectroscopy.Spectroscopy}. It owns four tasks that were +previously mixed into mutable Lanczos engines: +\begin{enumerate} + \item defining the requested Raman or IR observable; + \item finding symmetry-equivalent perturbations and independent runs; + \item executing and restarting those runs; and + \item reconstructing response, Stokes/anti-Stokes Raman spectra, and the IR + dielectric function. +\end{enumerate} +Lanczos classes remain numerical engines. This separation mirrors the role +of the Hessian driver: the high-level class expresses the physical task while +the backend performs applications of the TD-SCHA linear operator. + +The implementation supports three backends: +\texttt{real}, \texttt{qspace}, and \texttt{atom\_fourier}. KPM is not a +fourth option: its source, build entry, CLI handling, tests, and documentation +were removed because it was unmaintained and no longer useful for this code +base. The two-phonon Raman source survives only as an inert private archival +file, not as a callable implementation. + +\subsection{A compact view of the architecture} + +\begin{center} +\fbox{\begin{minipage}{0.91\linewidth} +\centering +\textbf{Physical request}\\[-2pt] +polarized Raman $\mid$ powder Raman $\mid$ polarized IR $\mid$ powder IR +\\[5pt] +$\Downarrow$ shared tensor contractions and validation\\[5pt] +\textbf{Symmetry planner}\\[-2pt] +point-group representation $\to$ orbits $\to$ stabilizers $\to$ right cosets +\\[5pt] +$\Downarrow$ one immutable run specification per representative\\[5pt] +\textbf{Workflow and persistence}\\[-2pt] +manifest $+$ native restart $+$ portable Lanczos coefficients +\\[5pt] +$\Downarrow$ narrow backend hook\\[5pt] +\textbf{Lanczos engine}\\[-2pt] +real space $\mid$ q space $\mid$ atom-Fourier +\\[5pt] +$\Downarrow$ shared continued-fraction analysis\\[5pt] +\textbf{Observable}\\[-2pt] +$-\Im G$, Stokes/anti-Stokes intensity, $\chi^{\rm ion}$, or $\varepsilon$ +\end{minipage}} +\end{center} + +No backend duplicates the Placzek components, Born-charge contraction, +request planning, restart protocol, or spectrum assembly. Conversely, the +workflow does not duplicate Lanczos recurrence or continued-fraction code. + +\section{Audit of the legacy Raman API} +\label{sec:legacy-audit} + +\subsection{Why the old API was confusing} + +The legacy class exposed two similarly named methods which did not compute a +complete unpolarized spectrum: +\begin{itemize} + \item \texttt{prepare\_raman()} defaults to a \emph{polarized $xx$} + perturbation. Its optional \texttt{unpolarized=i} selector prepares + one of seven \emph{normalized} invariant components. + \item \texttt{prepare\_unpolarized\_raman(index=0)} also prepares only one + component, but in a \emph{raw Cartesian} normalization. Despite its + name, one call never computes the powder spectrum. +\end{itemize} +Both variants are mathematically correct in the present branch only when +paired with their matching weights. They are summarized in +\cref{tab:legacy-normalizations}. + +\begin{table}[ht] +\centering +\caption{Equivalent component conventions. $R_{ab}$ denotes a Raman +derivative vector, not a scalar mode intensity.} +\label{tab:legacy-normalizations} +\small +\begin{tabular}{@{}clcc@{}} +\toprule +$j$ & component & normalized vector; weight & raw vector; weight\\ +\midrule +0 & isotropic & $(R_{xx}+R_{yy}+R_{zz})/3$; $45$ + & $R_{xx}+R_{yy}+R_{zz}$; $5$\\ +1 & diagonal & $(R_{xx}-R_{yy})/\sqrt2$; $7$ + & $R_{xx}-R_{yy}$; $7/2$\\ +2 & diagonal & $(R_{xx}-R_{zz})/\sqrt2$; $7$ + & $R_{xx}-R_{zz}$; $7/2$\\ +3 & diagonal & $(R_{yy}-R_{zz})/\sqrt2$; $7$ + & $R_{yy}-R_{zz}$; $7/2$\\ +4--6 & off-diagonal & $\sqrt3 R_{xy,xz,yz}$; $7$ + & $R_{xy,xz,yz}$; $21$\\ +\bottomrule +\end{tabular} +\end{table} + +For each row, scaling the perturbation by $s$ scales its diagonal response by +$s^2$. Therefore $45(1/3)^2=5$, $7(1/\sqrt2)^2=7/2$, and +$7(\sqrt3)^2=21$. The two columns produce exactly the same total if, and +only if, the corresponding weights are used. Combining normalized vectors +with raw weights (or the reverse) is wrong. + +The old \texttt{mixed=True} option adds two polarization contractions +\emph{coherently} before taking the response. This is not the same as adding +two spectra. The stable API removes that ambiguity: a general coherent +contraction is a single symmetric $3\times3$ Raman coefficient tensor, while +separate measurements are separate named requests. + +\subsection{Was one implementation wrong?} + +Historically, yes. Before the 1.7 hotfix, the normalized +\texttt{prepare\_raman(unpolarized=i)} path accumulated channels 0--3 and +then overwrote the accumulated vector with the final Cartesian term. In +particular, channels 2 and 3 became identical and $R_{xx}$ disappeared from +the powder result. Saved normalized channels 0--3 from that version cannot +be corrected after the fact because the missing diagonal cross terms were +never computed; they must be rerun. Channels 4--6 were unaffected. + +The present implementation has one shared vector builder and characterization +tests for both conventions. Thus the current answer is: +\begin{quote} +The normalized and raw one-phonon methods are both correct, but they are two +normalizations of the same seven-term decomposition. Their former duplicate +implementations and names made correct use unnecessarily difficult, and one +duplicate did contain a serious overwrite bug. +\end{quote} + +\section{Linear-response quantities} +\label{sec:response} + +\subsection{Coordinates, perturbations, and the Green function} + +Consider a primitive cell with $N$ atoms. Let +$\bm u\in\mathbb R^{3N}$ collect Cartesian displacements and let +\begin{equation} + \bm M=\operatorname{diag}(M_1,M_1,M_1,\ldots,M_N,M_N,M_N) +\end{equation} +be the mass matrix. An external generalized force has the linear coupling +\begin{equation} + \delta \hat H(t)=-f(t)\,\bm v^{\mathsf T}\bm u, + \label{eq:external-coupling} +\end{equation} +where $\bm v$ is the Cartesian optical vertex. The backend maps this vertex +to its mass-weighted normal-coordinate representation. This map, including +the factor associated with a repeated supercell-periodic $\Gamma$ pattern, +is backend-specific; the definition of $\bm v$ is not. + +In the Wigner TD-SCHA formulation~\cite{siciliano2023}, the linearized +dynamics is represented by a linear operator $\mathcal L_W$ acting on the +one- and two-phonon response variables. For a prepared Krylov vector +$|p\rangle$, the code evaluates a projected resolvent of the form +\begin{equation} + G_p(\omega)= + \langle p|\bigl[\mathcal L_W+(\omega+\ii\eta)^2\bigr]^{-1}|p\rangle, + \label{eq:resolvent} +\end{equation} +with the exact internal sign transformation documented by the Wigner +Lanczos implementation. The nonnegative, Bose-free spectral response used +by the public API is +\begin{equation} + \mathcal R_p(\omega)=-\Im G_p(\omega),\qquad \omega>0. + \label{eq:response} +\end{equation} +Lanczos tridiagonalization~\cite{lanczos1950} turns +\cref{eq:resolvent} into a continued fraction. The new workflow calls that +existing implementation; it does not introduce a second spectral evaluator. + +\subsection{Equilibrium one-phonon scope} + +This report concerns an equilibrium optical vertex that is linear in +displacement. Schematically, +\begin{equation} + A(\bm u)=A(\bm 0)+\bm v^{\mathsf T}\bm u+O(u^2). +\end{equation} +Configuration-dependent effective charges and second derivatives of the +polarizability would produce explicit two-phonon optical vertices. The +legacy implementation of those terms did not have a sufficiently audited +observable definition, units, or symmetry action. It is therefore disabled +rather than silently mixed with the validated one-phonon response. + +\section{Raman observables} +\label{sec:raman} + +\subsection{Polarized Raman vertex} + +Let $\alpha_{ab}$ be the electronic polarizability tensor and define its +equilibrium derivative +\begin{equation} + P_{ab,i}=\left.\frac{\partial\alpha_{ab}}{\partial u_i}\right|_{\bm u=0}, + \qquad a,b\in\{x,y,z\},\quad i=1,\ldots,3N. + \label{eq:raman-derivative} +\end{equation} +For incident and analyzed unit polarization vectors $\bm e_{\rm in}$ and +$\bm e_{\rm out}$, nonresonant Raman scattering probes the symmetric +coefficient matrix +\begin{equation} + \bm C=\frac12\left( + \bm e_{\rm in}\bm e_{\rm out}^{\mathsf T}+ + \bm e_{\rm out}\bm e_{\rm in}^{\mathsf T}\right). +\end{equation} +The prepared Cartesian vertex is the linear contraction +\begin{equation} + v_i(\bm C)=\sum_{ab}C_{ab}P_{ab,i}. + \label{eq:raman-vertex} +\end{equation} +This equation is the single source of truth used by +\texttt{add\_raman\_polarized} and \texttt{add\_raman\_tensor}. +Antisymmetric/resonant Raman tensors are outside this API because their +rotational invariants differ. + +\subsection{Unpolarized Placzek invariant} + +For a symmetric Raman tensor $R$ associated with one response channel, define +\begin{align} + \bar\alpha &= \frac{R_{xx}+R_{yy}+R_{zz}}{3},\\ + \gamma^2 &= \frac12\left[(R_{xx}-R_{yy})^2+ + (R_{xx}-R_{zz})^2+(R_{yy}-R_{zz})^2\right] + +3\left(R_{xy}^2+R_{xz}^2+R_{yz}^2\right). +\end{align} +The conventional rotationally averaged nonresonant intensity is +\begin{equation} + I_{\rm powder}=45\,\bar\alpha^2+7\,\gamma^2. + \label{eq:placzek} +\end{equation} +The same invariant is used in first-principles powder Raman methods; polar +materials may require additional nonanalytic/electro-optic physics beyond +this basic average~\cite{popov2020}. + +Equation~\eqref{eq:placzek} is a sum of seven diagonal response functions, +not seven arbitrary light polarizations. Define the coefficient tensors +$C^{(j)}$ by the normalized column of +\cref{tab:legacy-normalizations}. Then +\begin{equation} + \mathcal R_{\rm powder}(\omega)= + 45\,\mathcal R_{v(C^{(0)})}(\omega) + +7\sum_{j=1}^{6}\mathcal R_{v(C^{(j)})}(\omega). + \label{eq:powder-response} +\end{equation} +The API records every term, including an exactly zero $v(C^{(j)})$. A zero +term contributes zero and creates no numerical run. + +\subsection{Response, Stokes, and anti-Stokes output} + +With +\begin{equation} + n_B(\omega,T)=\frac{1}{\exp(\hbar\omega/k_BT)-1}, +\end{equation} +the thermal factors returned by the implementation are +\begin{align} + I_{\rm S}(\omega)&=[n_B(\omega,T)+1]\,\mathcal R(\omega),\\ + I_{\rm AS}(\omega)&=n_B(\omega,T)\,\mathcal R(\omega). +\end{align} +Consequently $I_{\rm S}-I_{\rm AS}=\mathcal R$ and +$I_{\rm AS}/I_{\rm S}=\exp(-\hbar\omega/k_BT)$. +If a laser frequency $\omega_L$ is supplied in the same units as $\omega$, +the API additionally multiplies by $(\omega_L-\omega)^4$ for Stokes or +$(\omega_L+\omega)^4$ for anti-Stokes. Absolute cross sections still require +the experiment-dependent prefactor and a consistent polarizability unit. + +\section{Infrared observables} +\label{sec:ir} + +\subsection{Born effective-charge vertex} + +The Born effective-charge tensor connects a displacement of atom $\kappa$ to +the macroscopic polarization: +\begin{equation} + Z^*_{\kappa,ai}=\Omega\left. + \frac{\partial P_a}{\partial u_{\kappa i}}\right|_{\bm{\mathcal E}=0}, + \label{eq:born-charge} +\end{equation} +in the atomic-unit convention used by CellConstructor. For a unit electric +field direction $\bm e$, the Cartesian IR vertex is +\begin{equation} + v_{\kappa i}(\bm e)=\sum_a e_a Z^*_{\kappa,ai}. + \label{eq:ir-vertex} +\end{equation} +The powder response is the Cartesian trace average +\begin{equation} + G_{\rm powder}(\omega)=\frac13\sum_{a=x,y,z}G_{v(\bm e_a)}(\omega). + \label{eq:ir-powder} +\end{equation} +In cubic symmetry the three terms belong to one orbit, so one Lanczos run is +sufficient. + +\subsection{Ionic susceptibility and electronic background} + +The raw Lanczos Green function uses a Rydberg energy convention. Because +$1\,\mathrm{Ha}=2\,\mathrm{Ry}$, the projected ionic susceptibility exposed +by the API is +\begin{equation} + \chi^{\rm ion}_{\bm e}(\omega)=2G_{v(\bm e)}(\omega) + \label{eq:chi-ionic} +\end{equation} +in Hartree atomic units. Let $\epsinf$ be the clamped-ion electronic +dielectric tensor. The projected total dielectric response is +\begin{equation} + \varepsilon_{\bm e}(\omega)= + \bm e^{\mathsf T}\epsinf\bm e+ + \frac{4\pi}{\Omega_{\rm bohr^3}} + \chi^{\rm ion}_{\bm e}(\omega). + \label{eq:dielectric} +\end{equation} +For the unpolarized request, the electronic term is +$\Tr(\epsinf)/3$. Cell volume is read in $\angstrom^3$ and converted to +bohr$^3$ before applying \cref{eq:dielectric}. An explicit +\texttt{ionic\_prefactor} override is available for a different +electromagnetic convention; no hidden material-dependent scaling is applied. + +The retarded response convention in the present evaluator is reported through +$-\Im G\ge0$. If an absorption coefficient or optical conductivity is +formed downstream, the sign must be kept consistent with that Fourier +convention. The stable layer returns the complex dielectric function rather +than guessing SI/cgs conversion, refractive-index branch, sample geometry, or +experimental broadening. + +\section{Finite groups as matrices} +\label{sec:group-theory} + +This section develops the symmetry reduction using only finite-dimensional +linear algebra. No prior abstract-algebra course is assumed. + +\subsection{Group and representation} + +A finite symmetry group $G=\{g_1,\ldots,g_{|G|}\}$ is a set of operations +closed under composition, with an identity and inverses. To act on a +Cartesian displacement vector, each operation is represented by an +orthogonal matrix +\begin{equation} + D(g)\in\mathbb R^{3N\times3N},\qquad + D(g_1g_2)=D(g_1)D(g_2),\qquad D(g)^{\mathsf T}D(g)=I. + \label{eq:representation} +\end{equation} +The matrix combines a $3\times3$ Cartesian rotation with a permutation of +symmetry-equivalent atoms. The implementation obtains crystallographic +operations with spglib~\cite{togo2024}, filters out rotations incompatible +with the finite supercell mesh, and then constructs and verifies +\cref{eq:representation} numerically. + +For example, a $90^\circ$ rotation about $z$ acts on a vector by +\begin{equation} + R_z=\begin{pmatrix}0&-1&0\\1&0&0\\0&0&1\end{pmatrix},\qquad + R_z\begin{pmatrix}1\\0\\0\end{pmatrix}= + \begin{pmatrix}0\\1\\0\end{pmatrix}. +\end{equation} +For many atoms, copies of $R_z$ appear in a larger matrix and are connected by +the atom permutation. + +\subsection{Orbits remove duplicate external calculations} + +Given a nonzero prepared perturbation $\bm v$, its sign-aware orbit is +\begin{equation} + \mathcal O_{\bm v}= + \{\,\chi D(g)\bm v:g\in G,\ \chi\in\{+1,-1\}\,\}. + \label{eq:orbit} +\end{equation} +The sign is harmless for a diagonal response because +$G_{-v,-v}=G_{v,v}$, but it is stored for future cross-response work. Two +requested Cartesian vertices share one Lanczos run only if their complete +vectors have equal norm and match under an actual $D(g)$ to tolerance. The +planner never assumes that labels such as ``$x$'' and ``$y$'' are equivalent. + +For cubic IR, +\begin{equation} + \{\bm v_x,\bm v_y,\bm v_z\}=\mathcal O_{\bm v_x} +\end{equation} +up to signs; thus three requested terms need one representative. For a +generic low-symmetry crystal the three vectors need not share an orbit and no +such reduction is made. + +\subsection{Stabilizer and orbit--stabilizer counting} + +The sign-aware stabilizer (little group) of $\bm v$ is +\begin{equation} + H_{\bm v}=\{h\in G:D(h)\bm v=\chi(h)\bm v,\quad + |\chi(h)|=1\}. + \label{eq:stabilizer} +\end{equation} +It is a subgroup. At $\Gamma$ the optical perturbations are real and +$\chi(h)=\pm1$. At finite non-time-reversal-invariant momentum the Bloch +representation is complex and $\chi(h)$ can be any unit phase. In either +case it is a one-dimensional representation because +\begin{equation} + \chi(h_1h_2)=\chi(h_1)\chi(h_2). +\end{equation} +The orbit--stabilizer theorem becomes a simple counting identity: +\begin{equation} + |\mathcal O_{\bm v}|=\frac{|G|}{|H_{\bm v}|}. + \label{eq:orbit-stabilizer} +\end{equation} +In the cubic benchmark, $|G|=\GroupOrder$ and +$|H_{\bm v}|=\StabilizerOrder$, so the orbit contains +$\GroupOrder/\StabilizerOrder=\CosetCount$ Cartesian directions. + +\subsection{Right cosets reduce the inner ensemble average} + +Choose one representative $r_c$ from each right coset +\begin{equation} + H_{\bm v}r_c=\{hr_c:h\in H_{\bm v}\},\qquad + G=\bigsqcup_{c=1}^{m}H_{\bm v}r_c,qquad + m=|G|/|H_{\bm v}|. + \label{eq:right-cosets} +\end{equation} +The disjoint-union symbol means every $g\in G$ appears in exactly one coset. +Define the character-weighted projector +\begin{equation} + P_H^{(\chi)}=\frac1{|H|}\sum_{h\in H}\chi(h)^*D(h). + \label{eq:projector} +\end{equation} +From the representation rule in \cref{eq:representation}, one verifies +\begin{equation} + \left(P_H^{(\chi)}\right)^2=P_H^{(\chi)},\qquad + D(h)P_H^{(\chi)}=\chi(h)P_H^{(\chi)}. +\end{equation} +Thus $P_H^{(\chi)}$ selects the part transforming like the perturbation. + +Let $\bm f_{r_c}$ and $K_{r_c}$ be, respectively, the perturbed force vector +and second-derivative matrix obtained after applying only coset representative +$r_c$ to an ensemble configuration. Equivariance rewrites the full group +average as +\begin{align} + \overline{\bm f} + &=\frac1m\sum_{c=1}^{m}\frac1{|H|} + \sum_{h\in H}\chi(h)^*D(h)\bm f_{r_c}, + \label{eq:force-project}\\ + \overline K + &=\frac1m\sum_{c=1}^{m}\frac1{|H|} + \sum_{h\in H}\chi(h)^*D(h)K_{r_c}D(h)^{\mathsf T}. + \label{eq:hessian-project} +\end{align} +Equations~\eqref{eq:force-project}--\eqref{eq:hessian-project} are not an +approximation. They rearrange the same $|G|$ terms into $m$ expensive +ensemble transformations followed by a cheap matrix projector. Right rather +than left cosets are required by the order in which this implementation acts +on the vectors; the multiplication table and the matrix representation are +tested together to prevent an ordering mistake. + +\section{Implementation details} +\label{sec:implementation} + +\subsection{One source of truth for perturbations} + +\texttt{Modules/Spectroscopy.py} defines immutable perturbation objects and +the seven Raman components. The contractions in +\cref{eq:raman-vertex,eq:ir-vertex} occur once. Legacy methods delegate to +those functions and then call a single backend hook, +\texttt{\_prepare\_gamma\_cartesian\_perturbation}. + +Real space tiles the unit-cell vertex over the supercell and performs the +existing mass/normal-mode projection. Q space inserts the vertex at +$\Gamma$, with its established $\sqrt{N_{\rm cell}}$ normalization. +\texttt{QSpaceLanczos} does not redeclare public Raman or IR methods. +\texttt{QSpaceAtomFourierLanczos} inherits the q-space optical preparation and +overrides only the fine/coarse numerical representation needed by its +operator. This is deliberate: atom-Fourier interpolation is a backend, not a +second spectroscopy API. + +\subsection{Planning and global deduplication} + +For every request the planner: +\begin{enumerate} + \item contracts the optical tensor into a complete Cartesian vector; + \item records exactly zero vectors as zero contributions; + \item partitions nonzero vectors into sign-aware orbits; + \item creates one stable content-derived run identifier per representative; + \item stores stabilizer indices, characters, right cosets, assembly weights, + and original component indices; and + \item deduplicates representatives across different named requests. +\end{enumerate} +The zero-vector step was added during this audit. Rejecting all zero vectors +is sensible for a low-level Lanczos engine, because their norm cannot +normalize a Krylov recurrence. It was wrong at the high level: a +symmetry-forbidden optical tensor component is a valid term whose response is +identically zero. + +\subsection{Kernel integration} + +The real-space and q-space Julia kernels accept three optional arrays: +selected coset symmetry indices, stabilizer indices, and stabilizer +characters. Empty arrays select the legacy full average. Otherwise the +kernel evaluates only the selected ensemble replicas and applies +\cref{eq:force-project,eq:hessian-project} to both the force and every +second-derivative block. + +The q-space implementation reconstructs the complete sparse block operator, +applies $D(h)KD(h)^{\mathsf T}$, and extracts the independent momentum-pair +blocks again. The atom-Fourier backend calls the same q-space projector after +its fine/coarse mapping. Unsupported execution modes or a nonreducing +stabilizer automatically use the full average. + +For optical $\Gamma$ requests, the workflow calls +\texttt{configure\_spectroscopy\_symmetry}. For a manually prepared finite-q +perturbation, \texttt{QSpaceLanczos.configure\_qspace\_perturbation\_symmetry} +instead applies each exact cached sparse Bloch matrix to the one-phonon input, +detects its invariant complex line, and constructs the little group, right +cosets, and conjugated unit-phase projector coefficients. An element mapping +$q$ outside its star component cannot belong to this stabilizer. Because an +inner-group operation preserves total momentum, its rank-two action maps the +stored pairs $q_1+q_2=q$ back into the same bilinear pair sector; transpose, +not Hermitian conjugation, is therefore the correct second-index action. + +The public \texttt{Spectroscopy} constructor also promotes +\texttt{ignore\_v3}, \texttt{ignore\_v4}, and \texttt{lo\_to\_split} out of +an opaque backend dictionary. The old dictionary spelling is still parsed +for script compatibility. Atom-Fourier now passes the chosen nonanalytic +$\Gamma$ direction through the parent q-space basis, pins commensurate fine +points to that basis, and keeps the tensorial dipole--dipole term between +coarse points. With \texttt{ignore\_effective\_charges=True}, this harmonic +backend instead omits the entire dipolar interpolation, including the +directional $\Gamma$ limit. This uses a private dynamical-matrix copy: the +original $Z^*$ remains available for the IR vertex. + +\subsection{Restart and load-only analysis} + +Each independent run has +\begin{verbatim} +spectroscopy/ + manifest.json + runs// + status.npz + result.npz + lanczos.abc + metadata.json +\end{verbatim} +The manifest includes the request definitions, backend and options, structure, +temperature, optical tensors through an ensemble fingerprint, orbit and coset +metadata, completed steps, convergence state, and errors. JSON, native +status, and portable coefficient files are written to temporary siblings and +committed with an atomic rename. Only the MPI master writes; barriers keep +ranks at the same state transition. + +\texttt{Spectroscopy.load} reconstructs all named observables from completed +representatives. A portable \texttt{.abc} file is sufficient for load-only +analysis because its perturbation modulus and the sign/shift/Wigner +conventions are recorded. Resuming the recurrence uses native status and a +compatible ensemble. A changed request, backend option, run option, or +ensemble fingerprint is rejected rather than guessed. + +\section{Stable user API} +\label{sec:api} + +\begin{lstlisting} +from tdscha.Spectroscopy import Spectroscopy + +job = Spectroscopy( + ensemble, + backend="qspace", # "real", "qspace", "atom_fourier" + workdir="optical_spectroscopy", + use_symmetries=True, # the default +) + +job.add_raman_polarized([1, 0, 0], [0, 1, 0], name="raman_xy") +job.add_raman_unpolarized(name="raman_powder") +job.add_ir_polarized([1, 0, 0], name="ir_x") +job.add_ir_unpolarized(name="ir_powder") + +print(job.plan_calculations()) +job.run(500, save_each=10, resume=True) +\end{lstlisting} + +Analysis can occur in the same process or from disk: +\begin{lstlisting} +import numpy as np +from tdscha.Spectroscopy import Spectroscopy + +loaded = Spectroscopy.load("optical_spectroscopy") +omega = np.linspace(1e-5, 0.01, 2000) # Rydberg frequency units +opts = dict(smearing=2e-5, use_terminator=False) + +response = loaded.raman_spectrum( + "raman_powder", omega, kind="response", **opts) +stokes = loaded.raman_spectrum( + "raman_powder", omega, kind="stokes", **opts) +anti = loaded.raman_spectrum( + "raman_powder", omega, kind="anti_stokes", **opts) + +epsilon = loaded.dielectric_function( + "ir_powder", omega, **opts) # epsilon_inf inferred from the dyn/manifest +\end{lstlisting} + +For a custom coherent Raman contraction use +\texttt{add\_raman\_tensor(C,name)}. For an already contracted vertex use +\texttt{add\_raman\_vector} or \texttt{add\_ir\_vector}; an explicit IR +vector requires an explicit electronic projection when forming +$\varepsilon$, because its field direction is no longer inferable. +The complete $3\times3$ electronic tensor is retained: a polarized result +uses $\bm e^{\mathsf T}\varepsilon_\infty\bm e$, while a powder result uses +$\operatorname{Tr}\varepsilon_\infty/3$. Passing +\texttt{epsilon\_infinity=} remains a tensor override. + +\section{Numerical validation} +\label{sec:validation} + +\subsection{Test construction} + +The reproducible driver is +\texttt{spectroscopy\_report/benchmark\_spectroscopy.py}. It loads the +repository's cubic SnTe ensemble at 250~K: two atoms in the primitive cell, a +$2\times2\times2$ supercell, and ten stochastic configurations. To test the +API without claiming a material-specific optical prediction, controlled +vertices are attached: +\begin{itemize} + \item isotropic neutral effective charges $Z_1^*=I$, $Z_2^*=-I$; + \item a synthetic Raman derivative for which trace and the three diagonal + deviatoric channels vanish, while $xy$, $xz$, and $yz$ prepare three + symmetry-equivalent opposite-sublattice displacements. +\end{itemize} +This is a software-verification case, not a predicted Raman spectrum of SnTe. +The same ensemble, operator, \BenchSteps{} recurrence steps, frequency grid, +and smearing are used on both sides. + +The legacy Raman reference invokes the direct low-level API separately for +each of its \RamanNonzero{} nonzero channels and manually combines them with +the normalized weights. Its four zero channels are skipped manually because +the low-level API cannot normalize a zero Krylov vector. The new calculation +receives all \RamanRequested{} components, records four zero contributions, +and schedules \RamanIndependent{} run. The legacy IR reference schedules +$x,y,z$ independently; the new request schedules \IRIndependent{} run. + +\subsection{Spectral agreement} + +\begin{figure}[ht] +\centering +\includegraphics[width=0.84\linewidth]{figures/raman_legacy_vs_symmetry.pdf} +\caption{Controlled cubic unpolarized Raman response. The legacy sum uses +three independent nonzero calculations; the new result reconstructs all +seven requested terms from one calculation and four exact zeros. Curves are +visually coincident; the relative $L^\infty$ discrepancy is \RamanError.} +\label{fig:raman-spectrum} +\end{figure} + +\begin{figure}[ht] +\centering +\includegraphics[width=0.84\linewidth]{figures/ir_legacy_vs_symmetry.pdf} +\caption{Controlled cubic unpolarized IR response. Three legacy Cartesian +calculations are reconstructed from one symmetry representative. The +relative $L^\infty$ discrepancy is \IRError.} +\label{fig:ir-spectrum} +\end{figure} + +The agreement in \cref{fig:raman-spectrum,fig:ir-spectrum} is much tighter +than any physical statistical error in an SSCHA ensemble. More importantly, +the test suite compares a single reduced application of the anharmonic +operator with the complete group average before Lanczos spectral analysis. +The real-space relative error is \RealKernelError{} and the q-space error is +\QKernelError{}. This rules out the possibility that two visibly broadened +spectra merely hide different kernels. + +\subsection{Additional correctness gates} + +The focused suite exercises: +\begin{itemize} + \item exact normalized/raw Raman invariant equivalence; + \item polarized and coherent tensor contractions; + \item real/q-space vertex and perturbation-modulus parity; + \item cubic orbit reduction and anisotropic-mesh filtering; + \item real/q-space full-average versus stabilizer/coset parity; + \item direct q-space full-replica versus coset/inner-group parity on every + point of a cubic $3\times3\times3$ mesh: $\Gamma$ and 26 non-TRI + points, followed by a second application with a generated nonzero + two-phonon input sector; + \item atom-Fourier execution through inherited q-space preparation; + \item directional atom-Fourier LO--TO basis pinning and interpolation-local + effective-charge suppression that retains $Z^*$ for IR preparation; + \item interrupted native restart, strict manifest mismatch rejection, + portable \texttt{.abc} load, and load-only analysis; + \item Bose detailed balance, Stokes minus anti-Stokes identity, and + anisotropic tensorial IR electronic-background handling, including + load-only inference and explicit override; + \item zero symmetry-forbidden Raman channels; and + \item uniform failure of every quarantined two-phonon/configuration- + dependent optical entry point in base and derived classes. +\end{itemize} +At report generation time the focused spectroscopy/Raman/IR/interpolation +suite contains 69 passing +tests. The complete non-heavy repository result is recorded in the project +handoff after the final audit run, so this document does not misstate a count +that changes when obsolete KPM tests are deleted. + +\section{Performance analysis} +\label{sec:performance} + +\begin{table}[ht] +\centering +\caption{End-to-end benchmark, including conservative new-workflow manifest +and checkpoint writes. Times are wall seconds on the report-generation +host, not portable hardware constants.} +\label{tab:workflow-timing} +\begin{tabular}{@{}lrrr@{}} +\toprule +observable & legacy & new & speedup\\ +\midrule +unpolarized Raman & \RamanLegacyTime & \RamanNewTime & \RamanSpeedup$\times$\\ +unpolarized IR & \IRLegacyTime & \IRNewTime & \IRSpeedup$\times$\\ +\bottomrule +\end{tabular} +\end{table} + +\begin{table}[ht] +\centering +\caption{Isolated anharmonic-operator benchmark at +\KernelConfigurations{} configurations. Medians use seven repeated +applications after warm-up.} +\label{tab:kernel-timing} +\begin{tabular}{@{}lcccc@{}} +\toprule +backend & full $|G|$ & representatives & stabilizer $|H|$ & speedup\\ +\midrule +real space & \GroupOrder & \CosetCount & \StabilizerOrder & +\RealKernelSpeedup$\times$\\ +q space & \GroupOrder & \CosetCount & \StabilizerOrder & +\QKernelSpeedup$\times$\\ +\bottomrule +\end{tabular} +\end{table} + +\begin{figure}[ht] +\centering +\includegraphics[width=0.86\linewidth]{figures/timings.pdf} +\caption{Measured workflow and isolated-kernel wall times. The logarithmic +axis is necessary because a complete 256-step workflow and one kernel +application have different natural scales.} +\label{fig:timings} +\end{figure} + +\begin{figure}[ht] +\centering +\includegraphics[width=0.82\linewidth]{figures/kernel_scaling.pdf} +\caption{Measured kernel speedup after deterministically tiling the same ten +configurations. Tiling leaves the normalized ensemble average unchanged and +isolates computational scaling; it does not claim improved statistical +sampling. The dashed line is the asymptotic representative ratio $48/3=16$.} +\label{fig:kernel-scaling} +\end{figure} + +Three effects contribute to the workflow improvement: +\begin{enumerate} + \item \textbf{Across observables:} three equivalent Raman or IR channels are + represented by one run. + \item \textbf{Translations:} a $\Gamma$ optical perturbation separates the + cheap translation projector from the expensive point-group ensemble + replication. The direct legacy real-space path used the full + supercell space-group list. + \item \textbf{Within one run:} \GroupOrder{} point-group operations reduce + to \CosetCount{} expensive representatives, followed by the + \StabilizerOrder-term linear-algebra projector. +\end{enumerate} +These factors are not expected to multiply into an exact wall-time ratio: +initialization, Python/Julia calls, BLAS work, checkpoint I/O, and the +stabilizer projector remain. In particular, the projector is independent of +the number of configurations and performs \StabilizerOrder{} sparse +vector/rank-two transformations after the representative average. With only +ten configurations that fixed work is comparable to evaluating three +representatives, giving the initially puzzling 1.87$\times$ (real) and +1.58$\times$ (q-space) speedups. At 40, 160, and 640 configurations the +measured real-space ratios are 3.86, 8.83, and +\RealKernelSpeedup; q-space gives 3.23, 7.16, and +\QKernelSpeedup. This monotone approach toward $48/3=16$ verifies that the +optimization scales as predicted rather than hiding a replicated-ensemble +calculation in the projector. The isolated-kernel speedups in +\cref{tab:kernel-timing} are therefore the clean measure of the new coset +optimization, while \cref{tab:workflow-timing} measures what a legacy script +and a restartable production workflow actually experienced on this fixture. + +A six-step microbenchmark was also inspected during the audit. There, atomic +checkpoint and manifest writes dominated and made the new workflow slower +than three unsaved legacy calls even though its kernel was faster. This is +expected fixed-cost behavior, not a hidden regression. The reported +\BenchSteps-step case is still small but makes recurrence work visible. + +\section{Code audit and removal decisions} +\label{sec:audit} + +\subsection{Findings corrected in this branch} + +\begin{longtable}{@{}p{0.22\linewidth}p{0.69\linewidth}@{}} +\toprule +area & audit result\\ +\midrule +\endhead +Raman definitions & One canonical component table now generates both legacy +normalizations and the new API. The former channel-accumulation overwrite +cannot recur in a second copy.\\[3pt] +Derived classes & QSpaceLanczos and QSpaceAtomFourier do not duplicate public +Raman/IR preparation. Only representation-specific $\Gamma$ preparation and +operator application remain overridden.\\[3pt] +Finite q & The q-space backend now retains its exact sparse Bloch +representation and detects finite-q little groups with complex characters. +Full/coset parity is checked for all 27 points of an odd cubic mesh and for a +nonzero two-phonon input.\\[3pt] +Zero channels & Exactly zero Raman derivatives are valid symmetry-forbidden +contributions. The planner now records them with no run identifier, and the +evaluator skips them as mathematical zeros.\\[3pt] +Scale independence & Symmetry matching and canonical signs use relative +vector tolerances. Roundoff-level forbidden components are detected relative +to the strongest channel in the same request, so a physically small but +nonzero optical tensor is not quantized away.\\[3pt] +Global deduplication & Orbit analysis is performed over all named requests at +once. Separately added cubic \texttt{ir\_x} and \texttt{ir\_y} requests now +share one run while retaining independent reconstruction weights.\\[3pt] +Group order & Unit-cell crystallographic rotations are matched to the +backend's native ordering by matrix comparison. The code does not assume +spglib and CellConstructor enumerate operations identically.\\[3pt] +Coset side & Right cosets are used consistently with the representation action +and checked through full-average parity tests.\\[3pt] +Rank-two output & The stabilizer is applied to both force and second-derivative +terms, including every q-space momentum-pair block.\\[3pt] +Restart writes & Native status uses a temporary filename that preserves the +\texttt{.npz} extension before atomic replacement; interruption/resume is +tested.\\[3pt] +IR units & The raw Rydberg Green function receives the explicit factor two for +Hartree susceptibility. $4\pi/\Omega$ uses bohr$^3$, and the electronic +dielectric projection uses the full tensor, inferred from the dynamical +matrix or stored manifest unless explicitly overridden.\\[3pt] +Backend switches & \texttt{ignore\_v3}, \texttt{ignore\_v4}, and +\texttt{lo\_to\_split} are public, manifest-recorded options; legacy +\texttt{backend\_options} scripts remain accepted. Atom-Fourier implements +directional LO--TO interpolation rather than raising +\texttt{NotImplementedError}.\\[3pt] +KPM & Backend module, build/install entry, CLI/script loaders, documentation, +and KPM tests were purged. Repository-wide textual audit finds no KPM API +reference. Git history remains the recovery mechanism.\\[3pt] +Two-phonon optics & Public legacy names remain only to fail loudly with +\texttt{NotImplementedError}. The Raman source backup is private, unimported, +and excluded from installation.\\ +\bottomrule +\end{longtable} + +\subsection{Deliberate limitations} + +The following are not silently approximated: +\begin{itemize} + \item configuration-dependent/two-phonon Raman and IR vertices; + \item resonant or antisymmetric Raman tensors; + \item cross-correlations between two distinct external vertices; + \item absolute experimental Raman cross sections; + \item automatic SI absorption or conductivity conversions; and + \item interpreting the controlled benchmark as a material prediction. +\end{itemize} +The stable API covers equilibrium one-phonon polarized/unpolarized Raman and +IR response. Adding any item above should begin from a written observable +definition and symmetry transformation law, then acquire full-average parity +tests before being exposed. + +\section{Reproducibility} + +From the repository root, regenerate numerical data and vector figures with +\begin{lstlisting}[language=bash] +micromamba run -n sscha \ + python spectroscopy_report/benchmark_spectroscopy.py +\end{lstlisting} +The script writes +\texttt{data/benchmark\_summary.json}, +\texttt{data/spectra.csv}, generated \LaTeX{} macros, and the four PDF +figures. Compile this report from its own directory: +\begin{lstlisting}[language=bash] +lualatex -interaction=nonstopmode -halt-on-error \ + tdscha_spectroscopy_report.tex +lualatex -interaction=nonstopmode -halt-on-error \ + tdscha_spectroscopy_report.tex +\end{lstlisting} +This directory is intentionally independent of +\texttt{report/interpolation/}. Neither its source, figures, data, nor PDF is +shared with the interpolation report. + +\section{Conclusions} + +The refactor makes the default behavior unambiguous: users name a polarized +or unpolarized Raman/IR observable, inspect a symmetry plan, and run it through +one restartable driver. Legacy equilibrium calls continue to work, but no +longer define physics independently. The two formerly competing +unpolarized Raman routines are recognized as equivalent normalizations; the +historically broken normalized implementation is repaired through shared +construction and regression tests. + +The group-theory optimization is exact linear algebra. Orbits remove +equivalent requested experiments; stabilizers and right cosets rearrange the +same ensemble average inside each application of $\mathcal L$. Both levels +are verified against unreduced calculations. The cubic test reconstructs +Raman and IR spectra at $10^{-14}$ relative accuracy with one independent run +per observable, and the measured timing improvement is substantial once the +recurrence exceeds fixed checkpoint overhead. + +The resulting code has one optical-definition layer, one workflow layer, and +small backend adapters. Obsolete KPM code is gone and unvalidated two-phonon +code cannot be invoked accidentally. This provides a clean base on which +future optical vertices can be added only after their physics and symmetry +are independently validated. + +\appendix +\section{Algebraic equivalence of Raman normalizations} + +Let a raw component be $\bm v$ with response +$\mathcal R_{\bm v}$. Because a diagonal linear response is bilinear in its +two external vertices, +\begin{equation} + \mathcal R_{s\bm v}=s^2\mathcal R_{\bm v}. +\end{equation} +For the isotropic component $s=1/3$, so +\begin{equation} + 45\mathcal R_{\bm v/3}=45\frac{1}{9}\mathcal R_{\bm v} + =5\mathcal R_{\bm v}. +\end{equation} +For a diagonal deviator $s=1/\sqrt2$, +$7s^2=7/2$. For an off-diagonal component $s=\sqrt3$, +$7s^2=21$. This proves every row of +\cref{tab:legacy-normalizations}; it also explains why saved spectra cannot be +combined safely unless their component convention is known. + +\section{Why a zero component needs no Lanczos run} + +If $\bm v=\bm0$, then the external coupling in +\cref{eq:external-coupling} vanishes. Bilinearity gives +\begin{equation} + G_{0,0}(\omega)=0, + \qquad \mathcal R_0(\omega)=0. +\end{equation} +Attempting Lanczos recursion would require normalizing the zero initial vector +and is undefined. Therefore ``record zero, schedule nothing'' is both the +mathematically correct behavior and the numerically safe behavior. + +\section{Manifest interpretation of symmetry reconstruction} + +For each requested component the manifest stores +\begin{equation} + (\texttt{run\_id},w_j,\chi_j,j). +\end{equation} +A null \texttt{run\_id} means the component is identically zero. Otherwise +all components with the same identifier share one cached diagonal Green +function. Because $G_{-v,-v}=G_{v,v}$, $\chi_j$ is not multiplied into a +diagonal intensity. It remains essential metadata for any future +$G_{v_i,v_j}$ cross response, where the relative sign does matter. + +\begin{thebibliography}{9} + +\bibitem{monacelli2021} +L.~Monacelli and F.~Mauri, +``Time-dependent self-consistent harmonic approximation: Anharmonic nuclear +quantum dynamics and time correlation functions,'' +\emph{Physical Review B} \textbf{103}, 104305 (2021), +\href{https://doi.org/10.1103/PhysRevB.103.104305}{doi:10.1103/PhysRevB.103.104305}. + +\bibitem{siciliano2023} +A.~Siciliano, L.~Monacelli, G.~Caldarelli, and F.~Mauri, +``Wigner Gaussian dynamics: Simulating the anharmonic and quantum ionic +motion,'' \emph{Physical Review B} \textbf{107}, 174307 (2023), +\href{https://doi.org/10.1103/PhysRevB.107.174307}{doi:10.1103/PhysRevB.107.174307}. + +\bibitem{lanczos1950} +C.~Lanczos, ``An iteration method for the solution of the eigenvalue problem +of linear differential and integral operators,'' +\emph{Journal of Research of the National Bureau of Standards} +\textbf{45}, 255--282 (1950). + +\bibitem{placzek1934} +G.~Placzek, ``Rayleigh-Streuung und Raman-Effekt,'' in +\emph{Handbuch der Radiologie}, Vol.~VI, Part~II (Akademische +Verlagsgesellschaft, Leipzig, 1934), pp.~205--374. + +\bibitem{popov2020} +M.~N. Popov, J.~Spitaler, V.~K. Veerapandiyan, E.~Bousquet, J.~Hlinka, and +M.~Deluca, ``Raman spectra of fine-grained materials from first principles,'' +\emph{npj Computational Materials} \textbf{6}, 121 (2020), +\href{https://doi.org/10.1038/s41524-020-00395-3}{doi:10.1038/s41524-020-00395-3}. + +\bibitem{togo2024} +A.~Togo, K.~Shinohara, and I.~Tanaka, +``Spglib: a software library for crystal symmetry search,'' +\emph{Science and Technology of Advanced Materials: Methods} +\textbf{4}, 2384822 (2024), +\href{https://doi.org/10.1080/27660400.2024.2384822}{doi:10.1080/27660400.2024.2384822}. + +\end{thebibliography} + +\end{document} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..57a480ca --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,72 @@ +"""Shared fixtures for the tdscha test suite.""" +from __future__ import print_function + +import shutil +import subprocess +import sys + +import pytest + + +def _probe_mpirun(): + """Return (launcher, n_ranks_seen) for a two-rank ``mpirun`` job. + + ``n_ranks_seen`` is what the *child* processes report as the size of + ``MPI.COMM_WORLD``. It is 2 on a working installation. It is 1 when the + mpi4py in use was built against a different MPI than the launcher on + PATH -- typically a pip wheel bundling its own runtime next to a + system-packaged mpirun. Each process then initialises its own singleton + communicator and ``mpirun -np 2`` silently becomes two unrelated + serial jobs. + """ + launcher = shutil.which("mpirun") or shutil.which("mpiexec") + if launcher is None: + return None, 0 + probe = ("from mpi4py import MPI; " + "print('COMM_WORLD_SIZE', MPI.COMM_WORLD.Get_size())") + try: + proc = subprocess.run( + [launcher, "-np", "2", sys.executable, "-c", probe], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=300) + except (subprocess.TimeoutExpired, OSError): + return launcher, 0 + sizes = [int(line.split()[1]) + for line in proc.stdout.decode().splitlines() + if line.startswith("COMM_WORLD_SIZE")] + if proc.returncode != 0 or not sizes: + return launcher, 0 + return launcher, min(sizes) + + +@pytest.fixture(scope="session") +def multi_rank_mpirun(): + """An ``mpirun`` that really runs several ranks in one MPI job. + + Skips when there is no MPI at all, and fails -- loudly, with the + diagnosis -- when there is an mpirun that does not actually produce a + multi-rank job. That second case is the dangerous one: the distributed + tests would still run, every rank would believe it owned the whole + ensemble, and a test suite that never exercised MPI would report + success. + """ + pytest.importorskip("mpi4py") + launcher, size = _probe_mpirun() + if launcher is None: + pytest.skip("no mpirun/mpiexec found") + if size == 2: + return launcher + if size == 1: + pytest.fail( + "'{} -np 2' starts two INDEPENDENT single-rank jobs: each child " + "reports MPI.COMM_WORLD size 1.\n\n" + "mpi4py was built against a different MPI implementation than " + "this launcher -- usually a pip wheel that bundles its own " + "runtime installed next to a system mpirun. Every distributed " + "test would then pass while testing nothing, so this is a hard " + "failure rather than a skip.\n\n" + "Rebuild mpi4py against the MPI that owns this launcher:\n" + " pip install --no-binary=mpi4py --force-reinstall mpi4py" + .format(launcher)) + pytest.fail( + "'{} -np 2' did not run: the MPI installation is broken.".format( + launcher)) diff --git a/tests/test_atom_fourier/_toy_crystal3d.py b/tests/test_atom_fourier/_toy_crystal3d.py new file mode 100644 index 00000000..8f92b6cd --- /dev/null +++ b/tests/test_atom_fourier/_toy_crystal3d.py @@ -0,0 +1,109 @@ +"""Minimal non-orthogonal 3D crystal for atom-Fourier tests. + +The atom-centred Fourier kernel is built from geometry alone (atom +positions + cell metric) at construction time, so these tests only need a +class instance on a genuinely non-orthogonal cell; the ensemble forces are +irrelevant to the kernel. The model is a two-atom cell on a primitive +bcc-like (non-orthogonal, 109.47-degree) lattice with an isotropic +nearest-neighbour spring network, which gives a stable harmonic dynamical +matrix (three acoustic modes at Gamma, everything else positive). +""" +from __future__ import print_function + +import numpy as np + +import cellconstructor as CC +import cellconstructor.Structure +import cellconstructor.Phonons +import cellconstructor.symmetries +import cellconstructor.Units + +import sscha +import sscha.Ensemble + + +A_LAT = 3.0 +MASS0 = 1200.0 +MASS1 = 2600.0 +# Atom-1 fractional offset: d = tau0 - tau1 = -(0.5, 0.5, 0.0), whose +# aliasing class (0,0,1) is the metric/separable disagreement case on this +# primitive bcc cell (separable manufactures a (0,0,+-1) tie; the metric +# selects a single image). +TAU1 = np.array([0.5, 0.5, 0.0]) +K_SPRING = np.array([0.16, 0.13, 0.10]) # per-Cartesian isotropic-ish + + +def build_unit_structure(): + s = CC.Structure.Structure(2) + s.unit_cell = A_LAT * np.array([[-1.0, 1.0, 1.0], + [1.0, -1.0, 1.0], + [1.0, 1.0, -1.0]]) + s.coords[0] = np.zeros(3) + s.coords[1] = TAU1 @ s.unit_cell + s.atoms = ["A", "B"] + s.masses = {"A": MASS0, "B": MASS1} + s.has_unit_cell = True + return s + + +def _nearest_bonds(super_struct, n_neigh=8): + """Shortest inter-atomic bonds (minimum image) of the supercell.""" + coords = super_struct.coords + cell = super_struct.unit_cell + nat = super_struct.N_atoms + inv = np.linalg.inv(cell) + bonds = [] + for i in range(nat): + d2 = [] + for j in range(nat): + if i == j: + continue + frac = (coords[j] - coords[i]) @ inv + frac -= np.round(frac) + cart = frac @ cell + d2.append((float(cart @ cart), j)) + d2.sort() + for _, j in d2[:n_neigh]: + if i < j: + bonds.append((i, j)) + return sorted(set(bonds)) + + +def build_dyn(supercell=(2, 2, 2)): + unit = build_unit_structure() + super_struct = unit.generate_supercell(supercell) + nat = super_struct.N_atoms + bonds = _nearest_bonds(super_struct) + + fc = np.zeros((3 * nat, 3 * nat)) + for i, j in bonds: + for a in range(3): + k = K_SPRING[a] + fc[3 * i + a, 3 * j + a] += -k + fc[3 * j + a, 3 * i + a] += -k + fc[3 * i + a, 3 * i + a] += k + fc[3 * j + a, 3 * j + a] += k + + q_tot = CC.symmetries.GetQGrid(unit.unit_cell, supercell) + q_tot = [np.array(q) for q in q_tot] + dynq = CC.Phonons.GetDynQFromFCSupercell( + fc, np.array(q_tot), unit, super_struct) + + dyn = CC.Phonons.Phonons(unit, nqirr=len(q_tot)) + dyn.q_tot = q_tot + dyn.dynmats = [dynq[i] for i in range(len(q_tot))] + dyn.q_stars = [[np.array(q)] for q in q_tot] + dyn.AdjustQStar() + return dyn + + +def make_ensemble(dyn, T=250.0, N=40, seed=0): + """A trivial ensemble (harmonic forces): only geometry matters here.""" + np.random.seed(seed) + ens = sscha.Ensemble.Ensemble(dyn, T) + ens.generate(N) + ens.forces = np.zeros_like(ens.forces) + ens.energies = np.zeros(N) + ens.force_computed = np.ones(N, dtype=bool) + ens.init() + return ens diff --git a/tests/test_atom_fourier/test_kernel.py b/tests/test_atom_fourier/test_kernel.py new file mode 100644 index 00000000..563c70f7 --- /dev/null +++ b/tests/test_atom_fourier/test_kernel.py @@ -0,0 +1,241 @@ +"""Geometry and exact-reconstruction tests for atom-Fourier interpolation.""" + +import itertools +import os +import sys + +import numpy as np +import pytest + +os.environ.setdefault("JULIA_NUM_THREADS", "1") +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, _HERE) +sys.path.insert(0, os.path.join(_HERE, "..", "test_interpolation")) + +import _toy_chain as chain +import _toy_crystal3d as crystal3d + +try: + import tdscha.QSpaceAtomFourier as AF + import tdscha.QSpaceInterpolation as interpolation + import tdscha.QSpaceLanczos as QL + _AVAILABLE = QL.__JULIA_EXT__ +except Exception: + _AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not _AVAILABLE, reason="QSpaceLanczos/Julia not available") + + +def _fractional_points(indices, mesh): + return np.asarray(indices, dtype=float) / np.asarray(mesh, dtype=float) + + +def _negated_index(index, indices, mesh, lookup): + key = tuple((-np.asarray(indices[index])) % np.asarray(mesh)) + return lookup[key] + + +def _pair_images(lanczos, atom_a, atom_b): + cell = np.asarray(lanczos.uci_structure.unit_cell, dtype=float) + tau = np.linalg.solve(cell.T, lanczos.uci_structure.coords.T).T + return lanczos._metric_alias_images( + tau[atom_a] - tau[atom_b], + lanczos.coarse_mesh, cell @ cell.T) + + +def _pair_function(points, images, coefficients): + values = np.zeros(len(points), dtype=np.complex128) + for coefficient, entries in zip(coefficients, images.values()): + for lattice_vector, weight in entries: + values += coefficient * weight * np.exp( + 2j * np.pi * ( + points @ np.asarray(lattice_vector, dtype=float))) + return values + + +@pytest.fixture(scope="module") +def nonorthogonal_system(): + dyn = crystal3d.build_dyn((2, 2, 2)) + ensemble = crystal3d.make_ensemble(dyn, N=24, seed=3) + lanczos = AF.QSpaceAtomFourierLanczos( + ensemble, fine_mesh=(4, 4, 4), allow_unstable=True) + return dyn, ensemble, lanczos + + +@pytest.mark.parametrize( + "mesh", [(1, 1, 1), (1, 3, 6), np.array([2, 4, 3])]) +def test_validate_mesh(mesh): + assert np.array_equal( + interpolation.validate_mesh(mesh), np.asarray(mesh, dtype=int)) + + +@pytest.mark.parametrize( + "mesh", [None, (), (1, 2), (1, 2, 3, 4), (0, 2, 2), + (-1, 2, 2), (1.5, 2, 2), (True, 2, 2)]) +def test_validate_mesh_rejects_invalid_values(mesh): + with pytest.raises(ValueError, match="positive integer|three entries"): + interpolation.validate_mesh(mesh) + + +def test_generate_anisotropic_odd_even_mesh(): + dyn = chain.build_dyn(3) + q_points, indices = interpolation.generate_fine_mesh( + dyn.structure, (1, 3, 4)) + assert q_points.shape == (12, 3) + assert indices.shape == (12, 3) + assert tuple(indices[0]) == (0, 0, 0) + lookup = interpolation.build_q_index_lookup( + q_points, dyn.structure, (1, 3, 4)) + assert len(lookup) == 12 + for iq, q in enumerate(q_points): + assert lookup[ + interpolation.mesh_key( + q, dyn.structure, (1, 3, 4))] == iq + + +def test_mesh_helpers_reject_invalid_q_arrays(): + dyn = chain.build_dyn(3) + with pytest.raises(ValueError, match="shape"): + interpolation.mesh_key( + np.zeros(2), dyn.structure, (1, 1, 3)) + with pytest.raises(ValueError, match="shape"): + interpolation.build_q_index_lookup( + np.zeros(3), dyn.structure, (1, 1, 3)) + with pytest.raises(ValueError, match="at least one"): + interpolation.interpolate_dyn_fine( + dyn, np.empty((0, 3))) + + +def test_constructor_rejects_noncommensurate_fine_mesh(): + dyn = chain.build_dyn(3) + ensemble = chain.make_ensemble( + dyn, 300.0, 12, seed=2, g3=0.2, g4=0.3) + with pytest.raises(ValueError, match="integer multiple"): + AF.QSpaceAtomFourierLanczos( + ensemble, fine_mesh=(1, 1, 4)) + + with pytest.raises(ValueError, match="w_min_guard"): + AF.QSpaceAtomFourierLanczos( + ensemble, fine_mesh=(1, 1, 6), w_min_guard=0) + + +def test_metric_images_match_brute_force(): + cell = 3.0 * np.array( + [[-1, 1, 1], [1, -1, 1], [1, 1, -1]], dtype=float) + metric = cell @ cell.T + mesh = np.array([2, 2, 2]) + displacement = np.array([0.5, 0.5, 0.0]) + got = AF.QSpaceAtomFourierLanczos._metric_alias_images( + displacement, mesh, metric) + + for key in itertools.product(*(range(n) for n in mesh)): + candidates = [] + for shift in itertools.product(range(-3, 4), repeat=3): + vector = np.array(key) + mesh * np.array(shift) + delta = vector - displacement + candidates.append( + (float(delta @ metric @ delta), tuple(vector))) + minimum = min(item[0] for item in candidates) + expected = { + vector for distance, vector in candidates + if abs(distance - minimum) < 1e-7} + assert {vector for vector, _ in got[key]} == expected + assert sum(weight for _, weight in got[key]) == pytest.approx(1.0) + + assert max(len(images) for images in got.values()) == 4 + + +def test_metric_search_expands_for_highly_skewed_cell(): + """Minimum images outside the old fixed ±2 search are still found.""" + cell = np.array( + [[1.0, 0.0, 0.0], [10.0, 0.1, 0.0], [0.0, 0.0, 1.0]]) + got = AF.QSpaceAtomFourierLanczos._metric_alias_images( + np.array([0.0, 0.5, 0.0]), (1, 1, 1), cell @ cell.T) + images = {vector for vector, _ in got[(0, 0, 0)]} + assert images == {(5, 0, 0), (-5, 1, 0)} + assert sum(weight for _, weight in got[(0, 0, 0)]) == pytest.approx(1.0) + + +def test_kernel_is_cardinal_and_mirror_symmetric(nonorthogonal_system): + _, _, lanczos = nonorthogonal_system + kernel = lanczos._atom_fourier_kernel + n_atoms = lanczos.uci_structure.N_atoms + + for coarse_iq, fine_iq in enumerate(lanczos._fine_of_coarse): + for coarse_jq in range(lanczos.cn_q): + expected = 1.0 if coarse_iq == coarse_jq else 0.0 + assert np.allclose( + kernel[fine_iq, coarse_jq], + np.full((n_atoms, n_atoms), expected), atol=1e-12) + + for fine_iq in range(lanczos.n_q): + minus_fine = _negated_index( + fine_iq, lanczos._fine_idx, lanczos.fine_mesh, + lanczos._q_lookup) + for coarse_iq in range(lanczos.cn_q): + minus_coarse = _negated_index( + coarse_iq, lanczos._coarse_idx, lanczos.coarse_mesh, + lanczos._coarse_lookup) + assert np.allclose( + kernel[fine_iq, coarse_iq], + kernel[minus_fine, minus_coarse].T, atol=1e-12) + + +def test_d3_pair_interpolation_is_exact(nonorthogonal_system): + """Every representable third-order pair harmonic is reconstructed.""" + _, _, lanczos = nonorthogonal_system + coarse_q = _fractional_points( + lanczos._coarse_idx, lanczos.coarse_mesh) + fine_q = _fractional_points( + lanczos._fine_idx, lanczos.fine_mesh) + rng = np.random.default_rng(8) + + for atom_a in range(lanczos.uci_structure.N_atoms): + for atom_b in range(lanczos.uci_structure.N_atoms): + images = _pair_images(lanczos, atom_a, atom_b) + coefficients = ( + rng.normal(size=len(images)) + + 1j * rng.normal(size=len(images))) + coarse = _pair_function(coarse_q, images, coefficients) + exact = _pair_function(fine_q, images, coefficients) + reconstructed = ( + lanczos._atom_fourier_kernel[:, :, atom_a, atom_b] + @ coarse) + assert np.allclose(reconstructed, exact, atol=2e-12) + + +def test_d4_pair_product_interpolation_is_exact(nonorthogonal_system): + """The d4 continuation is the tensor product of two exact pair maps.""" + _, _, lanczos = nonorthogonal_system + coarse_q = _fractional_points( + lanczos._coarse_idx, lanczos.coarse_mesh) + fine_q = _fractional_points( + lanczos._fine_idx, lanczos.fine_mesh) + rng = np.random.default_rng(9) + pairs = [(0, 0), (0, 1), (1, 0), (1, 1)] + + for pair_index, (atom_a, atom_b) in enumerate(pairs): + images_ab = _pair_images(lanczos, atom_a, atom_b) + coeff_ab = ( + rng.normal(size=len(images_ab)) + + 1j * rng.normal(size=len(images_ab))) + coarse_ab = _pair_function(coarse_q, images_ab, coeff_ab) + fine_ab = _pair_function(fine_q, images_ab, coeff_ab) + kernel_ab = lanczos._atom_fourier_kernel[ + :, :, atom_a, atom_b] + + atom_c, atom_d = pairs[(pair_index + 1) % len(pairs)] + images_cd = _pair_images(lanczos, atom_c, atom_d) + coeff_cd = ( + rng.normal(size=len(images_cd)) + + 1j * rng.normal(size=len(images_cd))) + coarse_cd = _pair_function(coarse_q, images_cd, coeff_cd) + fine_cd = _pair_function(fine_q, images_cd, coeff_cd) + kernel_cd = lanczos._atom_fourier_kernel[ + :, :, atom_c, atom_d] + + coarse_d4 = np.outer(coarse_ab, coarse_cd) + reconstructed = kernel_ab @ coarse_d4 @ kernel_cd.T + assert np.allclose( + reconstructed, np.outer(fine_ab, fine_cd), atol=5e-12) diff --git a/tests/test_atom_fourier/test_operator.py b/tests/test_atom_fourier/test_operator.py new file mode 100644 index 00000000..04aee73f --- /dev/null +++ b/tests/test_atom_fourier/test_operator.py @@ -0,0 +1,198 @@ +"""d3/d4 operator tests for the production atom-Fourier path.""" + +import os +import sys + +import numpy as np +import pytest + +os.environ.setdefault("JULIA_NUM_THREADS", "1") +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(_HERE, "..", "test_interpolation")) + +import _toy_chain as chain + +try: + import tdscha.QSpaceAtomFourier as AF + import tdscha.QSpaceLanczos as QL + _AVAILABLE = QL.__JULIA_EXT__ +except Exception: + _AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not _AVAILABLE, reason="QSpaceLanczos/Julia not available") + +TEMPERATURE = 300.0 +COARSE = 3 +FINE = 6 + + +@pytest.fixture(scope="module") +def system(): + dyn = chain.build_dyn(COARSE) + ensemble = chain.make_ensemble( + dyn, TEMPERATURE, 120, seed=7, g3=0.2, g4=0.3) + return dyn, ensemble + + +@pytest.fixture(scope="module") +def interpolated(system): + _, ensemble = system + return AF.QSpaceAtomFourierLanczos( + ensemble, fine_mesh=(1, 1, FINE)) + + +def _fine_index(lanczos, z_index): + matches = np.where( + (lanczos._fine_idx == [0, 0, z_index]).all(axis=1))[0] + return int(matches[0]) + + +def _random_vector(rng, size): + return rng.normal(size=size) + 1j * rng.normal(size=size) + + +def _run(lanczos, iq, band, steps=5): + lanczos.init(use_symmetries=True) + lanczos.prepare_mode_q(iq, band) + lanczos.run_FT(steps, verbose=False) + return np.asarray(lanczos.a_coeffs), np.asarray(lanczos.b_coeffs) + + +def test_identity_mesh_reproduces_parent_d3_and_d4(system): + """At equal meshes the full d3+d4 operator equals QSpaceLanczos.""" + _, ensemble = system + parent = QL.QSpaceLanczos(ensemble, lo_to_split=None) + atom_fourier = AF.QSpaceAtomFourierLanczos( + ensemble, fine_mesh=(1, 1, COARSE)) + + parent_a, parent_b = _run(parent, iq=1, band=2) + fine_iq = atom_fourier.find_fine_q(parent.q_points[1]) + interp_a, interp_b = _run(atom_fourier, iq=fine_iq, band=2) + + assert len(parent_a) > 2 + assert np.allclose(interp_a, parent_a, rtol=1e-10, atol=1e-14) + assert np.allclose(interp_b, parent_b, rtol=1e-10, atol=1e-14) + + no_d4 = AF.QSpaceAtomFourierLanczos( + ensemble, fine_mesh=(1, 1, COARSE)) + no_d4.ignore_v4 = True + no_d4_a, _ = _run(no_d4, iq=fine_iq, band=2) + assert not np.allclose(no_d4_a, interp_a, rtol=1e-7, atol=1e-14) + + +def test_atom_fourier_accepts_directional_lo_to_and_pins_gamma_basis(): + dyn = chain.build_dyn(COARSE) + charges = np.zeros((dyn.structure.N_atoms, 3, 3)) + charges[0] = 1.5 * np.eye(3) + charges[1] = -1.5 * np.eye(3) + dyn.effective_charges = charges + dyn.dielectric_tensor = np.diag([2.0, 3.0, 5.0]) + ensemble = chain.make_ensemble( + dyn, TEMPERATURE, 12, seed=17, g3=0.2, g4=0.3) + direction = np.array([1.0, 2.0, 3.0]) + + parent = QL.QSpaceLanczos(ensemble, lo_to_split=direction) + interpolated = AF.QSpaceAtomFourierLanczos( + ensemble, fine_mesh=(1, 1, 2 * COARSE), + lo_to_split=direction, allow_unstable=True) + gamma_fine = interpolated._fine_of_coarse[0] + np.testing.assert_allclose( + interpolated.w_q[:, gamma_fine], parent.w_q[:, 0], atol=1e-14) + np.testing.assert_allclose( + interpolated.pols_q[:, :, gamma_fine], parent.pols_q[:, :, 0], + atol=1e-14) + + short_range_parent = QL.QSpaceLanczos(ensemble, lo_to_split=None) + short_range = AF.QSpaceAtomFourierLanczos( + ensemble, fine_mesh=(1, 1, 2 * COARSE), + lo_to_split=direction, ignore_effective_charges=True, + allow_unstable=True) + gamma_short = short_range._fine_of_coarse[0] + np.testing.assert_allclose( + short_range.w_q[:, gamma_short], short_range_parent.w_q[:, 0], + atol=1e-14) + np.testing.assert_allclose( + short_range.pols_q[:, :, gamma_short], + short_range_parent.pols_q[:, :, 0], atol=1e-14) + + # Suppression is interpolation-local: the original Z* remains available + # and can still prepare an IR perturbation. + np.testing.assert_allclose(short_range.dyn.effective_charges, charges) + short_range.init(use_symmetries=False) + short_range.prepare_ir(pol_vec=[1, 0, 0]) + assert short_range.perturbation_modulus > 0 + + +def test_commensurate_frequencies_and_normalization( + system, interpolated): + _, ensemble = system + parent = QL.QSpaceLanczos(ensemble, lo_to_split=None) + + for coarse_iq, fine_iq in enumerate(interpolated._fine_of_coarse): + assert np.allclose( + interpolated.w_q[:, fine_iq], parent.w_q[:, coarse_iq], + atol=1e-14) + assert np.allclose( + interpolated.pols_q[:, :, fine_iq], + parent.pols_q[:, :, coarse_iq], atol=1e-14) + + ratio = interpolated.cn_q / float(interpolated.n_q) + assert interpolated.qspace_scale3 == pytest.approx(np.sqrt(ratio)) + assert interpolated.qspace_scale4 == pytest.approx(ratio) + + +def test_off_coarse_perturbation_is_rejected(interpolated): + with pytest.raises(ValueError, match="coarse mesh"): + interpolated.build_q_pair_map(_fine_index(interpolated, 1)) + with pytest.raises(ValueError, match="iq_pert"): + interpolated.build_q_pair_map(interpolated.n_q) + + +@pytest.mark.parametrize("z_index", [0, 2]) +def test_d3_operator_is_hermitian(interpolated, z_index): + """With d4 disabled, d3 couples the one/two-phonon sectors adjointly.""" + interpolated.ignore_v4 = True + interpolated.init(use_symmetries=True) + interpolated.build_q_pair_map(_fine_index(interpolated, z_index)) + interpolated.reset_q() + + rng = np.random.default_rng(30 + z_index) + size = interpolated.get_psi_size() + mask = interpolated.mask_dot_wigner() + left = _random_vector(rng, size) + right = _random_vector(rng, size) + lhs = np.vdot( + left, interpolated.apply_full_L(right.copy()) * mask) + rhs = np.vdot( + right, interpolated.apply_full_L(left.copy()) * mask) + assert lhs == pytest.approx(np.conj(rhs), rel=1e-9, abs=1e-12) + + +@pytest.mark.parametrize("z_index", [0, 2]) +def test_d4_two_phonon_operator_is_hermitian(interpolated, z_index): + """Projecting out the one-phonon sector isolates the d4 block.""" + interpolated.ignore_v4 = False + interpolated.init(use_symmetries=True) + interpolated.build_q_pair_map(_fine_index(interpolated, z_index)) + interpolated.reset_q() + + rng = np.random.default_rng(40 + z_index) + size = interpolated.get_psi_size() + n_bands = interpolated.n_bands + mask = interpolated.mask_dot_wigner() + + def two_phonon(vector): + vector = vector.copy() + vector[:n_bands] = 0.0 + return vector + + def apply_d4(vector): + return two_phonon( + interpolated.apply_full_L(two_phonon(vector))) + + left = _random_vector(rng, size) + right = _random_vector(rng, size) + lhs = np.vdot(left, apply_d4(right) * mask) + rhs = np.vdot(right, apply_d4(left) * mask) + assert lhs == pytest.approx(np.conj(rhs), rel=1e-9, abs=1e-12) diff --git a/tests/test_interpolation/.gitignore b/tests/test_interpolation/.gitignore new file mode 100644 index 00000000..c18dd8d8 --- /dev/null +++ b/tests/test_interpolation/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/tests/test_interpolation/_toy_chain.py b/tests/test_interpolation/_toy_chain.py new file mode 100644 index 00000000..3644e0b6 --- /dev/null +++ b/tests/test_interpolation/_toy_chain.py @@ -0,0 +1,215 @@ +""" +Anharmonic diatomic-chain toy model, parametrized by the supercell length. + +The model is defined by its TOPOLOGY (not by distances), so that every +supercell length L represents exactly the same infinite chain: + + ... A(n) --K1-- B(n) --K2-- A(n+1) ... (chain along z) + +Each bond carries, per Cartesian component, the energy + + E(s) = 1/2 k s^2 + g3/3 s^3 + g4/4 s^4, s = u_first - u_second + +with the bond ends ordered consistently (A(n), B(n)) and (B(n), A(n+1)), +so the cubic term has the same sign in every cell and supercell. + +Because the harmonic dynamical matrix is built from the same springs, the +force residual (forces - sscha_forces) is EXACTLY the anharmonic bond force: +no harmonic sampling noise enters the D3/D4 estimators. + +The unit cell is triclinic with generic positions (P1: identity is the only +point-group operation), so no symmetry can mask convention errors. +""" +from __future__ import print_function +import numpy as np + +import cellconstructor as CC +import cellconstructor.Structure +import cellconstructor.Phonons +import cellconstructor.symmetries +import cellconstructor.Units + +import sscha, sscha.Ensemble + +# anisotropic spring constants (Ry/Bohr^2) per Cartesian component +K1 = np.array([0.18, 0.225, 0.15]) # A(n) - B(n) +K2 = np.array([0.10, 0.07, 0.16]) # B(n) - A(n+1) +MASS_A = 1000.0 # Ry atomic units +MASS_B = 2500.0 + + +def build_unit_structure(): + s = CC.Structure.Structure(2) + s.unit_cell = np.array([[5.0, 0.0, 0.0], + [0.7, 6.0, 0.0], + [0.9, 0.6, 7.0]]) + s.coords[0] = np.array([0.10, 0.15, 0.00]) + s.coords[1] = np.array([0.37, 0.21, 2.60]) + s.atoms = ["A", "B"] + s.masses = {"A": MASS_A, "B": MASS_B} + s.has_unit_cell = True + return s + + +def get_bonds(super_struct, unit, L): + """Chain bonds by topology: (index_A(n), index_B(n), K1) and + (index_B(n), index_A(n+1 mod L), K2). + + The supercell atom of unit-cell atom a in cell n is identified through + itau and the integer cell index along z. + """ + itau = super_struct.get_itau(unit) - 1 + nat_sc = super_struct.N_atoms + # integer cell index of each supercell atom (fractional coords along z + # of the cell origin) + r_lat = super_struct.coords - unit.coords[itau] + frac = np.linalg.solve(unit.unit_cell.T, r_lat.T).T # cell indices (float) + n_z = np.round(frac[:, 2]).astype(int) % L + + idx_A = {} + idx_B = {} + for k in range(nat_sc): + if itau[k] == 0: + idx_A[n_z[k]] = k + else: + idx_B[n_z[k]] = k + + bonds = [] + for n in range(L): + bonds.append((idx_A[n], idx_B[n], K1)) + bonds.append((idx_B[n], idx_A[(n + 1) % L], K2)) + return bonds + + +def get_triplets(super_struct, unit, L): + """A-atom triplets (A(n), A(n+1), A(n+2)) for the three-body term.""" + itau = super_struct.get_itau(unit) - 1 + r_lat = super_struct.coords - unit.coords[itau] + frac = np.linalg.solve(unit.unit_cell.T, r_lat.T).T + n_z = np.round(frac[:, 2]).astype(int) % L + idx_A = {} + for k in range(super_struct.N_atoms): + if itau[k] == 0: + idx_A[n_z[k]] = k + return [(idx_A[n], idx_A[(n + 1) % L], idx_A[(n + 2) % L]) + for n in range(L)] + + +def build_dyn(L): + """Harmonic spring-chain dyn on the (1, 1, L) supercell.""" + unit = build_unit_structure() + supercell = (1, 1, L) + super_struct = unit.generate_supercell(supercell) + bonds = get_bonds(super_struct, unit, L) + + nat = super_struct.N_atoms + fc = np.zeros((3 * nat, 3 * nat)) + for i, j, k in bonds: + for a in range(3): + fc[3 * i + a, 3 * j + a] += -k[a] + fc[3 * j + a, 3 * i + a] += -k[a] + fc[3 * i + a, 3 * i + a] += k[a] + fc[3 * j + a, 3 * j + a] += k[a] + + q_tot = CC.symmetries.GetQGrid(unit.unit_cell, supercell) + q_tot = [np.array(q) for q in q_tot] + dynq = CC.Phonons.GetDynQFromFCSupercell( + fc, np.array(q_tot), unit, super_struct) + + dyn = CC.Phonons.Phonons(unit, nqirr=len(q_tot)) + dyn.q_tot = q_tot + dyn.dynmats = [dynq[i] for i in range(len(q_tot))] + dyn.q_stars = [[np.array(q)] for q in q_tot] + dyn.AdjustQStar() + return dyn + + +def make_ensemble(dyn, T, N, seed=0, g3=0.6, g4=0.0, g3b=0.0): + """SSCHA ensemble with deterministic bond anharmonicity. + + forces = harmonic bond force + anharmonic bond force. The harmonic part + coincides with the SSCHA force by construction, so the residual is the + pure anharmonic force. + + With g4=0 (default) the model is purely cubic: = D3 = 0, + so the SSCHA stationarity assumed by the vertex rescaling holds exactly + in expectation. + + g3b adds a THREE-BODY cubic term per cell and Cartesian component, + V_3b = g3b * s1^2 * s2, + s1 = uA(n+1) - uA(n), s2 = uA(n+2) - uA(n+1), + whose Phi3 entries span three distinct cells. + """ + np.random.seed(seed) + ens = sscha.Ensemble.Ensemble(dyn, T) + ens.generate(N) + + unit = dyn.structure + L = dyn.GetSupercell()[2] + super_struct = unit.generate_supercell(dyn.GetSupercell()) + bonds = get_bonds(super_struct, unit, L) + + u_bohr = ens.u_disps.copy() * CC.Units.A_TO_BOHR # Bohr + nat = super_struct.N_atoms + f_harm = np.zeros_like(u_bohr) # Ry/Bohr + f_anh = np.zeros_like(u_bohr) + + for (i, j, k) in bonds: + for a in range(3): + ia, ja = 3 * i + a, 3 * j + a + s = u_bohr[:, ia] - u_bohr[:, ja] + fh = k[a] * s + fa = g3 * s ** 2 + g4 * s ** 3 + f_harm[:, ia] += -fh + f_harm[:, ja] += +fh + f_anh[:, ia] += -fa + f_anh[:, ja] += +fa + + if g3b != 0.0: + for (i, j, k) in get_triplets(super_struct, unit, L): + for a in range(3): + ia, ja, ka = 3 * i + a, 3 * j + a, 3 * k + a + s1 = u_bohr[:, ja] - u_bohr[:, ia] + s2 = u_bohr[:, ka] - u_bohr[:, ja] + dv1 = 2.0 * g3b * s1 * s2 # dV/ds1 + dv2 = g3b * s1 ** 2 # dV/ds2 + f_anh[:, ia] += dv1 + f_anh[:, ja] += dv2 - dv1 + f_anh[:, ka] += -dv2 + + # Remove the ensemble-average anharmonic force (mimic SSCHA stationarity) + f_anh -= np.mean(f_anh, axis=0, keepdims=True) + + f_tot = (f_harm + f_anh) * CC.Units.A_TO_BOHR # Ry/Angstrom + ens.forces = f_tot.reshape(N, nat, 3) + ens.energies = np.zeros(N) + ens.force_computed = np.ones(N, dtype=bool) + + # CRITICAL: refresh the q-space arrays. ens.generate() precomputes + # u_disps_qspace and leaves forces_qspace = 0; assigning ens.forces + # afterwards does NOT update them, and QSpaceLanczos skips ens.init() + # when u_disps_qspace is already present. + ens.init() + return ens + + +def lanczos_effective_freq(lanc): + """Static effective frequency (Ry) of the perturbed mode. + + The Wigner L has eigenvalues -w^2. The static response of the initial + perturbation is g = [M^-1]_00 with M the Lanczos tridiagonal matrix; + the renormalized frequency is w_eff = sqrt(-1/g). This is the standard + static-Hessian-from-Lanczos observable: it includes all decay channels + with their proper spectral weight. + """ + a = np.array(lanc.a_coeffs) + b = np.array(lanc.b_coeffs) + n = len(a) + M = np.diag(a) + if n > 1: + M += np.diag(b[:n - 1], 1) + np.diag(b[:n - 1], -1) + e1 = np.zeros(n) + e1[0] = 1.0 + g = np.linalg.solve(M, e1)[0] + assert g < 0, "Static response is not negative definite (g = {})".format(g) + return np.sqrt(-1.0 / g) diff --git a/tests/test_interpolation/test_fine_harmonic.py b/tests/test_interpolation/test_fine_harmonic.py new file mode 100644 index 00000000..e9ca8e4c --- /dev/null +++ b/tests/test_interpolation/test_fine_harmonic.py @@ -0,0 +1,104 @@ +"""``FineHarmonicInterpolation``: the value the distributed loaders inject. + +The interpolation is computed by every MPI rank and then handed to a +constructor that would otherwise recompute it. Two things must hold: it must +be the interpolation that constructor would have produced, and it must be +recognisably *not* it when the inputs differ. Nothing else in the pipeline +would notice a mismatch -- the ensemble would simply be contracted in a mode +basis that does not belong to it. +""" + +import os + +import numpy as np +import pytest + +import cellconstructor as CC +import cellconstructor.Phonons + +import tdscha.QSpaceInterpolation as QI + +HERE = os.path.dirname(os.path.abspath(__file__)) +DATA = os.path.abspath(os.path.join(HERE, "..", "test_julia", "data")) +MESH = (2, 2, 4) + +if not os.path.isdir(DATA): + pytest.skip("q-space test dynamical matrix not available", + allow_module_level=True) + + +@pytest.fixture(scope="module") +def dyn(): + return CC.Phonons.Phonons(os.path.join(DATA, "dyn_gen_pop1_"), 3) + + +@pytest.fixture(scope="module") +def harmonic(dyn): + return QI.build_fine_harmonic(dyn, MESH) + + +def test_it_reproduces_the_direct_interpolation(dyn, harmonic): + q_points, indices = QI.generate_fine_mesh(dyn.structure, MESH) + frequencies, polarizations = QI.interpolate_dyn_fine( + dyn, q_points, use_asr=True) + + np.testing.assert_array_equal(harmonic.q_points, q_points) + np.testing.assert_array_equal(harmonic.indices, indices) + np.testing.assert_array_equal(harmonic.frequencies, frequencies) + np.testing.assert_array_equal(harmonic.polarizations, polarizations) + assert harmonic.n_q == int(np.prod(MESH)) + assert harmonic.n_bands == 3 * dyn.structure.N_atoms + + +def test_it_accepts_the_matrix_it_came_from(dyn, harmonic): + harmonic.validate_for(dyn, MESH) + harmonic.validate_for(dyn.Copy(), MESH) + + +def test_a_copy_through_an_ensemble_is_still_the_same_matrix(dyn, harmonic): + """The loader interpolates ``final_dyn``; the constructor sees a copy. + + ``Ensemble`` hands back a ``current_dyn`` whose Gamma block has been + demoted from complex to real without any value changing. If the + identity check noticed that, every distributed interpolated run would be + refused; if it noticed nothing at all, it would be worthless. + """ + ensemble_module = pytest.importorskip("sscha.Ensemble") + ensemble = ensemble_module.Ensemble(dyn, 250.0) + ensemble.load_bin(DATA, 1) + harmonic.validate_for(ensemble.current_dyn, MESH) + + ensemble.update_weights(dyn, 250.0) + harmonic.validate_for(ensemble.current_dyn.Copy(), MESH) + + +def test_it_refuses_a_different_mesh(dyn, harmonic): + with pytest.raises(ValueError, match="built on mesh"): + harmonic.validate_for(dyn, (2, 2, 2)) + + +def test_it_refuses_different_interpolation_settings(dyn, harmonic): + with pytest.raises(ValueError, match="not built from"): + harmonic.validate_for(dyn, MESH, use_asr=False) + with pytest.raises(ValueError, match="not built from"): + harmonic.validate_for(dyn, MESH, lo_to_split=[1.0, 0.0, 0.0]) + with pytest.raises(ValueError, match="not built from"): + harmonic.validate_for(dyn, MESH, ignore_effective_charges=True) + + +def test_it_refuses_a_different_dynamical_matrix(dyn, harmonic): + other = dyn.Copy() + other.dynmats[0] = np.asarray(other.dynmats[0]) * 1.01 + with pytest.raises(ValueError, match="not built from"): + harmonic.validate_for(other, MESH) + + +def test_the_constructor_refuses_a_foreign_interpolation(dyn, harmonic): + """The injection point itself must reject it, not just the value type.""" + pytest.importorskip("sscha.Ensemble") + import tdscha.QSpaceAtomFourier as AF + + with pytest.raises(TypeError, match="FineHarmonicInterpolation"): + AF.QSpaceAtomFourierLanczos( + ensemble=object(), fine_mesh=MESH, + harmonic_interpolation="not an interpolation") diff --git a/tests/test_interpolation/test_ignore_effective_charges.py b/tests/test_interpolation/test_ignore_effective_charges.py new file mode 100644 index 00000000..2067d76a --- /dev/null +++ b/tests/test_interpolation/test_ignore_effective_charges.py @@ -0,0 +1,201 @@ +"""Regression tests for ``ignore_effective_charges``. + +Background (see report/interpolation sec:cssni3-longrange and benchmark.md +section 19): ``ForceTensor.Tensor2.SetupFromPhonons`` subtracts the Ewald +dipole-dipole term from every commensurate block when the dynamical matrix +carries Born effective charges, centers/ASR-projects only the remainder, and +``Interpolate`` adds the Ewald term back. That cycle is exactly the identity +on the coarse mesh, so no commensurate test can detect it; it acts only +between coarse points. For an ensemble generated by a short-range potential +(a machine-learning force field, say) the stored Z*/eps are inherited +metadata and the cycle injects a dipole tail that is not in the data. + +The toy chain is the sharpest possible probe: its force constants are +strictly range-1, so the plain centered Fourier continuation is EXACT at +every q, on-grid or off. Any off-grid deviation is therefore entirely +attributable to the long-range cycle. +""" +import os +import sys + +os.environ.setdefault("JULIA_NUM_THREADS", "1") +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import numpy as np +import pytest + +import cellconstructor as CC +import cellconstructor.Methods + +import _toy_chain as TC + +try: + import tdscha.QSpaceInterpolation as QI + _OK = True +except Exception: + _OK = False + +pytestmark = pytest.mark.skipif( + not _OK, reason="tdscha.QSpaceInterpolation not importable") + +L = 4 +Z_MAG = 2.0 + + +def _dyn_with_charges(): + """Range-1 spring chain carrying (fictitious, neutral) Z* and eps.""" + dyn = TC.build_dyn(L) + nat = dyn.structure.N_atoms + zeu = np.zeros((nat, 3, 3)) + zeu[0] = +Z_MAG * np.eye(3) + zeu[1] = -Z_MAG * np.eye(3) + assert np.abs(zeu.sum(axis=0)).max() < 1e-12, "Z* must be neutral" + dyn.effective_charges = zeu + dyn.dielectric_tensor = np.diag([2.5, 2.5, 3.5]) + return dyn + + +def _fine_q(dyn, fine): + return QI.generate_fine_mesh(dyn.structure, (1, 1, fine))[0] + + +def test_ignore_flag_does_not_mutate_the_caller_dyn(): + """Scope guarantee: IR/Raman consumers downstream must still see Z*. + + The flag hides the charges from the Tensor2 built inside + interpolate_dyn_fine only; it must never strip them from the caller's + dynamical matrix. + """ + dyn = _dyn_with_charges() + zeu_before = dyn.effective_charges.copy() + eps_before = dyn.dielectric_tensor.copy() + + QI.interpolate_dyn_fine(dyn, _fine_q(dyn, 2 * L), + ignore_effective_charges=True) + + assert dyn.effective_charges is not None + assert dyn.dielectric_tensor is not None + assert np.allclose(dyn.effective_charges, zeu_before) + assert np.allclose(dyn.dielectric_tensor, eps_before) + + +def test_default_is_to_use_the_charges(): + """Opt-in, not opt-out: the default must remain the cellconstructor + behaviour, since for a genuinely polar potential it is the correct one.""" + dyn = _dyn_with_charges() + q_fine = _fine_q(dyn, 2 * L) + + w_default, _ = QI.interpolate_dyn_fine(dyn, q_fine) + w_used, _ = QI.interpolate_dyn_fine(dyn, q_fine, + ignore_effective_charges=False) + assert np.allclose(w_default, w_used) + + +def test_explicit_lo_to_direction_controls_tensorial_gamma_limit(): + dyn = _dyn_with_charges() + gamma = np.zeros((1, 3)) + w_x, _ = QI.interpolate_dyn_fine( + dyn, gamma, use_asr=False, reuse_commensurate=False, + lo_to_split=[1, 0, 0]) + w_z, _ = QI.interpolate_dyn_fine( + dyn, gamma, use_asr=False, reuse_commensurate=False, + lo_to_split=[0, 0, 1]) + + # epsilon_inf is anisotropic, hence the nonanalytic correction depends on + # qhat^T epsilon_inf qhat and the two directional limits must differ. + assert np.max(np.abs(np.sort(w_x[:, 0]) - np.sort(w_z[:, 0]))) > 1e-8 + + w_ignored_with_direction, _ = QI.interpolate_dyn_fine( + dyn, gamma, use_asr=False, reuse_commensurate=False, + ignore_effective_charges=True, lo_to_split=[1, 0, 0]) + w_ignored, _ = QI.interpolate_dyn_fine( + dyn, gamma, use_asr=False, reuse_commensurate=False, + ignore_effective_charges=True, lo_to_split=None) + np.testing.assert_allclose(w_ignored_with_direction, w_ignored) + assert dyn.effective_charges is not None + assert dyn.dielectric_tensor is not None + + +def test_commensurate_limit_is_blind_to_the_flag(): + """The subtract/re-add cycle cancels identically on the coarse mesh. + + This is exactly why the bug survived every regression test in the suite, + and it is worth pinning: a commensurate check can never discriminate the + two conventions. + """ + dyn = _dyn_with_charges() + q_coarse = _fine_q(dyn, L) + + w_used, _ = QI.interpolate_dyn_fine(dyn, q_coarse, use_asr=False, + reuse_commensurate=False, + ignore_effective_charges=False) + w_ignored, _ = QI.interpolate_dyn_fine(dyn, q_coarse, use_asr=False, + reuse_commensurate=False, + ignore_effective_charges=True) + assert np.allclose(w_used, w_ignored, atol=1e-9), ( + "on the coarse mesh the two conventions must agree to machine " + "precision") + + +def test_ignoring_charges_is_exact_for_the_range1_model(): + """The chain's force constants are strictly range-1, so the plain + Fourier continuation reproduces the exact dynamical matrix at ANY q.""" + dyn = _dyn_with_charges() + fine = 2 * L + q_fine = _fine_q(dyn, fine) + + w_ignored, _ = QI.interpolate_dyn_fine(dyn, q_fine, use_asr=False, + reuse_commensurate=False, + ignore_effective_charges=True) + + # exact reference: the same chain built directly on the fine supercell + dyn_fine = TC.build_dyn(fine) + bg = dyn.structure.get_reciprocal_vectors() / (2 * np.pi) + m3 = np.repeat(dyn.structure.get_masses_array(), 3) + inv_sqrt_mm = 1.0 / np.sqrt(np.outer(m3, m3)) + + worst = 0.0 + for q in q_fine: + jq = min(range(len(dyn_fine.q_tot)), + key=lambda j: CC.Methods.get_min_dist_into_cell( + bg, np.asarray(q), np.asarray(dyn_fine.q_tot[j]))) + D = dyn_fine.dynmats[jq] * inv_sqrt_mm + D = 0.5 * (D + np.conj(D.T)) + e = np.linalg.eigvalsh(D) + w_ref = np.sign(e) * np.sqrt(np.abs(e)) + iq = min(range(len(q_fine)), + key=lambda i: CC.Methods.get_min_dist_into_cell( + bg, np.asarray(q), np.asarray(q_fine[i]))) + worst = max(worst, np.abs(np.sort(w_ignored[:, iq]) - np.sort(w_ref)).max()) + + assert worst < 1e-8, ( + "ignore_effective_charges=True must be exact for a range-1 model, " + "got {:.3e} Ry".format(worst)) + + +def test_charges_corrupt_the_offgrid_continuation(): + """The whole point: with the charges used, the same range-1 model is no + longer reproduced off-grid, even though it is exact on-grid.""" + dyn = _dyn_with_charges() + fine = 2 * L + q_fine = _fine_q(dyn, fine) + + w_used, _ = QI.interpolate_dyn_fine(dyn, q_fine, use_asr=False, + reuse_commensurate=False, + ignore_effective_charges=False) + w_ignored, _ = QI.interpolate_dyn_fine(dyn, q_fine, use_asr=False, + reuse_commensurate=False, + ignore_effective_charges=True) + + # off-grid points: fine index odd along the chain direction + frac = np.array([dyn.structure.unit_cell @ q for q in q_fine]) + n_z = np.round(frac[:, 2] * fine).astype(int) % fine + off = (n_z % (fine // L)) != 0 + assert off.any(), "the fine mesh must contain off-grid points" + + assert np.abs(w_used[:, ~off] - w_ignored[:, ~off]).max() < 1e-9, ( + "the conventions must coincide on the coarse points") + assert np.abs(w_used[:, off] - w_ignored[:, off]).max() > 1e-6, ( + "the long-range cycle must visibly displace the off-grid " + "continuation of a range-1 model -- if this ever fails, the " + "subtract/re-add path in cellconstructor has changed") diff --git a/tests/test_interpolation/test_mesh_and_dyn.py b/tests/test_interpolation/test_mesh_and_dyn.py new file mode 100644 index 00000000..77bb05bc --- /dev/null +++ b/tests/test_interpolation/test_mesh_and_dyn.py @@ -0,0 +1,140 @@ +""" +Unit tests for the fine-mesh utilities and the interpolated dynamical matrix +(no Lanczos runs; no Julia needed except through module imports). + +Covers: +- fine mesh generation (Gamma first, closure under q -> -q and pair map) +- O(1) index lookup consistency with the O(n^2) distance search +- interpolated dyn: exact at commensurate points, EXACT everywhere for the + nearest-neighbor spring chain (its force constants are strictly range-1, + so centered Fourier interpolation has zero error -- a sharp test), +- time-reversal gauge e(-q) = conj(e(q)), +- acoustic sum rule of the interpolated dyn (w_acoustic -> 0 smoothly). +""" +import os, sys +os.environ.setdefault("JULIA_NUM_THREADS", "1") +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import numpy as np +import pytest + +import cellconstructor as CC +import cellconstructor.Methods + +import _toy_chain as TC + +try: + import tdscha.QSpaceInterpolation as QI + _OK = True +except Exception: + _OK = False + +pytestmark = pytest.mark.skipif(not _OK, reason="tdscha.QSpaceInterpolation not importable") + + +def test_mesh_generation_and_lookup(): + unit = TC.build_unit_structure() + mesh = (2, 3, 4) + q_points, idx = QI.generate_fine_mesh(unit, mesh) + + assert len(q_points) == 24 + assert np.allclose(q_points[0], 0.0), "Gamma must be first" + + lookup = QI.build_q_index_lookup(q_points, unit, mesh) + assert len(lookup) == 24, "lookup keys must be unique" + + bg = unit.get_reciprocal_vectors() / (2 * np.pi) + + # every q must find itself through the hash + for iq, q in enumerate(q_points): + assert lookup[QI.mesh_key(q, unit, mesh)] == iq + + # closure under q -> -q, verified against the O(n^2) distance search + for iq, q in enumerate(q_points): + jq = lookup[QI.mesh_key(-q, unit, mesh)] + d = CC.Methods.get_min_dist_into_cell(bg, -q, q_points[jq]) + assert d < 1e-8 + + +def test_pair_map_matches_parent_search(): + """The O(N_f) mesh-index pair map must agree with the parent's + O(n^2) minimum-distance search on the same q list.""" + unit = TC.build_unit_structure() + mesh = (1, 2, 3) + q_points, idx = QI.generate_fine_mesh(unit, mesh) + lookup = QI.build_q_index_lookup(q_points, unit, mesh) + bg = unit.get_reciprocal_vectors() / (2 * np.pi) + + for iq_pert in range(len(q_points)): + n_pert = idx[iq_pert] + for iq1 in range(len(q_points)): + # index arithmetic + key = tuple((n_pert - idx[iq1]) % np.asarray(mesh)) + iq2_fast = lookup[key] + # reference: q2 = q_pert - q1 modulo G + q_target = q_points[iq_pert] - q_points[iq1] + d = CC.Methods.get_min_dist_into_cell(bg, q_target, q_points[iq2_fast]) + assert d < 1e-8, (iq_pert, iq1) + + +def test_dyn_interpolation_exact_for_range1_model(): + """The spring chain has strictly nearest-cell force constants: centered + Fourier interpolation from ANY supercell L >= 2 must reproduce the + L' = 2L dispersion exactly (not just approximately).""" + dyn2 = TC.build_dyn(3) + dyn4 = TC.build_dyn(6) + + q_fine, _ = QI.generate_fine_mesh(dyn2.structure, (1, 1, 6)) + w_int, pols_int = QI.interpolate_dyn_fine(dyn2, q_fine, use_asr=True) + + # reference frequencies from the direct 1x1x6 dyn + lookup = QI.build_q_index_lookup(q_fine, dyn2.structure, (1, 1, 6)) + for jq, q in enumerate(dyn4.q_tot): + iq = lookup[QI.mesh_key(np.asarray(q), dyn2.structure, (1, 1, 6))] + w_ref, _ = dyn4.DyagDinQ(jq) + assert np.max(np.abs(np.sort(w_ref) - np.sort(w_int[:, iq]))) < 1e-9, \ + "interpolated dispersion wrong at q={}".format(q) + + +def test_tensor2_sign_convention(): + """Pin the Tensor2.Interpolate phase convention: Interpolate(-q) must + reproduce dyn.dynmats[q] at commensurate NON-TRI q (a q <-> -q swap is + invisible to frequencies and to the constructive TRI gauge, so it must + be pinned at the dynamical-matrix level).""" + import cellconstructor.ForceTensor + dyn = TC.build_dyn(3) + uc = dyn.structure + sc = uc.generate_supercell(dyn.GetSupercell()) + t2 = CC.ForceTensor.Tensor2(uc, sc, dyn.GetSupercell()) + t2.SetupFromPhonons(dyn) + t2.Center() + for iq, q in enumerate(dyn.q_tot): + if np.linalg.norm(q) < 1e-8: + continue + D_ref = dyn.dynmats[iq] + D_minus = t2.Interpolate(-np.asarray(q), asr=False, lo_to_splitting=False) + assert np.max(np.abs(D_minus - D_ref)) < 1e-12, \ + "Tensor2.Interpolate sign convention changed!" + + +def test_dyn_interpolation_tri_gauge_and_asr(): + dyn = TC.build_dyn(3) + mesh = (1, 1, 6) + q_fine, _ = QI.generate_fine_mesh(dyn.structure, mesh) + w_int, pols_int = QI.interpolate_dyn_fine(dyn, q_fine, use_asr=True) + lookup = QI.build_q_index_lookup(q_fine, dyn.structure, mesh) + + # TRI gauge: e(-q) = conj(e(q)), w(-q) = w(q) + for iq, q in enumerate(q_fine): + jq = lookup[QI.mesh_key(-q, dyn.structure, mesh)] + assert np.allclose(w_int[:, iq], w_int[:, jq], atol=1e-12) + assert np.allclose(pols_int[:, :, jq], np.conj(pols_int[:, :, iq]), + atol=1e-10) + + # ASR: at Gamma exactly three zero modes; smallest nonzero acoustic + # frequency at the closest-to-Gamma fine point must be positive and small + w_gamma = np.sort(np.abs(w_int[:, 0])) + assert np.all(w_gamma[:3] < 1e-7), "Gamma translations must be at zero" + assert np.all(w_gamma[3:] > 1e-4), "optical modes must be finite" + + # acoustic branch grows away from Gamma (stability) + assert np.all(w_int[:, 1:] > -1e-8), "no imaginary frequencies off Gamma" diff --git a/tests/test_qspace/_build_nontri.py b/tests/test_qspace/_build_nontri.py new file mode 100644 index 00000000..b2905960 --- /dev/null +++ b/tests/test_qspace/_build_nontri.py @@ -0,0 +1,183 @@ +""" +Builder for a small NON-time-reversal-invariant test system. + +A diatomic chain with a 1x1x3 supercell gives q = 0, 1/3, 2/3 along z. +The points 1/3 and 2/3 = -1/3 are *distinct* (non-TRI), which is the path the +all-TRI 2x2x2 dataset never exercises in the anharmonic q-space code. + +The model is an isotropic diatomic spring chain (guaranteed positive definite, +exact acoustic sum rule). We then build a SSCHA ensemble and give it forces +with a deterministic cubic+quartic anharmonicity so that D3/D4 averages are +non-zero. +""" +from __future__ import print_function +import numpy as np + +import cellconstructor as CC +import cellconstructor.Structure +import cellconstructor.Phonons +import cellconstructor.symmetries +import cellconstructor.Units + +import sscha, sscha.Ensemble + +SUPERCELL = (1, 1, 3) +# anisotropic spring constants (Ry/Bohr^2) per Cartesian component +# (distinct per direction -> non-degenerate modes, clean mode matching). +K1 = np.array([0.18, 0.225, 0.15]) # intracell +K2 = np.array([0.10, 0.07, 0.16]) # intercell +MASS_A = 1000.0 # in Ry atomic units (m_e); distinct masses -> optical gap +MASS_B = 2500.0 + + +def build_unit_structure(): + s = CC.Structure.Structure(2) + # Triclinic cell with generic atomic positions -> P1 (point group = identity + # only). With no point-group symmetry the anharmonic force model cannot + # accidentally break a symmetry the codes assume; the only symmetry left is + # translational (momentum conservation), which both codes must reproduce. + s.unit_cell = np.array([[5.0, 0.0, 0.0], + [0.7, 6.0, 0.0], + [0.9, 0.6, 7.0]]) + s.coords[0] = np.array([0.10, 0.15, 0.00]) + s.coords[1] = np.array([0.37, 0.21, 2.60]) # generic, along the chain (z) + s.atoms = ["A", "B"] + s.masses = {"A": MASS_A, "B": MASS_B} + s.has_unit_cell = True + return s + + +def get_bonds(super_struct, itau): + """Return the list of (i, j, k_vec) bonds of the diatomic chain. + + Each A atom bonds to its nearest B (k1, intracell) and second-nearest B + (k2, intercell), using the minimum image along the chain. + """ + nat = super_struct.N_atoms + coords = super_struct.coords + cell = super_struct.unit_cell + + def mindist(i, j): + d = coords[j] - coords[i] + frac = CC.Methods.covariant_coordinates(cell, d[None, :])[0] + frac -= np.round(frac) + return frac @ cell + + A_atoms = [i for i in range(nat) if itau[i] == 0] + B_atoms = [i for i in range(nat) if itau[i] == 1] + bonds = [] + for i in A_atoms: + order = sorted(B_atoms, key=lambda j: np.linalg.norm(mindist(i, j))) + bonds.append((i, order[0], K1)) + bonds.append((i, order[1], K2)) + return bonds + + +def build_supercell_fc(super_struct, itau): + """Diatomic anisotropic spring chain force constants in the supercell.""" + nat = super_struct.N_atoms + fc = np.zeros((3 * nat, 3 * nat)) + for i, j, k in get_bonds(super_struct, itau): + for a in range(3): + fc[3 * i + a, 3 * j + a] += -k[a] + fc[3 * j + a, 3 * i + a] += -k[a] + fc[3 * i + a, 3 * i + a] += k[a] + fc[3 * j + a, 3 * j + a] += k[a] + return fc + + +def build_dyn(): + unit = build_unit_structure() + super_struct = unit.generate_supercell(SUPERCELL) + itau = super_struct.get_itau(unit) - 1 + + fc_sc = build_supercell_fc(super_struct, itau) + + q_tot = CC.symmetries.GetQGrid(unit.unit_cell, SUPERCELL) + q_tot = [np.array(q) for q in q_tot] + + dynq = CC.Phonons.GetDynQFromFCSupercell( + fc_sc, np.array(q_tot), unit, super_struct) + + dyn = CC.Phonons.Phonons(unit, nqirr=len(q_tot)) + dyn.q_tot = [np.array(q) for q in q_tot] + dyn.dynmats = [dynq[i] for i in range(len(q_tot))] + # Treat every q as its own star (avoid symmetry q-star machinery). + dyn.q_stars = [[np.array(q)] for q in q_tot] + dyn.AdjustQStar() + return dyn + + +def tri_status(dyn): + q = np.array(dyn.q_tot) + bg = dyn.structure.get_reciprocal_vectors() / (2 * np.pi) + out = [] + for i, qq in enumerate(q): + d = CC.Methods.get_min_dist_into_cell(bg, qq, -qq) + out.append((i, qq, d < 1e-6)) + return out + + +def make_ensemble(dyn, T, N, seed=0, g3=0.6, g4=1.5): + """Generate a SSCHA ensemble and assign BOND-based anharmonic forces. + + Each bond (a, b) gets an anharmonic energy g3/3 s^3 + g4/4 s^4 in the bond + stretch s = u_a - u_b (per Cartesian component). Because the anharmonicity + lives on the bonds (like the harmonic springs), the residual force + forces - sscha_forces has weight at ALL q-points, including q = +-1/3. + This is essential to exercise the (q, -q) off-diagonal anharmonic blocks. + """ + np.random.seed(seed) + ens = sscha.Ensemble.Ensemble(dyn, T) + ens.generate(N) + + u = ens.u_disps.copy() # Angstrom, (N, 3*nat_sc) + super_struct = dyn.structure.generate_supercell(dyn.GetSupercell()) + itau = super_struct.get_itau(dyn.structure) - 1 + bonds = get_bonds(super_struct, itau) + + u_bohr = u * CC.Units.A_TO_BOHR # Bohr + nat = super_struct.N_atoms + f_harm = np.zeros_like(u_bohr) # harmonic bond force, Ry/Bohr + f_anh = np.zeros_like(u_bohr) # anharmonic bond force, Ry/Bohr + + for (i, j, k) in bonds: + for a in range(3): + ia, ja = 3 * i + a, 3 * j + a + s = u_bohr[:, ia] - u_bohr[:, ja] # bond stretch + fh = k[a] * s # harmonic + fa = g3 * s ** 2 + g4 * s ** 3 # anharmonic part of dE/ds + f_harm[:, ia] += -fh + f_harm[:, ja] += +fh + f_anh[:, ia] += -fa + f_anh[:, ja] += +fa + + # Centre the anharmonic force so = 0, i.e. mimic a + # SSCHA stationary point. Otherwise the (large) net force exercises the + # Gamma-only mean-force subtraction, which is irrelevant to converged runs. + f_anh -= np.mean(f_anh, axis=0, keepdims=True) + f_tot = (f_harm + f_anh) * CC.Units.A_TO_BOHR # Ry/Angstrom + ens.forces = f_tot.reshape(N, nat, 3) + ens.energies = np.zeros(N) + ens.force_computed = np.ones(N, dtype=bool) + + # CRITICAL: refresh the q-space arrays. ens.generate() precomputes + # u_disps_qspace and leaves forces_qspace = 0; assigning ens.forces + # afterwards does NOT update them, and QSpaceLanczos skips ens.init() + # when u_disps_qspace is already present. Without this call the q-space + # code silently uses STALE ZERO forces (this was the origin of the + # "force-Parseval artifact" previously attributed to the hand-built dyn). + ens.init() + return ens + + +if __name__ == "__main__": + dyn = build_dyn() + w, p, wq, pq = dyn.DiagonalizeSupercell(return_qmodes=True) + print("supercell", dyn.GetSupercell(), "nq", len(dyn.q_tot)) + for i, q, tri in tri_status(dyn): + print(" iq={} q={} TRI={} freqs[cm-1]={}".format( + i, np.round(q, 4), tri, np.round(wq[:, i] * CC.Units.RY_TO_CM, 2))) + nontri = sum(1 for _, _, tri in tri_status(dyn) if not tri) + print("NON-TRI q-points:", nontri) + print("min supercell freq cm-1:", np.min(w) * CC.Units.RY_TO_CM) diff --git a/tests/test_qspace/_distributed_probe.py b/tests/test_qspace/_distributed_probe.py new file mode 100644 index 00000000..1cdb37ab --- /dev/null +++ b/tests/test_qspace/_distributed_probe.py @@ -0,0 +1,109 @@ +"""Worker for test_distributed_loader.py -- run under mpirun, dump a/b/c. + +Usage: python _distributed_probe.py + mode = serial-plain | dist-plain | serial-tri | dist-tri + | oracle-tri | guard-tri + +``oracle-tri`` is the build-on-every-rank reference: it replicates the +ensemble during construction, which is exactly what the production loader +avoids, so it exists only to pin the master-only path against it. + +``guard-tri`` reintroduces the historical defect on purpose and must fail +loudly rather than hang or return a corrupted object. + +Separate processes (rather than one job that builds both) because every +matrix-vector product goes through a collective reduction: a rank that built a +second, non-distributed object and stepped it alone would hang the others. +""" +from __future__ import print_function + +import os +import sys + +import numpy as np + +import cellconstructor as CC +import cellconstructor.Phonons +import sscha +import sscha.Ensemble + +import tdscha.QSpaceLanczos as QL +import tdscha.QSpaceAtomFourier as AF +import cellconstructor.Settings as Parallel + +T = 250.0 +NQIRR = 3 +FINE = (2, 2, 4) # multiple of the 2x2x2 coarse supercell +NSTEPS = 6 +BAND = 3 +POP = 1 + + +def build(mode, data_dir): + dyn = CC.Phonons.Phonons(os.path.join(data_dir, "dyn_gen_pop%d_" % POP), + NQIRR) + if mode == "serial-plain": + ens = sscha.Ensemble.Ensemble(dyn, T) + ens.load_bin(data_dir, POP) + lanc = QL.QSpaceLanczos(ens, lo_to_split=None) + lanc.init(use_symmetries=True) + elif mode == "dist-plain": + lanc = QL.load_distributed_tdscha(data_dir, POP, dyn, T, + use_symmetries=True) + elif mode == "serial-tri": + ens = sscha.Ensemble.Ensemble(dyn, T) + ens.load_bin(data_dir, POP) + lanc = AF.QSpaceAtomFourierLanczos(ens, fine_mesh=FINE) + lanc.init(use_symmetries=True) + elif mode == "dist-tri": + lanc = AF.load_distributed_atom_fourier_tdscha( + data_dir, POP, dyn, T, fine_mesh=FINE, + use_symmetries=True) + elif mode == "guard-tri": + # Deliberately leaves the collective interpolation inside the + # constructor, i.e. reintroduces the defect the loader guards + # against. The workers must notice that the master's ForceTensor + # broadcast, not the loader's metadata, reached them. + class Unprepared(AF.QSpaceAtomFourierLanczos): + @classmethod + def prepare_distributed_construction(cls, dyn, **kwargs): + return {} + + lanc = QL.load_distributed_tdscha( + data_dir, POP, dyn, T, use_symmetries=True, + lanczos_class=Unprepared, fine_mesh=FINE) + elif mode == "oracle-tri": + lanc = QL.load_distributed_tdscha( + data_dir, POP, dyn, T, use_symmetries=True, + lanczos_class=AF.QSpaceAtomFourierLanczos, + build_on_all_ranks=True, fine_mesh=FINE) + else: + raise ValueError("unknown mode %s" % mode) + return lanc + + +def main(): + mode, data_dir, out = sys.argv[1], sys.argv[2], sys.argv[3] + lanc = build(mode, data_dir) + + # The distributed object must still describe the *global* ensemble. + n_global = getattr(lanc, "_N_global", lanc.N) + + lanc.prepare_mode_q(0, BAND) + lanc.run_FT(NSTEPS, verbose=False, reorthogonalize=False, optimized=True) + + if Parallel.am_i_the_master(): + np.savez(out, + a=np.asarray(lanc.a_coeffs, dtype=float), + b=np.asarray(lanc.b_coeffs, dtype=float), + c=np.asarray(lanc.c_coeffs, dtype=float), + n_global=n_global, + cls=type(lanc).__name__, + distributed=bool(getattr(lanc, "_distributed", False)), + xq_nq=lanc.X_q.shape[0], + n_local=lanc.X_q.shape[1]) + print("%s: wrote %s" % (mode, out)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_qspace/plot_qspace.py b/tests/test_qspace/plot_qspace.py deleted file mode 100644 index a80405c3..00000000 --- a/tests/test_qspace/plot_qspace.py +++ /dev/null @@ -1,79 +0,0 @@ -import sys, os -import matplotlib.pyplot as plt -import numpy as np - - -import cellconstructor as CC, cellconstructor.Phonons -import sscha, sscha.Ensemble -import tdscha, tdscha.QSpaceKPM - -N_STEPS = 256 -NQIRR = 3 -T = 250 -DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), - '..', 'test_julia', 'data') -IGNORE_V3 = True -IGNORE_V4 = True - -def plot_qspace(show=False): - dyn = CC.Phonons.Phonons("{}/dyn_gen_pop1_".format(DATA_DIR), NQIRR) - ens = sscha.Ensemble.Ensemble(dyn, T) - ens.load_bin(DATA_DIR, 1) - - kpm = tdscha.QSpaceKPM.QSpaceKPM(ens, lo_to_split=None) - kpm.ignore_v3 = IGNORE_V3 - kpm.ignore_v4 = IGNORE_V4 - kpm.init() - - kpm.prepare_mode_q(0, 5) - - # Estimate KPM steps for 2 cm⁻¹ precision - n_steps_estimated = kpm.estimate_kpm_steps(2.0, bound_factor=1.2) - print(f"Estimated KPM steps for 2 cm⁻¹ precision: {n_steps_estimated}") - - # Use the estimated steps or the fixed N_STEPS, whichever is larger - n_steps = max(N_STEPS, n_steps_estimated) - print(f"Using {n_steps} KPM steps") - - kpm.run_KPM(n_steps, verbose=False, bound_factor=1.2) # Use tighter bounds for better resolution - - w = np.linspace(0, 200, 1000) - w_ry = w / CC.Units.RY_TO_CM - spectral = kpm.get_spectral_function_KPM(w_ry, regularization="jackson") - - # Do the same with the standard lanczos - lanc = tdscha.QSpaceLanczos.QSpaceLanczos(ens, lo_to_split=None) - lanc.ignore_v3 = IGNORE_V3 - lanc.ignore_v4 = IGNORE_V4 - lanc.init() - lanc.prepare_mode_q(0, 5) - lanc.run_FT(N_STEPS, verbose=False) - - gf_lanc = lanc.get_green_function_continued_fraction(w_ry, use_terminator=False, smearing=0.05 * kpm.w_q[5, 0]) - spectral_lanc = -np.imag(gf_lanc) - - - peak_pos = w[np.argmax(spectral)] - peak_pos_lanc = w[np.argmax(spectral_lanc)] - print("Peak position KPM: {:.2f} cm-1".format(peak_pos)) - print("Peak position Lanczos: {:.2f} cm-1".format(peak_pos_lanc)) - print("Error: {:.2f} cm-1 | {} %".format(abs(peak_pos - peak_pos_lanc), 100 * abs(peak_pos - peak_pos_lanc) / peak_pos_lanc)) - - # Plot the resunt - fig = plt.figure() - plt.plot(w, spectral, label="KPM") - plt.plot(w, spectral_lanc, label="Lanczos") - plt.axvline(kpm.w_q[5, 0] * CC.Units.RY_TO_CM, color="C0", linestyle="--", label="Mode freq") - plt.xlabel("Frequency (cm$^{-1}$)") - plt.ylabel("Spectral function") - plt.legend() - plt.savefig("qspace_kpm_spectral.png") - if show: - plt.show() - - -if __name__ == "__main__": - show = sys.argv[-1] == "--show" - plot_qspace(show=show) - - diff --git a/tests/test_qspace/test_anharm_q_vs_real.py b/tests/test_qspace/test_anharm_q_vs_real.py new file mode 100644 index 00000000..3706a16b --- /dev/null +++ b/tests/test_qspace/test_anharm_q_vs_real.py @@ -0,0 +1,174 @@ +""" +Compare the ANHARMONIC part of the Lanczos propagator L between the real-space +DynamicalLanczos and the q-space QSpaceLanczos. + +The Lanczos coefficients a/b/c are scalar products and are therefore +*basis independent*: when the perturbation is the same physical phonon the two +representations must produce identical coefficients (up to ensemble noise). + +Contents +-------- + test_coeffs_tri + All-TRI 2x2x2 supercell (every q satisfies q == -q) with the real SnTe-like + ensemble. The codes AGREE for D3, D4 and full anharmonic, at Gamma and at + an X point. This is a valid regression test and it passes. + + test_synthetic_dyn_force_parseval_healthcheck + Historically an xfail "negative control" blaming the hand-built dyn for + inconsistent force projections (Sum|Y_q|^2 / Sum|Y_real|^2 ~ 0.04). The + real cause was a STALENESS TRAP in the ensemble: ens.generate() + precomputes u_disps_qspace and zeroes forces_qspace; assigning + ens.forces afterwards does not refresh them, and QSpaceLanczos skips + ens.init() when u_disps_qspace is already present -- so the q-space code + silently used zero forces. _build_nontri.make_ensemble now calls + ens.init() after assigning the forces and both projections are exact; + the test now passes as a positive regression guard. +""" +from __future__ import print_function +import os +import sys +os.environ.setdefault("JULIA_NUM_THREADS", "1") +import numpy as np +import pytest + +import cellconstructor as CC +import cellconstructor.Phonons +import cellconstructor.Methods +import cellconstructor.Units + +import sscha, sscha.Ensemble +import tdscha.DynamicalLanczos as DL + +try: + import tdscha.QSpaceLanczos as QL + _HAS_Q = QL.__JULIA_EXT__ +except Exception: + _HAS_Q = False + +import _build_nontri as B + +pytestmark = pytest.mark.skipif(not _HAS_Q, reason="QSpaceLanczos/Julia not available") + +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), + '..', 'test_julia', 'data') + + +# -------------------------------------------------------------------------- +# helpers +# -------------------------------------------------------------------------- +def _match_mode(dyn, iq, band): + """Supercell-mode index whose frequency matches q-mode (iq, band).""" + ws_sc, pols_sc, w_q, _ = dyn.DiagonalizeSupercell(return_qmodes=True) + ss = dyn.structure.generate_supercell(dyn.GetSupercell()) + trans = CC.Methods.get_translations(pols_sc, ss.get_masses_array()) + good = ws_sc[~trans] + return int(np.where(np.abs(good - w_q[band, iq]) < 1e-7)[0][0]) + + +def _run_real(ens, mode_index, n_steps, iv3, iv4): + lanc = DL.Lanczos(ens, lo_to_split=None) + lanc.ignore_harmonic = False + lanc.ignore_v3, lanc.ignore_v4 = iv3, iv4 + lanc.use_wigner = True + lanc.mode = DL.MODE_FAST_JULIA + lanc.init(use_symmetries=True) + lanc.prepare_mode(mode_index) + lanc.run_FT(n_steps, run_simm=True, verbose=False) + return np.array(lanc.a_coeffs), np.array(lanc.b_coeffs) + + +def _run_q(ens, iq, band, n_steps, iv3, iv4): + q = QL.QSpaceLanczos(ens, lo_to_split=None) + q.ignore_harmonic = False + q.ignore_v3, q.ignore_v4 = iv3, iv4 + q.init(use_symmetries=True) + q.prepare_mode_q(iq, band) + q.run_FT(n_steps, verbose=False, reorthogonalize=True) + return np.array(q.a_coeffs), np.array(q.b_coeffs) + + +def _relerr(x, y): + n = min(len(x), len(y)) + x, y = x[:n], y[:n] + s = np.maximum(np.abs(x), np.abs(y)) + s[s == 0] = 1.0 + return np.max(np.abs(x - y) / s) + + +def _compare_coeffs(ens, dyn, iq, band, n_steps=5): + mode_index = _match_mode(dyn, iq, band) + worst = 0.0 + for iv3, iv4 in [(False, True), (True, False), (False, False)]: + ar, br = _run_real(ens, mode_index, n_steps, iv3, iv4) + aq, bq = _run_q(ens, iq, band, n_steps, iv3, iv4) + worst = max(worst, _relerr(ar, aq)) + if len(br) and len(bq): + worst = max(worst, _relerr(br, bq)) + return worst + + +def _force_parseval_ratio(ens): + """Sum|Y_q|^2 / Sum|Y_real|^2 over valid modes -- must be 1 for consistency.""" + lanc = DL.Lanczos(ens, lo_to_split=None) + lanc.ignore_v3, lanc.ignore_v4, lanc.use_wigner = False, True, True + lanc.mode = DL.MODE_FAST_JULIA + lanc.init(use_symmetries=True) + q = QL.QSpaceLanczos(ens, lo_to_split=None) + q.ignore_v3, q.ignore_v4 = False, True + q.init(use_symmetries=True) + Yr = np.sum(np.abs(lanc.Y) ** 2) + Xr = np.sum(np.abs(lanc.X) ** 2) + Yq = Xq = 0.0 + for iq in range(q.n_q): + v = q.valid_modes_q[:, iq] + Xq += np.sum(np.abs(q.X_q[iq][:, v]) ** 2) + Yq += np.sum(np.abs(q.Y_q[iq][:, v]) ** 2) + return Xq / Xr, Yq / Yr + + +# -------------------------------------------------------------------------- +# 1. all-TRI 2x2x2, real ensemble -- the codes agree (valid regression test) +# -------------------------------------------------------------------------- +def test_coeffs_tri(): + T = 250.0 + dyn = CC.Phonons.Phonons(os.path.join(DATA_DIR, "dyn_gen_pop1_"), 3) + ens = sscha.Ensemble.Ensemble(dyn, T) + ens.load_bin(DATA_DIR, 1) + + # displacement AND force projections must be consistent on real data + xr, yr = _force_parseval_ratio(ens) + assert abs(xr - 1.0) < 1e-6, "X Parseval broken on real ensemble: {}".format(xr) + assert abs(yr - 1.0) < 1e-6, "Y Parseval broken on real ensemble: {}".format(yr) + + # Gamma optical (band 5) and X-point (iq=5, band 2) + worst_gamma = _compare_coeffs(ens, dyn, 0, 5) + worst_x = _compare_coeffs(ens, dyn, 5, 2) + assert worst_gamma < 1e-4, "TRI Gamma coeffs disagree: {:.2e}".format(worst_gamma) + assert worst_x < 1e-4, "TRI X-point coeffs disagree: {:.2e}".format(worst_x) + + +# -------------------------------------------------------------------------- +# 2. hand-built dyn health check (RESOLVED). +# The historical "force-Parseval artifact" was NOT a property of the +# hand-built dyn: ens.generate() precomputes u_disps_qspace and leaves +# forces_qspace = 0, and assigning ens.forces afterwards does not refresh +# them (QSpaceLanczos skips ens.init() when u_disps_qspace is present). +# make_ensemble now calls ens.init() after assigning the forces, and both +# projections are consistent. This test guards against a regression of +# that staleness trap. +# -------------------------------------------------------------------------- +def test_synthetic_dyn_force_parseval_healthcheck(): + dyn = B.build_dyn() + ens = B.make_ensemble(dyn, 300.0, 3000, seed=1) + xr, yr = _force_parseval_ratio(ens) + assert abs(xr - 1.0) < 1e-6, "X Parseval: {:.6f}".format(xr) + assert abs(yr - 1.0) < 1e-6, "Y Parseval: {:.6f} (stale forces_qspace?)".format(yr) + + +if __name__ == "__main__": + T = 250.0 + dyn = CC.Phonons.Phonons(os.path.join(DATA_DIR, "dyn_gen_pop1_"), 3) + ens = sscha.Ensemble.Ensemble(dyn, T) + ens.load_bin(DATA_DIR, 1) + print("TRI X/Y Parseval:", _force_parseval_ratio(ens)) + print("TRI worst coeff rel-err (Gamma):", _compare_coeffs(ens, dyn, 0, 5)) diff --git a/tests/test_qspace/test_distributed.py b/tests/test_qspace/test_distributed.py index 2c8c6ef2..0a6f881b 100644 --- a/tests/test_qspace/test_distributed.py +++ b/tests/test_qspace/test_distributed.py @@ -26,7 +26,6 @@ import sscha, sscha.Ensemble import tdscha.QSpaceLanczos as QL -import tdscha.QSpaceKPM as QK import tdscha.QSpaceHessian as QH from tdscha.QSpaceLanczos import load_distributed_tdscha @@ -231,42 +230,6 @@ def test_distributed_hessian(): assert np.all(evals >= -1e-10), "Negative eigenvalues in Hessian" -def test_distributed_kpm(): - """Test that KPM works with distributed configurations.""" - n_procs = _get_n_procs() - if n_procs < 2: - pytest.skip("This test requires mpirun -np 2") - - pprint("=" * 60) - pprint("TEST: Distributed KPM") - pprint("=" * 60) - - # Create dynamical matrix - dyn = _create_dyn() - - # Use load_distributed_tdscha - qlanc = load_distributed_tdscha(DATA_DIR, 1, dyn, T, lo_to_split=None, use_symmetries=True) - - # Prepare perturbation - iq = 0 - band = _find_gamma_mode(dyn) - qlanc.prepare_mode_q(iq, band) - - # Create KPM from distributed Lanczos - pprint("Creating KPM from distributed Lanczos...") - kpm = QK.QSpaceKPM.from_qspace_lanczos(qlanc) - kpm.prepare_mode_q(iq, band) - - # Estimate and run KPM - pprint("Running KPM...") - n_moments = kpm.estimate_kpm_steps(precision_cm=50) - n_moments = min(n_moments, 16) # Cap for test speed - kpm.run_KPM(n_moments, verbose=False) - - # Check that moments are finite - assert all(np.isfinite(kpm.kpm_moments)), "KPM moments contain NaN/Inf" - - def test_goparallel_vs_distributed(): """Compare results from GoParallel (regular) vs load_distributed_tdscha. diff --git a/tests/test_qspace/test_distributed_loader.py b/tests/test_qspace/test_distributed_loader.py new file mode 100644 index 00000000..d1c87721 --- /dev/null +++ b/tests/test_qspace/test_distributed_loader.py @@ -0,0 +1,142 @@ +"""Distributing the ensemble must not change the Lanczos coefficients. + +``load_distributed_tdscha`` scatters the configurations across MPI ranks so no +rank holds a full replica. This checks the property that makes it usable: a +distributed run reproduces the replicated one, for the plain q-space Lanczos +*and* for the atom-Fourier interpolated one. + +The interpolated case is the delicate one. Building +``QSpaceAtomFourierLanczos`` interpolates the dynamical matrix, and that goes +through CellConstructor's ``ForceTensor`` (``Center`` and ``Apply_ASR``), each +of which ends in an unconditional ``Settings.broadcast``. Left inside the +master-only branch it deadlocks -- master in the ASR broadcast, workers in the +metadata broadcast. ``prepare_distributed_construction`` moves exactly that +step in front of the master/worker split so every rank runs it together, and +the master then reads the configurations alone. A regression here shows up as +a hang, so these tests carry a timeout and treat expiry as failure. + +Each case runs in its own ``mpirun`` because every matrix-vector product is a +collective: a rank that built a second, replicated object and stepped it alone +would hang the others. +""" +from __future__ import print_function + +import os +import subprocess +import sys + +import numpy as np +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, "..", "..")) +PROBE = os.path.join(HERE, "_distributed_probe.py") +DATA = os.path.join(REPO, "tests", "test_julia", "data") +TIMEOUT = 900 + +pytest.importorskip("mpi4py") +if not os.path.isdir(DATA): + pytest.skip("q-space test ensemble not available", allow_module_level=True) + + +def _run(launcher, mode, out, n_ranks): + cmd = [launcher, "-np", str(n_ranks), sys.executable, PROBE, mode, DATA, + out] + env = dict(os.environ, OMP_NUM_THREADS="1") + try: + proc = subprocess.run(cmd, cwd=REPO, env=env, timeout=TIMEOUT, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + except subprocess.TimeoutExpired: + pytest.fail("%s did not finish in %ds -- most likely a deadlock " + "between mismatched MPI collectives." % (mode, TIMEOUT)) + assert proc.returncode == 0, \ + "%s failed (rc=%d):\n%s" % (mode, proc.returncode, + proc.stdout.decode()[-3000:]) + assert os.path.exists(out), "%s produced no output" % mode + return np.load(out, allow_pickle=True) + + +def _assert_same_coeffs(serial, dist, msg): + for name in "abc": + s, d = serial[name], dist[name] + assert s.shape == d.shape, \ + "%s: %s shape %s != %s" % (msg, name, s.shape, d.shape) + diff = float(np.max(np.abs(s - d))) + scale = max(float(np.max(np.abs(s))), 1e-30) + # Not bit-exact by construction: the distributed reduction sums the + # per-rank partials in a different order than the replicated loop. + assert diff / scale < 1e-10, \ + "%s: %s differs by %.3e (rel %.3e)" % (msg, name, diff, + diff / scale) + + +@pytest.mark.parametrize("kind", ["plain", "tri"]) +def test_distributed_matches_replicated(kind, tmp_path, multi_rank_mpirun): + serial = _run(multi_rank_mpirun, "serial-%s" % kind, + str(tmp_path / "serial.npz"), 1) + dist = _run(multi_rank_mpirun, "dist-%s" % kind, + str(tmp_path / "dist.npz"), 2) + + # The distributed object really did split the configurations ... + assert bool(dist["distributed"]) is True + assert int(dist["n_global"]) == int(serial["n_global"]) + assert int(dist["n_local"]) < int(serial["n_local"]), \ + "each rank should hold a strict subset of the configurations" + # ... and it is still the class we asked for. + assert str(dist["cls"]) == str(serial["cls"]) + # The ensemble Bloch fields are indexed by the COARSE q count in both + # cases: for the interpolated class that is not n_q, which is why the + # loader ships the true leading dimension instead of assuming one. + assert int(dist["xq_nq"]) == int(serial["xq_nq"]) + + _assert_same_coeffs(serial, dist, "distributed %s" % kind) + + +def test_interpolated_master_only_matches_build_everywhere( + tmp_path, multi_rank_mpirun): + """The production loader must reproduce the replicating oracle exactly. + + ``build_on_all_ranks=True`` rebuilds the whole object identically on every + rank, so it cannot disagree with itself about the interpolated mode basis. + The production path instead interpolates on all ranks and then takes the + master's copy of that basis; if those two ever produced different + polarization vectors, the ensemble Bloch fields would be projected in one + gauge and contracted in another, and the coefficients would move. + """ + oracle = _run(multi_rank_mpirun, "oracle-tri", + str(tmp_path / "oracle.npz"), 2) + dist = _run(multi_rank_mpirun, "dist-tri", + str(tmp_path / "dist.npz"), 2) + + assert bool(dist["distributed"]) is True + assert int(dist["n_global"]) == int(oracle["n_global"]) + assert int(dist["n_local"]) == int(oracle["n_local"]) + _assert_same_coeffs(oracle, dist, "master-only vs build-everywhere") + + +def test_collective_left_in_the_constructor_fails_loudly( + tmp_path, multi_rank_mpirun): + """The historical defect must not be able to come back silently. + + A collective the master runs alone does not raise anywhere: MPI matches + it against whatever the workers happen to be waiting in and hands them + the wrong payload. Before the sentinel in the metadata, that produced a + hang -- and, when it did not hang, a plausible but wrong spectrum. The + loader must now diagnose it and stop the job. + """ + cmd = [multi_rank_mpirun, "-np", "2", sys.executable, PROBE, + "guard-tri", DATA, str(tmp_path / "unused.npz")] + env = dict(os.environ, OMP_NUM_THREADS="1") + try: + proc = subprocess.run(cmd, cwd=REPO, env=env, timeout=TIMEOUT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + except subprocess.TimeoutExpired: + pytest.fail("a mismatched collective must abort, not hang") + output = proc.stdout.decode() + assert proc.returncode != 0, \ + "a mismatched collective must not be reported as success:\n%s" % ( + output[-3000:]) + assert "distributed loader received something other than its own" \ + in output, output[-3000:] + assert not os.path.exists(str(tmp_path / "unused.npz")) diff --git a/tests/test_qspace/test_estimate_kpm_steps.py b/tests/test_qspace/test_estimate_kpm_steps.py deleted file mode 100644 index bb1c7491..00000000 --- a/tests/test_qspace/test_estimate_kpm_steps.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Test the estimate_kpm_steps function.""" -import sys, os -import numpy as np - -import cellconstructor as CC, cellconstructor.Phonons -import sscha, sscha.Ensemble -import tdscha, tdscha.QSpaceKPM - -NQIRR = 3 -T = 250 -DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), - '..', 'test_julia', 'data') -IGNORE_V3 = True -IGNORE_V4 = True - -def test_estimate_kpm_steps(): - """Test the KPM step estimation function.""" - dyn = CC.Phonons.Phonons("{}/dyn_gen_pop1_".format(DATA_DIR), NQIRR) - ens = sscha.Ensemble.Ensemble(dyn, T) - ens.load_bin(DATA_DIR, 1) - - kpm = tdscha.QSpaceKPM.QSpaceKPM(ens, lo_to_split=None) - kpm.ignore_v3 = IGNORE_V3 - kpm.ignore_v4 = IGNORE_V4 - kpm.init() - - # Test 1: Must raise error if no perturbation prepared - print("Test 1: Check error when no perturbation prepared...") - try: - kpm.estimate_kpm_steps(1.0) - print(" FAILED: Should have raised ValueError") - return False - except ValueError as e: - print(f" PASSED: Got expected error: {e}") - - # Prepare perturbation at q=0, mode 5 - kpm.prepare_mode_q(0, 5) - - # Test 2: Check with different precisions - print("\nTest 2: Check step estimates for different precisions...") - precisions = [10.0, 5.0, 2.0, 1.0, 0.5] # cm⁻¹ - for prec in precisions: - n_steps = kpm.estimate_kpm_steps(prec, bound_factor=1.2) - print(f" Precision {prec:.1f} cm⁻¹ -> {n_steps} steps") - # Higher precision (smaller value) should need more steps - - # Test 3: Check that smaller precision gives more steps - n_steps_10 = kpm.estimate_kpm_steps(10.0, bound_factor=1.2) - n_steps_1 = kpm.estimate_kpm_steps(1.0, bound_factor=1.2) - if n_steps_1 > n_steps_10: - print(f"\n PASSED: Higher precision needs more steps ({n_steps_1} > {n_steps_10})") - else: - print(f"\n FAILED: Higher precision should need more steps ({n_steps_1} <= {n_steps_10})") - return False - - # Test 4: Check with different bound factors - print("\nTest 3: Check step estimates for different bound factors...") - for bf in [1.1, 1.2, 1.5, 2.0]: - n_steps = kpm.estimate_kpm_steps(1.0, bound_factor=bf) - print(f" bound_factor={bf:.1f} -> {n_steps} steps") - - # Test 5: Check error for invalid inputs - print("\nTest 4: Check error handling for invalid inputs...") - try: - kpm.estimate_kpm_steps(-1.0) - print(" FAILED: Should have raised ValueError for negative precision") - return False - except ValueError: - print(" PASSED: Negative precision raises ValueError") - - try: - kpm.estimate_kpm_steps(1.0, bound_factor=1.0) - print(" FAILED: Should have raised ValueError for bound_factor=1.0") - return False - except ValueError: - print(" PASSED: bound_factor=1.0 raises ValueError") - - try: - kpm.estimate_kpm_steps(1.0, bound_factor=0.5) - print(" FAILED: Should have raised ValueError for bound_factor<1.0") - return False - except ValueError: - print(" PASSED: bound_factor<1.0 raises ValueError") - - # Test 6: Verify the estimated steps actually work - print("\nTest 5: Verify estimated steps produce reasonable spectral function...") - precision = 2.0 # cm⁻¹ - n_steps = kpm.estimate_kpm_steps(precision, bound_factor=1.2) - print(f" Estimated steps for {precision} cm⁻¹ precision: {n_steps}") - - # Reset KPM state - kpm = tdscha.QSpaceKPM.QSpaceKPM(ens, lo_to_split=None) - kpm.ignore_v3 = IGNORE_V3 - kpm.ignore_v4 = IGNORE_V4 - kpm.init() - kpm.prepare_mode_q(0, 5) - - # Run KPM with estimated steps - kpm.run_KPM(n_steps, bound_factor=1.2, verbose=False) - - # Compute spectral function - w = np.linspace(0, 200, 1000) - w_ry = w / CC.Units.RY_TO_CM - spectral = kpm.get_spectral_function_KPM(w_ry, regularization="jackson") - - # Find peak and check FWHM - peak_idx = np.argmax(spectral) - peak_w = w[peak_idx] - peak_val = spectral[peak_idx] - - # Find half-maximum points - half_max = peak_val / 2 - left_idx = np.where(spectral[:peak_idx] < half_max)[0] - right_idx = np.where(spectral[peak_idx:] < half_max)[0] - - if len(left_idx) > 0 and len(right_idx) > 0: - fwhm = w[peak_idx + right_idx[0]] - w[left_idx[-1]] - print(f" Peak at {peak_w:.2f} cm⁻¹, FWHM ≈ {fwhm:.2f} cm⁻¹") - print(f" Target precision: {precision} cm⁻¹") - if fwhm <= precision * 2: # FWHM should be roughly comparable to precision - print(f" PASSED: FWHM is within expected range") - else: - print(f" WARNING: FWHM ({fwhm:.2f}) > 2×precision ({2*precision:.2f})") - else: - print(f" Could not determine FWHM (peak may be too sharp or noisy)") - - print("\n=== All tests passed! ===") - return True - -if __name__ == "__main__": - success = test_estimate_kpm_steps() - sys.exit(0 if success else 1) diff --git a/tests/test_qspace/test_numpy_guard.py b/tests/test_qspace/test_numpy_guard.py new file mode 100644 index 00000000..7e5e1ab9 --- /dev/null +++ b/tests/test_qspace/test_numpy_guard.py @@ -0,0 +1,51 @@ +"""The q-space Lanczos must refuse to run under NumPy 1.x. + +NumPy 1.26.4 on Python 3.14 aliases the masked metric products and silently +corrupts the Krylov vectors. The recursion still completes with finite +coefficients and with ``b == c`` to machine precision, so no invariant in the +algorithm catches it -- only the NumPy version does. See +``numpy1_python314_qspace_issue.md``. +""" + +import numpy as np +import pytest + +import tdscha.QSpaceLanczos as QS + + +def test_guard_passes_on_the_installed_numpy(): + """Whatever CI runs on must be a NumPy the Lanczos is correct under.""" + QS.check_numpy_version() + assert int(np.__version__.split(".")[0]) >= 2 + + +@pytest.mark.parametrize("version", ["1.26.4", "1.20.0", "0.9"]) +def test_guard_rejects_numpy_1(monkeypatch, version): + monkeypatch.setattr(QS.np, "__version__", version) + with pytest.raises(RuntimeError) as excinfo: + QS.check_numpy_version() + message = str(excinfo.value) + assert version in message + # The message has to name the escape route, not just the problem. + assert "PYTHONPATH" in message + assert "numpy1_python314_qspace_issue.md" in message + + +@pytest.mark.parametrize("version", ["2.0.0", "2.4.6", "3.1.0"]) +def test_guard_accepts_numpy_2_and_above(monkeypatch, version): + monkeypatch.setattr(QS.np, "__version__", version) + QS.check_numpy_version() + + +def test_run_FT_calls_the_guard(monkeypatch): + """The guard must fire from run_FT itself, before any linear algebra. + + QSpaceAtomFourierLanczos inherits run_FT, so guarding it here covers the + interpolated path too. + """ + monkeypatch.setattr(QS.np, "__version__", "1.26.4") + lanczos = QS.QSpaceLanczos.__new__(QS.QSpaceLanczos) + # psi is deliberately left unset: the version check has to happen first, + # otherwise a corrupted environment could still start a recursion. + with pytest.raises(RuntimeError, match="NumPy 1.26.4"): + lanczos.run_FT(1) diff --git a/tests/test_qspace/test_optimized_basis.py b/tests/test_qspace/test_optimized_basis.py new file mode 100644 index 00000000..b63385c5 --- /dev/null +++ b/tests/test_qspace/test_optimized_basis.py @@ -0,0 +1,124 @@ +"""run_FT(optimized=True) must free the Krylov basis without changing anything. + +The non-reorthogonalized q-space Lanczos is a three-term recurrence: it only +ever reads basis_Q[-1]/basis_Q[-2] (and the matching P and s_norm entries). +Retaining the whole basis therefore costs O(n_steps) memory for nothing -- +about 10 GB per rank at 200 steps on a 12^3 fine mesh, which is what makes the +12^3 interpolation infeasible. ``optimized=True`` keeps only the last few +vectors. + +These tests pin the two properties that make that safe: the coefficients are +bit-identical to a full-basis run, and the basis really does stop growing. +""" +from __future__ import print_function + +import os +import sys + +import numpy as np +import pytest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from test_restart import ( # noqa: E402 + _assert_bit_exact, _make_plain, _make_tri, pytestmark) # noqa: F401 + +import tdscha.QSpaceLanczos as QL # noqa: E402 + + +@pytest.mark.parametrize("maker,total", [(_make_plain, 10), (_make_tri, 12)]) +def test_optimized_matches_full_basis(maker, total): + """optimized=True changes memory, not numbers.""" + ref = maker() + ref.run_FT(total, verbose=False, reorthogonalize=False) + + opt = maker() + opt.run_FT(total, verbose=False, reorthogonalize=False, optimized=True) + + _assert_bit_exact(ref, opt, "optimized vs full basis") + + +@pytest.mark.parametrize("maker,total", [(_make_plain, 10), (_make_tri, 12)]) +def test_optimized_bounds_the_basis(maker, total): + """The retained basis stays at the window size instead of growing.""" + opt = maker() + opt.run_FT(total, verbose=False, reorthogonalize=False, optimized=True) + + keep = QL._KEEP_BASIS_OPTIMIZED + assert len(opt.basis_Q) <= keep, \ + "basis_Q grew to %d (window %d)" % (len(opt.basis_Q), keep) + assert len(opt.basis_P) <= keep, \ + "basis_P grew to %d (window %d)" % (len(opt.basis_P), keep) + assert len(opt.s_norm) <= keep, \ + "s_norm grew to %d (window %d)" % (len(opt.s_norm), keep) + # and the run really did advance + assert len(opt.a_coeffs) == total + + # a full-basis run of the same length keeps every vector: this is what + # guarantees the test above is measuring the truncation and not a + # recursion that stopped early. + full = maker() + full.run_FT(total, verbose=False, reorthogonalize=False) + assert len(full.basis_Q) == total + 1 + + +@pytest.mark.parametrize("maker,total", [(_make_plain, 10), (_make_tri, 12)]) +def test_optimized_in_memory_restart_is_bit_exact(maker, total): + """Chunked continuation still reproduces the single shot under truncation.""" + ref = maker() + ref.run_FT(total, verbose=False, reorthogonalize=False, optimized=True) + + split = maker() + split.run_FT(total // 2, verbose=False, reorthogonalize=False, + optimized=True) + split.run_FT(total - total // 2, verbose=False, reorthogonalize=False, + optimized=True) + + _assert_bit_exact(ref, split, "optimized in-memory restart") + + +def test_optimized_disk_restart_is_bit_exact(tmp_path): + """save_status/load_status round-trips the truncated basis exactly. + + This is the property the production checkpoints rely on: the saved npz + holds only the retained window, and resuming from it must still reproduce + an uninterrupted run. + """ + total, half = 10, 5 + ref = _make_plain() + ref.run_FT(total, verbose=False, reorthogonalize=False, optimized=True) + + lanc = _make_plain() + lanc.run_FT(half, verbose=False, reorthogonalize=False, optimized=True) + status = str(tmp_path / "opt_status") + lanc.save_status(status) + + resumed = _make_plain() + resumed.load_status(status + ".npz") + resumed.run_FT(total - half, verbose=False, reorthogonalize=False, + optimized=True) + + _assert_bit_exact(ref, resumed, "optimized disk restart") + + +def test_optimized_rejects_reorthogonalization(): + """Silently reorthogonalizing against a truncated basis would be wrong.""" + lanc = _make_plain() + with pytest.raises(ValueError, match="reorthogonalize"): + lanc.run_FT(4, verbose=False, optimized=True, reorthogonalize=True) + + +def test_optimized_rejects_partial_reorthogonalization(): + """n_rep_orth reaches further back than the retained window.""" + lanc = _make_plain() + with pytest.raises(ValueError, match="n_rep_orth"): + lanc.run_FT(4, verbose=False, optimized=True, reorthogonalize=False, + n_rep_orth=1, n_ortho=10) + + +def test_reorthogonalize_refuses_to_continue_a_truncated_basis(): + """Switching to full reorthogonalization after truncation must not pass.""" + lanc = _make_plain() + lanc.run_FT(6, verbose=False, reorthogonalize=False, optimized=True) + with pytest.raises(ValueError, match="truncated"): + lanc.run_FT(2, verbose=False, reorthogonalize=True) diff --git a/tests/test_qspace/test_qspace_kpm.py b/tests/test_qspace/test_qspace_kpm.py deleted file mode 100644 index c4d7720a..00000000 --- a/tests/test_qspace/test_qspace_kpm.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -Regression test for Q-space KPM spectral function. - -Compares the KPM spectral function against the continued-fraction result -for a Gamma-point optical perturbation on the standard q-space benchmark. -""" -from __future__ import print_function - -import numpy as np -import pytest - -import cellconstructor as CC -import cellconstructor.Methods -import cellconstructor.Phonons - -import sscha, sscha.Ensemble -import tdscha.QSpaceKPM as QK - -from tdscha.Parallel import pprint as print - -from test_qspace_lanczos import DATA_DIR, NQIRR, _setup_qspace_lanczos - - -N_MOMENTS = 256 -N_MOMENTS_HARMONIC = 256 -PEAK_RTOL = 0.08 - - -def _find_high_gamma_mode_mapping(): - dyn = CC.Phonons.Phonons("{}/dyn_gen_pop1_".format(DATA_DIR), NQIRR) - ws_sc, pols_sc, w_q, pols_q = dyn.DiagonalizeSupercell(return_qmodes=True) - - super_structure = dyn.structure.generate_supercell(dyn.GetSupercell()) - m = super_structure.get_masses_array() - trans_mask = CC.Methods.get_translations(pols_sc, m) - good_ws = ws_sc[~trans_mask] - orig_indices = np.where(~trans_mask)[0] - - n_cell = np.prod(dyn.GetSupercell()) - nat_uc = dyn.structure.N_atoms - nat_sc = super_structure.N_atoms - itau = super_structure.get_itau(dyn.structure) - 1 - - band_index = np.where(w_q[:, 0] > 1e-6)[0][-1] - target_freq = w_q[band_index, 0] - mode_index = np.argmin(np.abs(good_ws - target_freq)) - orig_mode = orig_indices[mode_index] - pol_sc_mode = pols_sc[:, orig_mode] - - pol_gamma = np.zeros(3 * nat_uc) - for i_sc in range(nat_sc): - i_uc = itau[i_sc] - pol_gamma[3 * i_uc:3 * i_uc + 3] += pol_sc_mode[3 * i_sc:3 * i_sc + 3] - pol_gamma /= np.sqrt(n_cell) - - R1 = np.conj(pols_q[:, :, 0]).T @ pol_gamma - band_index = np.argmax(np.abs(R1)) - return mode_index, band_index, w_q, pols_q - - -def _setup_qspace_kpm(iq, band_index, ignore_v3=False, ignore_v4=False): - from test_qspace_lanczos import DATA_DIR, T, NQIRR - - dyn = CC.Phonons.Phonons("{}/dyn_gen_pop1_".format(DATA_DIR), NQIRR) - ens = sscha.Ensemble.Ensemble(dyn, T) - ens.load_bin(DATA_DIR, 1) - - kpm = QK.QSpaceKPM(ens, lo_to_split=None) - kpm.ignore_harmonic = False - kpm.ignore_v3 = ignore_v3 - kpm.ignore_v4 = ignore_v4 - kpm.init(use_symmetries=True) - kpm.prepare_mode_q(iq, band_index) - return kpm - - -@pytest.mark.skip(reason="KPM peak vs continued fraction: pre-existing 28% mismatch (PEAK_RTOL=0.08)") -def test_qspace_kpm_physics_regression(verbose=False): - mode_index, band_index, w_q, pols_q = _find_high_gamma_mode_mapping() - - if verbose: - print() - print("=== Q-space KPM physics regression ===") - print("Testing Gamma band {} (freq {:.2f} cm-1)".format( - band_index, w_q[band_index, 0] * CC.Units.RY_TO_CM)) - - # Setup and run the Lanczos file - lanc_cf = _setup_qspace_lanczos(0, band_index) - w_mode = w_q[band_index, 0] - - kpm = _setup_qspace_kpm(0, band_index) - kpm.run_KPM(N_MOMENTS, verbose=False) - - w_min = max(0.0, 0.3 * w_mode) - w_max = 1.4 * w_mode - w_array = np.linspace(w_min, w_max, 81) - - cf_smearing = 0.10 * w_mode - spectral_cf = -np.imag( - lanc_cf.get_green_function_continued_fraction( - w_array, use_terminator=False, smearing=cf_smearing)) - spectral_kpm = kpm.get_spectral_function_KPM(w_array, regularization="jackson") - - peak_cf = w_array[np.argmax(spectral_cf)] - peak_kpm = w_array[np.argmax(spectral_kpm)] - rel_peak = abs(peak_kpm - peak_cf) / peak_cf - - if verbose: - print("Peak CF: {:.6f} cm-1".format(peak_cf * CC.Units.RY_TO_CM)) - print("Peak KPM: {:.6f} cm-1".format(peak_kpm * CC.Units.RY_TO_CM)) - print("Relative peak diff: {:.6e}".format(rel_peak)) - import matplotlib.pyplot as plt - plt.plot(w_array * CC.Units.RY_TO_CM, spectral_cf, label="Continued fraction") - plt.plot(w_array * CC.Units.RY_TO_CM, spectral_kpm, label="KPM") - plt.axvline(w_mode * CC.Units.RY_TO_CM, color="C0", linestyle="--", label="CF peak") - plt.legend() - plt.show() - - assert rel_peak < PEAK_RTOL, ( - "KPM peak differs too much from continued fraction: {:.4e}".format(rel_peak)) - - -def test_qspace_kpm_harmonic_peak_exact(verbose=False): - mode_index, band_index, w_q, pols_q = _find_high_gamma_mode_mapping() - w_mode = w_q[band_index, 0] - - if verbose: - print() - print("=== Q-space KPM exact harmonic peak ===") - print("Testing Gamma band {} (harmonic freq {:.2f} cm-1)".format( - band_index, w_mode * CC.Units.RY_TO_CM)) - - kpm = _setup_qspace_kpm(0, band_index, ignore_v3=True, ignore_v4=True) - kpm.run_KPM(N_MOMENTS_HARMONIC, bound_factor=1.0, verbose=False) - - dw = 0.002 * w_mode - w_array = w_mode + dw * np.arange(-100, 101) - spectral_kpm = kpm.get_spectral_function_KPM(w_array, regularization="jackson") - i_peak = np.argmax(spectral_kpm) - - if verbose: - print("Harmonic peak: {:.10e} Ry".format(w_mode)) - print("KPM peak: {:.10e} Ry".format(w_array[i_peak])) - - assert i_peak == len(w_array) // 2, "KPM harmonic peak is not exactly at the harmonic frequency" - - -def test_qspace_kpm_save_restore_continuation(tmp_path): - """Test that save_status/load_status enables exact continuation of KPM.""" - import os - mode_index, band_index, w_q, pols_q = _find_high_gamma_mode_mapping() - - # Reference: run 10 moments in one shot - kpm_ref = _setup_qspace_kpm(0, band_index) - kpm_ref.run_KPM(10, verbose=False) - moments_ref = kpm_ref.kpm_moments.copy() - - # Split run: 5 moments, save, restore on fresh object, continue to 10 - kpm_a = _setup_qspace_kpm(0, band_index) - kpm_a.run_KPM(5, verbose=False) - save_file = os.path.join(str(tmp_path), "kpm_checkpoint") - kpm_a.save_status(save_file) - - kpm_b = _setup_qspace_kpm(0, band_index) - kpm_b.load_status(save_file) - kpm_b.run_KPM(10, verbose=False) - - # Moments must match exactly (deterministic, no floating-point reordering) - np.testing.assert_array_equal( - kpm_b.kpm_moments, moments_ref, - err_msg="Continued KPM moments differ from one-shot reference") - - # Spectral functions must also match - w_mode = w_q[band_index, 0] - w_array = np.linspace(0.3 * w_mode, 1.4 * w_mode, 81) - spec_ref = kpm_ref.get_spectral_function_KPM(w_array) - spec_cont = kpm_b.get_spectral_function_KPM(w_array) - np.testing.assert_array_equal( - spec_cont, spec_ref, - err_msg="Continued KPM spectral function differs from one-shot reference") - - -if __name__ == "__main__": - test_qspace_kpm_physics_regression(verbose=True) - test_qspace_kpm_harmonic_peak_exact(verbose=True) diff --git a/tests/test_qspace/test_raman_ir_invariants.py b/tests/test_qspace/test_raman_ir_invariants.py new file mode 100644 index 00000000..18afb0e8 --- /dev/null +++ b/tests/test_qspace/test_raman_ir_invariants.py @@ -0,0 +1,289 @@ +"""Fast regression tests for Raman invariants and IR backend scaling.""" + +import importlib + +import numpy as np +import pytest + +import tdscha.DynamicalLanczos as DL +import tdscha.QSpaceLanczos as QL + + +N_CELL = 4 +N_ATOMS = 2 +N_CART = 3 * N_ATOMS +MASSES = np.array([1.0, 4.0]) + + +class _FakeDyn: + def __init__(self): + self.raman_tensor = np.zeros((3, 3, N_CART)) + components = { + (0, 0): [1.0, -2.0, 3.0, 4.0, -5.0, 6.0], + (1, 1): [-3.0, 5.0, 2.0, -1.0, 7.0, 4.0], + (2, 2): [8.0, 1.0, -4.0, 3.0, 2.0, -6.0], + (0, 1): [2.0, 3.0, -1.0, 5.0, -2.0, 7.0], + (0, 2): [-5.0, 4.0, 6.0, 2.0, 1.0, -3.0], + (1, 2): [7.0, -6.0, 5.0, -4.0, 3.0, -2.0], + } + for (i, j), values in components.items(): + self.raman_tensor[i, j] = values + self.raman_tensor[j, i] = values + + self.effective_charges = np.array([ + [[1.0, 2.0, -1.0], [3.0, -2.0, 4.0], [5.0, 1.0, 2.0]], + [[-2.0, 1.0, 3.0], [4.0, 2.0, -3.0], [1.0, -5.0, 6.0]], + ]) + + def GetRamanVector(self, pol_in, pol_out): + return np.einsum( + "i,j,ijk->k", pol_in, pol_out, self.raman_tensor) + + @staticmethod + def GetSupercell(): + return np.array([2, 1, 2]) + + +class _FakeStructure: + @staticmethod + def get_masses_array(): + return MASSES.copy() + + +class _RealHarness(DL.Lanczos): + def __init__(self, dyn): + self.dyn = dyn + masses_uc = np.repeat(MASSES, 3) + self.m = np.tile(masses_uc, N_CELL) + self.n_modes = N_CART + gamma = np.ones(N_CELL) / np.sqrt(N_CELL) + self.pols = np.kron(gamma[:, None], np.eye(N_CART)) + self.psi = np.zeros(self.n_modes) + self.symmetrize = False + self.ignore_small_w = True + + def reset(self): + pass + + +class _QSpaceHarness(QL.QSpaceLanczos): + def __init__(self, dyn): + self.dyn = dyn + self.uci_structure = _FakeStructure() + self.n_bands = N_CART + self.pols_q = np.eye(N_CART, dtype=np.complex128)[:, :, None] + self.psi = np.zeros(self.n_bands, dtype=np.complex128) + + def build_q_pair_map(self, iq): + assert iq == 0 + + def reset_q(self): + self.psi = np.zeros(self.n_bands, dtype=np.complex128) + + +@pytest.fixture +def dyn(): + return _FakeDyn() + + +def _raw_invariants(tensor): + return [ + tensor[0, 0] + tensor[1, 1] + tensor[2, 2], + tensor[0, 0] - tensor[1, 1], + tensor[0, 0] - tensor[2, 2], + tensor[1, 1] - tensor[2, 2], + tensor[0, 1], + tensor[0, 2], + tensor[1, 2], + ] + + +def _projected_unit_cell(vector): + masses = np.repeat(MASSES, 3) + return np.sqrt(N_CELL) * vector / np.sqrt(masses) + + +def test_shared_builder_returns_all_seven_invariants(dyn): + lanc = _RealHarness(dyn) + raw = _raw_invariants(dyn.raman_tensor) + scales = [1 / 3] + [1 / np.sqrt(2)] * 3 + [np.sqrt(3)] * 3 + + for index, expected_raw in enumerate(raw): + np.testing.assert_allclose( + lanc._build_raman_vector( + unpolarized=index, normalized=False), + expected_raw) + np.testing.assert_allclose( + lanc._build_raman_vector( + unpolarized=index, normalized=True), + expected_raw * scales[index]) + + +@pytest.mark.parametrize("backend", [_RealHarness, _QSpaceHarness]) +def test_polarized_and_coherently_mixed_raman(backend, dyn): + lanc = backend(dyn) + pol_in = np.array([0.5, -1.0, 2.0]) + pol_out = np.array([1.5, 0.25, -0.75]) + pol_in_2 = np.array([-0.5, 2.0, 1.0]) + pol_out_2 = np.array([1.0, -1.5, 0.5]) + + direct = dyn.GetRamanVector(pol_in, pol_out) + lanc.prepare_raman(pol_vec_in=pol_in, pol_vec_out=pol_out) + np.testing.assert_allclose(lanc.psi[:N_CART], _projected_unit_cell(direct)) + np.testing.assert_allclose( + lanc.perturbation_modulus, + np.vdot(_projected_unit_cell(direct), + _projected_unit_cell(direct)).real) + + direct_mixed = direct + dyn.GetRamanVector(pol_in_2, pol_out_2) + lanc.prepare_raman( + pol_vec_in=pol_in, pol_vec_out=pol_out, mixed=True, + pol_in_2=pol_in_2, pol_out_2=pol_out_2) + np.testing.assert_allclose( + lanc.psi[:N_CART], _projected_unit_cell(direct_mixed)) + np.testing.assert_allclose( + lanc.perturbation_modulus, + np.vdot(_projected_unit_cell(direct_mixed), + _projected_unit_cell(direct_mixed)).real) + + +@pytest.mark.parametrize("backend", [_RealHarness, _QSpaceHarness]) +def test_both_unpolarized_apis_have_equivalent_weighted_intensities( + backend, dyn): + normalized_weights = [45] + [7] * 6 + lanc = backend(dyn) + + for index, normalized_weight in enumerate(normalized_weights): + lanc.prepare_raman(unpolarized=index) + normalized_intensity = ( + normalized_weight * lanc.perturbation_modulus) + + lanc.prepare_unpolarized_raman(index=index) + raw_intensity = ( + lanc.get_prefactors_unpolarized_raman(index) + * lanc.perturbation_modulus) + + np.testing.assert_allclose(normalized_intensity, raw_intensity) + + +def test_real_and_qspace_raman_vectors_and_moduli_match(dyn): + real = _RealHarness(dyn) + qspace = _QSpaceHarness(dyn) + cases = [ + {"pol_vec_in": np.array([0.5, -1.0, 2.0]), + "pol_vec_out": np.array([1.5, 0.25, -0.75])}, + {"pol_vec_in": np.array([1.0, 0.0, 0.0]), + "pol_vec_out": np.array([0.0, 1.0, 0.0]), + "mixed": True, + "pol_in_2": np.array([0.0, 0.0, 1.0]), + "pol_out_2": np.array([0.0, 1.0, 0.0])}, + ] + cases.extend({"unpolarized": index} for index in range(7)) + + for kwargs in cases: + real.prepare_raman(**kwargs) + qspace.prepare_raman(**kwargs) + np.testing.assert_allclose( + qspace.psi[:N_CART], real.psi[:N_CART]) + np.testing.assert_allclose( + qspace.perturbation_modulus, real.perturbation_modulus) + + +def test_generic_add_uses_complete_accumulated_perturbation(dyn): + vector_1 = np.arange(1, N_CART + 1, dtype=float) + vector_2 = np.array([-2.0, 1.0, 4.0, -3.0, 5.0, 2.0]) + + real = _RealHarness(dyn) + vector_1_sc = np.tile(vector_1, N_CELL) + vector_2_sc = np.tile(vector_2, N_CELL) + real.prepare_perturbation(vector_1_sc, masses_exp=-1) + real.prepare_perturbation(vector_2_sc, masses_exp=-1, add=True) + expected = _projected_unit_cell(vector_1 + vector_2) + np.testing.assert_allclose(real.psi[:N_CART], expected) + np.testing.assert_allclose(real.perturbation_modulus, expected @ expected) + + qspace = _QSpaceHarness(dyn) + qspace.prepare_perturbation_q(0, vector_1 * np.sqrt(N_CELL)) + qspace.prepare_perturbation_q( + 0, vector_2 * np.sqrt(N_CELL), add=True) + np.testing.assert_allclose(qspace.psi[:N_CART], expected) + np.testing.assert_allclose( + qspace.perturbation_modulus, np.vdot(expected, expected).real) + + +def test_ir_real_qspace_parity_and_powder_average(dyn): + real = _RealHarness(dyn) + qspace = _QSpaceHarness(dyn) + moduli = [] + + for pol_vec in np.eye(3): + direct = np.einsum( + "abc,b->ac", dyn.effective_charges, pol_vec).ravel() + expected = _projected_unit_cell(direct) + + real.prepare_ir(pol_vec=pol_vec) + qspace.prepare_ir(pol_vec=pol_vec) + np.testing.assert_allclose(real.psi[:N_CART], expected) + np.testing.assert_allclose(qspace.psi[:N_CART], expected) + np.testing.assert_allclose( + qspace.perturbation_modulus, real.perturbation_modulus) + moduli.append(real.perturbation_modulus) + + powder_average = sum(moduli) / 3 + direct_powder_average = sum( + np.vdot( + _projected_unit_cell( + np.einsum( + "abc,b->ac", dyn.effective_charges, pol_vec).ravel()), + _projected_unit_cell( + np.einsum( + "abc,b->ac", dyn.effective_charges, pol_vec).ravel()) + ).real + for pol_vec in np.eye(3) + ) / 3 + np.testing.assert_allclose(powder_average, direct_powder_average) + + +def test_atom_fourier_lanczos_inherits_qspace_raman_implementation(): + try: + module = importlib.import_module("tdscha.QSpaceAtomFourier") + except ImportError: + pytest.skip("QSpaceAtomFourier is only present on the 1.8 branch") + + cls = module.QSpaceAtomFourierLanczos + assert issubclass(cls, QL.QSpaceLanczos) + assert "prepare_raman" not in cls.__dict__ + assert "prepare_unpolarized_raman" not in cls.__dict__ + assert "prepare_perturbation_q" not in cls.__dict__ + + +def test_qspace_uses_only_backend_hook_for_optical_perturbations(): + assert "prepare_ir" not in QL.QSpaceLanczos.__dict__ + assert "prepare_raman" not in QL.QSpaceLanczos.__dict__ + assert "prepare_unpolarized_raman" not in QL.QSpaceLanczos.__dict__ + assert "_prepare_gamma_cartesian_perturbation" in ( + QL.QSpaceLanczos.__dict__) + + +@pytest.mark.parametrize("backend", [_RealHarness, _QSpaceHarness]) +@pytest.mark.parametrize("method_name", [ + "prepare_unpolarized_raman_FT", + "prepare_anharmonic_raman_FT", + "prepare_anharmonic_raman_FT_2ph", +]) +def test_unvalidated_two_phonon_raman_is_disabled(backend, method_name, dyn): + lanczos = backend(dyn) + with pytest.raises(NotImplementedError, match="Two-phonon Raman"): + getattr(lanczos, method_name)() + + +@pytest.mark.parametrize("backend", [_RealHarness, _QSpaceHarness]) +@pytest.mark.parametrize("method_name", [ + "prepare_anharmonic_ir_FT", + "prepare_anharmonic_ir", +]) +def test_unvalidated_configuration_dependent_ir_is_disabled( + backend, method_name, dyn): + lanczos = backend(dyn) + with pytest.raises(NotImplementedError, match="Configuration-dependent"): + getattr(lanczos, method_name)() diff --git a/tests/test_qspace/test_restart.py b/tests/test_qspace/test_restart.py new file mode 100644 index 00000000..b1740aca --- /dev/null +++ b/tests/test_qspace/test_restart.py @@ -0,0 +1,142 @@ +"""Restart correctness for the q-space Lanczos. + +A long production run (e.g. the CsSnI3 8^3 convergence study at 400+ Lanczos +steps) must be resumable: if 400 steps turn out to be too few, the run has to +continue from a checkpoint rather than start over. Restart is implemented by +``DynamicalLanczos.save_status`` / ``load_status`` (full Krylov state) plus the +``i_step = len(self.a_coeffs)`` continuation branch of ``run_FT``. + +These tests pin that behaviour to be **bit-exact**: a chain of shorter runs +with a save/load in between must reproduce, coefficient for coefficient, the +single-shot run of the same total length. They cover the exact production +configuration (``reorthogonalize=False``, the default) for both the plain +``QSpaceLanczos`` and the interpolating ``QSpaceAtomFourierLanczos``. +""" +from __future__ import print_function + +import os +import sys + +import numpy as np +import pytest + +# _toy_chain lives with the interpolation tests. +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "test_interpolation")) + +import cellconstructor as CC +import cellconstructor.Phonons +import sscha +import sscha.Ensemble + +try: + import tdscha.QSpaceLanczos as QL + import tdscha.QSpaceAtomFourier as AF + _HAS_Q = QL.__JULIA_EXT__ +except Exception: + _HAS_Q = False + +pytestmark = pytest.mark.skipif(not _HAS_Q, + reason="QSpaceLanczos/Julia not available") + +DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "test_julia", "data") +NQIRR = 3 +T = 250 + + +def _assert_bit_exact(ref, other, msg): + for name in ("a_coeffs", "b_coeffs", "c_coeffs"): + r = np.asarray(getattr(ref, name), dtype=float) + o = np.asarray(getattr(other, name), dtype=float) + assert r.shape == o.shape, \ + "%s: %s length %s != %s" % (msg, name, r.shape, o.shape) + if r.size: + d = np.max(np.abs(r - o)) + scale = max(np.max(np.abs(r)), 1e-30) + assert d <= 1e-10 * scale, \ + "%s: %s differs by %.3e (rel %.3e)" % (msg, name, d, d / scale) + + +def _make_plain(): + dyn = CC.Phonons.Phonons(os.path.join(DATA_DIR, "dyn_gen_pop1_"), NQIRR) + ens = sscha.Ensemble.Ensemble(dyn, T) + ens.load_bin(DATA_DIR, 1) + q = QL.QSpaceLanczos(ens, lo_to_split=None) + q.ignore_harmonic = False + q.ignore_v3 = False + q.ignore_v4 = False + q.init(use_symmetries=True) + q.prepare_mode_q(0, 3) + return q + + +def _make_tri(): + import _toy_chain as TC + dync = TC.build_dyn(3) + ensc = TC.make_ensemble(dync, 300.0, 3000, seed=11, g3=0.1) + li = AF.QSpaceAtomFourierLanczos(ensc, fine_mesh=(1, 1, 6)) + li.init(use_symmetries=True) + li.prepare_mode_q(0, 5) + return li + + +@pytest.mark.parametrize("maker,total", [(_make_plain, 10), (_make_tri, 12)]) +def test_in_memory_restart_is_bit_exact(maker, total): + """Calling run_FT twice on the same object continues the recursion exactly.""" + ref = maker() + ref.run_FT(total, verbose=False) + + split = maker() + split.run_FT(total // 2, verbose=False) + split.run_FT(total - total // 2, verbose=False) + + _assert_bit_exact(ref, split, "in-memory restart") + + +@pytest.mark.parametrize("maker,total", [(_make_plain, 10), (_make_tri, 12)]) +def test_disk_restart_is_bit_exact(maker, total, tmp_path): + """save_status -> fresh object + init + load_status -> continue == single shot. + + This is the production resume path: the object is rebuilt from scratch + (kernel included, for the atom-Fourier class) and only the Krylov state is + reloaded from disk. + """ + ref = maker() + ref.run_FT(total, verbose=False) + + ckpt = str(tmp_path / "ckpt") + first = maker() + first.run_FT(total // 2, verbose=False) + first.save_status(ckpt) + del first + + resumed = maker() # identical construction + init + perturbation + resumed.load_status(ckpt) + resumed.run_FT(total - total // 2, verbose=False) + + _assert_bit_exact(ref, resumed, "disk restart") + + +def test_multi_chunk_disk_restart(tmp_path): + """Three chunks with a save/load at every boundary still match one shot. + + Guards against any state that survives one restart but drifts across + repeated ones ("more than 400 steps needed" may chain several restarts). + """ + total = 12 + ref = _make_plain() + ref.run_FT(total, verbose=False) + + ckpt = str(tmp_path / "chain") + lanc = _make_plain() + done = 0 + for chunk in (4, 4, 4): + lanc.run_FT(chunk, verbose=False) + lanc.save_status(ckpt) + done += chunk + reloaded = _make_plain() + reloaded.load_status(ckpt) + lanc = reloaded + assert done == total + _assert_bit_exact(ref, lanc, "multi-chunk disk restart") diff --git a/tests/test_spectroscopy/_distributed_spectroscopy_probe.py b/tests/test_spectroscopy/_distributed_spectroscopy_probe.py new file mode 100644 index 00000000..fe6b8dcc --- /dev/null +++ b/tests/test_spectroscopy/_distributed_spectroscopy_probe.py @@ -0,0 +1,104 @@ +"""Worker for test_distributed_spectroscopy.py -- run under mpirun. + +Usage: python _distributed_spectroscopy_probe.py + +Drives the public ``Spectroscopy`` API from an ``EnsembleSource`` and writes, +per rank, the assembled responses together with what the engine actually held. +The test compares one rank against several and checks that the several never +replicated the configurations. + +A separate process per case because every matrix-vector product is a +collective: a rank stepping an engine the others do not have would hang them. +""" +from __future__ import print_function + +import os +import sys + +import numpy as np + +import cellconstructor.Settings as Parallel + +import tdscha.Spectroscopy as SP +from tdscha import _SpectroscopyWorkflow as workflow + +T = 250.0 +NQIRR = 3 +POP = 1 +FINE = (2, 2, 4) # multiple of the 2x2x2 coarse supercell +NSTEPS = 4 +FREQUENCIES = np.linspace(1e-4, 5e-3, 20) +ANALYSIS = dict(smearing=2e-4, use_terminator=False) + + +def build(backend, data_dir, workdir): + source = SP.EnsembleSource( + data_dir, POP, os.path.join(data_dir, "dyn_gen_pop%d_" % POP), T, + nqirr=NQIRR) + options = {"fine_mesh": FINE} if backend == "atom_fourier" else {} + job = SP.Spectroscopy(source, backend=backend, workdir=workdir, + use_symmetries=True, backend_options=options) + n_atoms = source.reference_dyn.structure.N_atoms + # The three Cartesian directions are one symmetry orbit in this cubic + # cell, so they collapse onto a single Lanczos run; displacing a second + # atom adds an independent one. Two runs exercise engine reuse. + for axis in range(3): + vector = np.zeros((n_atoms, 3)) + vector[0, axis] = 1.0 + job.add_ir_vector(vector.ravel(), "axis_%d" % axis) + second = np.zeros((n_atoms, 3)) + second[min(1, n_atoms - 1), 0] = 1.0 + second[0, 1] = 0.5 + job.add_ir_vector(second.ravel(), "mixed") + return job + + +def main(): + backend, data_dir, workdir = sys.argv[1], sys.argv[2], sys.argv[3] + + engines = [] + genuine_create_backend = workflow.create_backend + + def spy(*args, **kwargs): + engine = genuine_create_backend(*args, **kwargs) + engines.append(engine) + return engine + + workflow.create_backend = spy + try: + job = build(backend, data_dir, workdir) + plan = job.plan_calculations() + job.run(NSTEPS, save_each=2, verbose=False) + + # A second, fully satisfied run must not build another engine: the + # results are restored from the checkpoint and nothing is reloaded. + n_after_first = len(engines) + restored = build(backend, data_dir, workdir) + restored.run(NSTEPS, save_each=2, verbose=False) + finally: + workflow.create_backend = genuine_create_backend + + responses = {name: job.response(name, FREQUENCIES, **ANALYSIS) + for name in ("axis_0", "axis_1", "axis_2", "mixed")} + restored_responses = { + name: restored.response(name, FREQUENCIES, **ANALYSIS) + for name in responses} + + engine = engines[0] + np.savez( + os.path.join(workdir, "rank_%d.npz" % Parallel.get_rank()), + n_independent_runs=plan["n_independent_runs"], + n_engines=n_after_first, + n_engines_total=len(engines), + distributed=bool(getattr(engine, "_distributed", False)), + n_local=int(engine.N), + n_global=int(getattr(engine, "_N_global", engine.N)), + cls=type(engine).__name__, + **responses) + np.savez(os.path.join(workdir, "restored_%d.npz" % Parallel.get_rank()), + **restored_responses) + print("%s rank %d done" % (backend, Parallel.get_rank())) + + +if __name__ == "__main__": + main() diff --git a/tests/test_spectroscopy/test_distributed_spectroscopy.py b/tests/test_spectroscopy/test_distributed_spectroscopy.py new file mode 100644 index 00000000..c2b1266b --- /dev/null +++ b/tests/test_spectroscopy/test_distributed_spectroscopy.py @@ -0,0 +1,115 @@ +"""``Spectroscopy`` must distribute the ensemble, and get the same answer. + +The driver takes an ``EnsembleSource`` -- where the ensemble lives on disk -- +and routes the q-space backends through the distributed loaders, so the +configurations are read once by the master and scattered. Two properties have +to hold together, and neither is worth much alone: + +* no rank holds a replica (that is the point of the change), and +* the spectrum is the one a single, undistributed process computes. + +The interpolated backend additionally has to survive the collective inside its +own construction (CellConstructor's ``ForceTensor`` broadcasts while imposing +the ASR). That failure mode is a hang, so the cases carry a timeout and treat +expiry as a failure. +""" +from __future__ import print_function + +import os +import subprocess +import sys + +import numpy as np +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, "..", "..")) +PROBE = os.path.join(HERE, "_distributed_spectroscopy_probe.py") +DATA = os.path.join(REPO, "tests", "test_julia", "data") +REQUESTS = ("axis_0", "axis_1", "axis_2", "mixed") +TIMEOUT = 900 + +pytest.importorskip("mpi4py") +if not os.path.isdir(DATA): + pytest.skip("q-space test ensemble not available", allow_module_level=True) + + +def _run(launcher, backend, workdir, n_ranks): + os.makedirs(workdir, exist_ok=True) + cmd = [launcher, "-np", str(n_ranks), sys.executable, PROBE, + backend, DATA, workdir] + env = dict(os.environ, OMP_NUM_THREADS="1") + try: + proc = subprocess.run(cmd, cwd=REPO, env=env, timeout=TIMEOUT, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + except subprocess.TimeoutExpired: + pytest.fail( + "%s on %d ranks did not finish in %ds -- most likely a deadlock " + "between mismatched MPI collectives." + % (backend, n_ranks, TIMEOUT)) + assert proc.returncode == 0, \ + "%s on %d ranks failed (rc=%d):\n%s" % ( + backend, n_ranks, proc.returncode, proc.stdout.decode()[-4000:]) + return [np.load(os.path.join(workdir, "rank_%d.npz" % rank)) + for rank in range(n_ranks)] + + +@pytest.mark.parametrize("backend", ["qspace", "atom_fourier"]) +def test_spectroscopy_distributes_and_reproduces_the_spectrum( + backend, tmp_path, multi_rank_mpirun): + single = _run(multi_rank_mpirun, backend, + str(tmp_path / "single"), 1)[0] + ranks = _run(multi_rank_mpirun, backend, + str(tmp_path / "parallel"), 2) + + # The symmetry planner is not affected by the distribution: the three + # Cartesian directions are one orbit, the mixed vector another. + assert int(single["n_independent_runs"]) == 2 + # One engine for both runs, and none at all once everything is restored. + assert int(single["n_engines"]) == 1 + assert int(single["n_engines_total"]) == 1 + + for rank, data in enumerate(ranks): + assert int(data["n_independent_runs"]) == 2 + assert int(data["n_engines"]) == 1 + assert int(data["n_engines_total"]) == 1 + assert bool(data["distributed"]) is True, \ + "rank %d did not take the distributed path" % rank + assert int(data["n_global"]) == int(single["n_global"]) + assert int(data["n_local"]) < int(data["n_global"]), \ + "rank %d holds a full replica of the configurations" % rank + assert str(data["cls"]) == str(single["cls"]) + + assert (sum(int(data["n_local"]) for data in ranks) + == int(single["n_global"])), \ + "the configurations must be partitioned, not shared or dropped" + + for name in REQUESTS: + reference = single[name] + scale = max(float(np.max(np.abs(reference))), 1e-30) + for rank, data in enumerate(ranks): + # Not bit-exact: the distributed reduction sums the per-rank + # partials in a different order than the replicated loop. + difference = float(np.max(np.abs(reference - data[name]))) + assert difference / scale < 1e-10, \ + "%s on rank %d differs by %.3e (rel %.3e)" % ( + name, rank, difference, difference / scale) + + # The three symmetry-equivalent directions must assemble to one spectrum. + for name in ("axis_1", "axis_2"): + np.testing.assert_allclose(single[name], single["axis_0"], + rtol=1e-10, atol=0) + + +@pytest.mark.parametrize("backend", ["qspace", "atom_fourier"]) +def test_restarted_analysis_matches_the_run_that_produced_it( + backend, tmp_path, multi_rank_mpirun): + workdir = str(tmp_path / "restart") + _run(multi_rank_mpirun, backend, workdir, 2) + for rank in range(2): + produced = np.load(os.path.join(workdir, "rank_%d.npz" % rank)) + restored = np.load(os.path.join(workdir, "restored_%d.npz" % rank)) + for name in REQUESTS: + np.testing.assert_allclose(restored[name], produced[name], + rtol=1e-12, atol=0) diff --git a/tests/test_spectroscopy/test_ensemble_source.py b/tests/test_spectroscopy/test_ensemble_source.py new file mode 100644 index 00000000..2b47c587 --- /dev/null +++ b/tests/test_spectroscopy/test_ensemble_source.py @@ -0,0 +1,170 @@ +"""``EnsembleSource`` validation and backend routing. + +These are the cheap, serial guarantees around the distributed loading. That +the loading itself is correct is checked under ``mpirun`` by +``test_distributed_spectroscopy.py``. +""" + +import os + +import numpy as np +import pytest + +import cellconstructor as CC + +import tdscha.Spectroscopy as SP +from tdscha import _SpectroscopyWorkflow as workflow + + +class _Dyn: + def __init__(self): + structure = CC.Structure.Structure(1) + structure.unit_cell = np.eye(3) * 5.0 + structure.coords[0] = np.zeros(3) + structure.atoms = ["X"] + structure.masses = {"X": 1.0} + structure.has_unit_cell = True + self.structure = structure + self.dynmats = [np.eye(3)] + self.effective_charges = np.eye(3)[None, :, :] + self.dielectric_tensor = np.eye(3) * 2.5 + + @staticmethod + def GetSupercell(): + return np.ones(3, dtype=int) + + +def _source(tmp_path, **overrides): + arguments = dict(data_dir=str(tmp_path), population=1, dyn=_Dyn(), + T=250.0) + arguments.update(overrides) + return SP.EnsembleSource(**arguments) + + +def test_missing_directory_is_rejected_at_construction(tmp_path): + with pytest.raises(ValueError, match="does not exist"): + _source(tmp_path / "absent") + + +def test_a_path_needs_the_number_of_irreducible_q_points(tmp_path): + with pytest.raises(ValueError, match="irreducible q-points"): + _source(tmp_path, dyn=str(tmp_path / "dyn_")) + with pytest.raises(ValueError, match="must not be given"): + _source(tmp_path, nqirr=3) + + +def test_the_reference_is_the_converged_matrix_when_reweighting(tmp_path): + generating, converged = _Dyn(), _Dyn() + plain = _source(tmp_path, dyn=generating) + assert plain.reference_dyn is generating + assert plain.reference_temperature == 250.0 + assert plain.converged_dyn is None + + reweighted = _source(tmp_path, dyn=generating, final_dyn=converged, + final_T=100.0) + assert reweighted.reference_dyn is converged + assert reweighted.reference_temperature == 100.0 + + # Without an explicit final temperature the ensemble keeps T. + assert _source(tmp_path, dyn=generating, + final_dyn=converged).reference_temperature == 250.0 + + +def test_reweighting_arguments_require_a_target(tmp_path): + with pytest.raises(ValueError, match="final_T was given"): + _source(tmp_path, final_T=10.0) + with pytest.raises(ValueError, match="final_nqirr was given"): + _source(tmp_path, final_nqirr=2) + + +def test_identity_separates_populations_but_survives_a_move(tmp_path): + first = (tmp_path / "ensemble").resolve() + first.mkdir() + moved = (tmp_path / "elsewhere" / "ensemble").resolve() + moved.mkdir(parents=True) + + dyn = _Dyn() + here = SP.EnsembleSource(str(first), 1, dyn, 250.0) + there = SP.EnsembleSource(str(moved), 1, dyn, 250.0) + other_population = SP.EnsembleSource(str(first), 2, dyn, 250.0) + fewer = SP.EnsembleSource(str(first), 1, dyn, 250.0, n_configs=10) + + assert here.describe() == there.describe() + assert here.describe() != other_population.describe() + assert here.describe() != fewer.describe() + # The paths are still recorded, they just do not gate a restart. + assert here.provenance()["data_dir"] != there.provenance()["data_dir"] + + +def test_the_driver_rejects_something_that_is_neither(tmp_path): + with pytest.raises(TypeError, match="EnsembleSource"): + SP.Spectroscopy(object(), workdir=tmp_path / "work") + + +def test_the_manifest_carries_the_ensemble_identity(tmp_path): + job = SP.Spectroscopy(_source(tmp_path), backend="qspace", + workdir=tmp_path / "work") + job.add_ir_polarized([1, 0, 0], "ir") + manifest = job.manifest() + assert manifest["ensemble_source"]["population"] == 1 + assert manifest["ensemble_provenance"]["data_dir"] == os.path.abspath( + str(tmp_path)) + + analysis_only = SP.Spectroscopy(None, workdir=tmp_path / "work") + assert analysis_only.manifest()["ensemble_source"] is None + + +@pytest.mark.parametrize("backend", ["qspace", "atom_fourier"]) +def test_a_source_routes_the_q_space_backends_through_the_loader( + backend, tmp_path, monkeypatch): + """The whole point: a source must never be loaded rank-locally here.""" + source = _source(tmp_path, final_dyn=_Dyn(), final_T=100.0, + n_configs=64) + monkeypatch.setattr( + SP.EnsembleSource, "load_ensemble", + lambda self: pytest.fail( + "a q-space backend must not replicate the ensemble")) + + calls = {} + + def record(name): + def loader(*args, **kwargs): + calls["name"] = name + calls["args"] = args + calls["kwargs"] = kwargs + return "engine" + return loader + + import tdscha.QSpaceLanczos as QL + import tdscha.QSpaceAtomFourier as QAF + monkeypatch.setattr(QL, "load_distributed_tdscha", record("qspace")) + monkeypatch.setattr(QAF, "load_distributed_atom_fourier_tdscha", + record("atom_fourier")) + + options = {"lo_to_split": None} + if backend == "atom_fourier": + options["fine_mesh"] = (2, 2, 2) + engine = workflow.create_backend(source, backend, options, + use_symmetries=False) + + assert engine == "engine" + assert calls["name"] == backend + assert calls["args"][:2] == (source.data_dir, source.population) + assert calls["args"][2] is source.generating_dyn + assert calls["args"][3] == 250.0 + assert calls["kwargs"]["final_dyn"] is source.converged_dyn + assert calls["kwargs"]["final_T"] == 100.0 + assert calls["kwargs"]["n_configs"] == 64 + assert calls["kwargs"]["use_symmetries"] is False + if backend == "atom_fourier": + assert calls["args"][4] == (2, 2, 2) + + +def test_the_interpolated_backend_asks_for_its_mesh(tmp_path): + with pytest.raises(ValueError, match="fine_mesh"): + workflow.create_backend(_source(tmp_path), "atom_fourier", {}) + + +def test_an_unknown_backend_is_refused_before_anything_is_loaded(tmp_path): + with pytest.raises(ValueError, match="Unsupported spectroscopy backend"): + workflow.create_backend(_source(tmp_path), "nonsense", {}) diff --git a/tests/test_spectroscopy/test_qspace_coset_3x3x3.py b/tests/test_spectroscopy/test_qspace_coset_3x3x3.py new file mode 100644 index 00000000..5d525205 --- /dev/null +++ b/tests/test_spectroscopy/test_qspace_coset_3x3x3.py @@ -0,0 +1,138 @@ +"""Direct full-group/coset parity on a cubic 3x3x3 q mesh. + +This test intentionally stops at ``apply_anharmonic_FT``. Its second +application feeds the first anharmonic image back into the operator, so the +input has a nonzero two-phonon sector and exercises the stabilizer projection +of both ``f`` and ``d2v_dr2``. Every q point is covered; on an odd mesh only +Gamma is time-reversal invariant. +""" + +import os +from pathlib import Path + +import numpy as np +import pytest + +os.environ.setdefault("JULIA_NUM_THREADS", "1") + +import cellconstructor.Phonons as Phonons +import sscha.Ensemble + +try: + import tdscha.QSpaceLanczos as QL + import tdscha.QSpaceAtomFourier as QAF + _AVAILABLE = QL.__JULIA_EXT__ +except Exception: + _AVAILABLE = False + + +pytestmark = pytest.mark.skipif( + not _AVAILABLE, reason="QSpaceLanczos/Julia not available") + +DATA = Path(__file__).parents[1] / "test_julia" / "data" +MESH = (3, 3, 3) + + +@pytest.fixture(scope="module") +def cubic_odd_mesh_ensemble(): + coarse = Phonons.Phonons(str(DATA / "dyn_gen_pop1_"), 3) + dyn = coarse.Interpolate( + coarse.GetSupercell(), MESH, symmetrize=True) + # The algebraic parity does not assume a model for Y: Julia explicitly + # replicates and rotates each X/Y configuration. A deterministic random + # force field is the sharpest small-N probe because it avoids accidental + # zeros in D3 and D4 while only four configurations keep this test cheap. + np.random.seed(731) + ensemble = sscha.Ensemble.Ensemble(dyn, 250.0) + ensemble.generate(4) + rng = np.random.default_rng(991) + ensemble.forces = rng.normal(scale=2e-3, size=ensemble.forces.shape) + n_configurations = len(ensemble.structures) + ensemble.energies = np.zeros(n_configurations) + ensemble.force_computed = np.ones(n_configurations, dtype=bool) + ensemble.init() + return ensemble + + +def _new_engine(ensemble): + engine = QL.QSpaceLanczos(ensemble, lo_to_split=None) + engine.init(use_symmetries=True) + return engine + + +def test_all_q_full_replica_matches_coset_and_inner_group( + cubic_odd_mesh_ensemble): + full = _new_engine(cubic_odd_mesh_ensemble) + reduced = _new_engine(cubic_odd_mesh_ensemble) + + assert tuple(full.dyn.GetSupercell()) == MESH + assert full.n_q == 27 + assert full.n_syms_qspace == 48 + + reciprocal = full.uci_structure.get_reciprocal_vectors() / (2 * np.pi) + tri = [] + reductions = [] + # Opposite sublattice displacement: optical at Gamma and nonzero at each + # finite q. A Cartesian vector is preferable to a band index because it + # is insensitive to arbitrary gauges within degenerate eigenspaces. + perturbation = np.array([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]) + + for iq, qpoint in enumerate(full.q_points): + # Explicitly determine TRI status modulo a reciprocal lattice vector. + import cellconstructor.Methods as Methods + tri.append(Methods.get_min_dist_into_cell( + reciprocal, qpoint, -qpoint) < 1e-7) + + full.prepare_perturbation_q(iq, perturbation) + reduced.prepare_perturbation_q(iq, perturbation) + metadata = reduced.configure_qspace_perturbation_symmetry() + reductions.append(metadata) + + first_full = full.apply_anharmonic_FT() + first_reduced = reduced.apply_anharmonic_FT() + np.testing.assert_allclose( + first_reduced, first_full, rtol=3e-9, atol=3e-11, + err_msg="first anharmonic application differs at iq={}".format(iq)) + + # The first image contains d2v_dr2 in both Wigner two-phonon sectors. + assert np.linalg.norm(first_full[full.n_bands:]) > 1e-13 + full.psi = first_full.copy() + reduced.psi = first_reduced.copy() + second_full = full.apply_anharmonic_FT() + second_reduced = reduced.apply_anharmonic_FT() + np.testing.assert_allclose( + second_reduced, second_full, rtol=3e-9, atol=3e-11, + err_msg="two-phonon input differs at iq={}".format(iq)) + + assert tri == [True] + [False] * 26 + assert reductions[0]["coset_representatives"] < 48 + assert any(item["coset_representatives"] < 48 + for item in reductions[1:]) + + +@pytest.mark.parametrize("coarse_iq", [0, 1]) +def test_atom_fourier_uses_coarse_stabilizer_for_gamma_and_nontri( + cubic_odd_mesh_ensemble, coarse_iq): + full = QAF.QSpaceAtomFourierLanczos( + cubic_odd_mesh_ensemble, fine_mesh=MESH, lo_to_split=None) + reduced = QAF.QSpaceAtomFourierLanczos( + cubic_odd_mesh_ensemble, fine_mesh=MESH, lo_to_split=None) + full.init(use_symmetries=True) + reduced.init(use_symmetries=True) + fine_iq = int(full._fine_of_coarse[coarse_iq]) + perturbation = np.array([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]) + full.prepare_perturbation_q(fine_iq, perturbation) + reduced.prepare_perturbation_q(fine_iq, perturbation) + metadata = reduced.configure_qspace_perturbation_symmetry() + + assert metadata["full_group_order"] == 48 + assert metadata["coset_representatives"] < 48 + first_full = full.apply_anharmonic_FT() + first_reduced = reduced.apply_anharmonic_FT() + np.testing.assert_allclose( + first_reduced, first_full, rtol=3e-9, atol=3e-11) + full.psi = first_full.copy() + reduced.psi = first_reduced.copy() + np.testing.assert_allclose( + reduced.apply_anharmonic_FT(), full.apply_anharmonic_FT(), + rtol=3e-9, atol=3e-11) diff --git a/tests/test_spectroscopy/test_real_backends.py b/tests/test_spectroscopy/test_real_backends.py new file mode 100644 index 00000000..5120edc4 --- /dev/null +++ b/tests/test_spectroscopy/test_real_backends.py @@ -0,0 +1,92 @@ +"""Small real-engine integration tests for the Spectroscopy driver.""" + +from pathlib import Path + +import numpy as np +import pytest + +import cellconstructor as CC +import sscha.Ensemble + +import tdscha.Spectroscopy as SP +from tdscha import _SpectroscopyWorkflow as workflow + + +DATA = Path(__file__).parents[1] / "test_julia" / "data" + + +def _ensemble(): + dyn = CC.Phonons.Phonons(str(DATA / "dyn_gen_pop1_"), 3) + ensemble = sscha.Ensemble.Ensemble(dyn, 250) + ensemble.load_bin(str(DATA), 1) + return ensemble + + +@pytest.mark.parametrize("backend", ["real", "qspace"]) +def test_driver_executes_real_engines_and_loads_results(backend, tmp_path): + ensemble = _ensemble() + charges = np.zeros((ensemble.current_dyn.structure.N_atoms, 3, 3)) + charges[0] = np.eye(3) + charges[1] = -np.eye(3) + job = SP.Spectroscopy( + ensemble, backend=backend, + workdir=tmp_path / backend, + use_symmetries=False, + ignore_v3=True, ignore_v4=True) + job.add_ir_polarized( + [1, 0, 0], "ir_x", effective_charges=charges) + job.run(2, save_each=1, verbose=False) + + loaded = SP.Spectroscopy.load(tmp_path / backend) + frequencies = np.linspace(0.001, 0.003, 5) + response = loaded.response( + "ir_x", frequencies, use_terminator=False, smearing=1e-4) + assert response.shape == frequencies.shape + assert np.all(np.isfinite(response)) + assert np.all(response >= 0) + + +def test_driver_executes_atom_fourier_backend(tmp_path): + ensemble = _ensemble() + charges = np.zeros((ensemble.current_dyn.structure.N_atoms, 3, 3)) + charges[0] = np.eye(3) + charges[1] = -np.eye(3) + mesh = tuple(int(value) for value in + ensemble.current_dyn.GetSupercell()) + job = SP.Spectroscopy( + ensemble, backend="atom_fourier", + workdir=tmp_path / "atom_fourier", use_symmetries=False, + ignore_v3=True, ignore_v4=True, + backend_options={"fine_mesh": mesh}) + job.add_ir_polarized([1, 0, 0], "ir_x", effective_charges=charges) + job.run(1, verbose=False) + assert np.all(np.isfinite(job.response( + "ir_x", np.linspace(0.001, 0.003, 3), + use_terminator=False, smearing=1e-4))) + + +@pytest.mark.parametrize("backend", ["real", "qspace"]) +def test_stabilizer_coset_kernel_matches_full_group_average(backend, tmp_path): + ensemble = _ensemble() + charges = np.zeros((ensemble.current_dyn.structure.N_atoms, 3, 3)) + charges[0] = np.eye(3) + charges[1] = -np.eye(3) + job = SP.Spectroscopy( + ensemble, backend=backend, workdir=tmp_path / backend, + use_symmetries=True) + job.add_ir_polarized( + [1, 0, 0], "ir_x", effective_charges=charges) + job.plan_calculations() + spec = next(iter(job._run_specs.values())) + + full = workflow.create_backend(ensemble, backend, {}) + workflow.prepare_engine(full, spec.as_array(), True) + reduced = workflow.create_backend(ensemble, backend, {}) + workflow.prepare_engine( + reduced, spec.as_array(), True, spec, job.symmetry_tolerance) + + assert reduced._spectroscopy_symmetry_count( + reduced.n_syms) < reduced.n_syms + np.testing.assert_allclose( + reduced.apply_anharmonic_FT(), full.apply_anharmonic_FT(), + rtol=2e-10, atol=2e-12) diff --git a/tests/test_spectroscopy/test_spectroscopy_foundation.py b/tests/test_spectroscopy/test_spectroscopy_foundation.py new file mode 100644 index 00000000..7e414f6b --- /dev/null +++ b/tests/test_spectroscopy/test_spectroscopy_foundation.py @@ -0,0 +1,198 @@ +"""Tests for the backend-neutral spectroscopy API and symmetry algebra.""" + +import itertools + +import numpy as np +import pytest + +import tdscha.Spectroscopy as SP + + +def _cubic_rotations(): + """Return the 24 proper signed-permutation rotations of a cube.""" + rotations = [] + for permutation in itertools.permutations(range(3)): + permutation_matrix = np.eye(3)[list(permutation)] + for signs in itertools.product((-1, 1), repeat=3): + rotation = np.diag(signs) @ permutation_matrix + if np.linalg.det(rotation) > 0: + rotations.append(rotation) + return rotations + + +def _random_symmetric_raman(seed=1234, n_coordinates=11): + generator = np.random.default_rng(seed) + tensor = generator.normal(size=(3, 3, n_coordinates)) + return (tensor + tensor.swapaxes(0, 1)) / 2 + + +def test_normalized_and_raw_raman_definitions_are_equivalent(): + tensor = _random_symmetric_raman() + normalized_total = 0.0 + raw_total = 0.0 + + for component in SP.RAMAN_COMPONENTS: + normalized = SP.build_raman_vector( + tensor, component.coefficients("normalized")) + raw = SP.build_raman_vector( + tensor, component.coefficients("raw")) + normalized_total += component.weight("normalized") * normalized**2 + raw_total += component.weight("raw") * raw**2 + + np.testing.assert_allclose(normalized_total, raw_total) + np.testing.assert_allclose( + SP.get_unpolarized_raman_weights("normalized"), + [45, 7, 7, 7, 7, 7, 7]) + np.testing.assert_allclose( + SP.get_unpolarized_raman_weights("raw"), + [5, 3.5, 3.5, 3.5, 21, 21, 21]) + + +def test_component_vectors_match_the_placzek_formula(): + tensor = _random_symmetric_raman(n_coordinates=7) + responses = [] + for component in SP.RAMAN_COMPONENTS: + vector = SP.build_raman_vector( + tensor, component.coefficients("normalized")) + responses.append(vector**2) + + assembled = 45 * responses[0] + 7 * sum(responses[1:]) + alpha = (tensor[0, 0] + tensor[1, 1] + tensor[2, 2]) / 3 + gamma_squared = ( + ((tensor[0, 0] - tensor[1, 1])**2 + + (tensor[0, 0] - tensor[2, 2])**2 + + (tensor[1, 1] - tensor[2, 2])**2) / 2 + + 3 * (tensor[0, 1]**2 + tensor[0, 2]**2 + + tensor[1, 2]**2)) + np.testing.assert_allclose(assembled, 45 * alpha**2 + 7 * gamma_squared) + + +def test_polarized_coefficients_match_direct_symmetric_contraction(): + tensor = _random_symmetric_raman() + incoming = np.array([0.5, -1.0, 2.0]) + outgoing = np.array([1.5, 0.25, -0.75]) + coefficients = SP.raman_coefficients_from_polarizations( + incoming, outgoing) + + actual = SP.build_raman_vector(tensor, coefficients) + expected = np.einsum("a,b,abk->k", incoming, outgoing, tensor) + np.testing.assert_allclose(actual, expected) + + +def test_ir_builder_matches_existing_axis_convention(): + effective_charges = np.arange(18, dtype=float).reshape(2, 3, 3) + direction = np.array([0.25, -0.5, 1.5]) + np.testing.assert_allclose( + SP.build_ir_vector(effective_charges, direction), + np.einsum("abc,b->ac", effective_charges, direction).ravel()) + + +def test_specs_are_immutable_and_validate_physical_inputs(): + ir = SP.IRPolarizationPerturbation([2, 0, 0]) + np.testing.assert_allclose(ir.as_array(), [1, 0, 0]) + returned = ir.as_array() + returned[0] = 10 + np.testing.assert_allclose(ir.as_array(), [1, 0, 0]) + + with pytest.raises(ValueError, match="must not be zero"): + SP.IRPolarizationPerturbation([0, 0, 0]) + with pytest.raises(ValueError, match="must be symmetric"): + SP.RamanTensorPerturbation([[1, 2, 0], [0, 1, 0], [0, 0, 1]]) + with pytest.raises(ValueError, match="0 to 6"): + SP.get_raman_component(7) + + +def test_cubic_ir_orbit_reduces_three_axes_to_one_run(): + group = SP.SymmetryGroup.from_matrices(_cubic_rotations()) + vectors = np.eye(3) + orbits = SP.find_perturbation_orbits( + vectors, SP.vector_representations_for_ir(group), group) + + assert len(orbits) == 1 + orbit = orbits[0] + assert orbit.members == (0, 1, 2) + assert len(orbit.stabilizer) == 8 + assert len(orbit.left_cosets) == 3 + assert len(orbit.right_cosets) == 3 + assert set(orbit.characters) == {-1.0, 1.0} + + +def test_left_and_right_cosets_are_complete_partitions(): + group = SP.SymmetryGroup.from_matrices(_cubic_rotations()) + orbit = SP.find_perturbation_orbits( + np.eye(3), SP.vector_representations_for_ir(group), group)[0] + for cosets in (orbit.left_cosets, orbit.right_cosets): + flattened = [member for coset in cosets for member in coset] + assert sorted(flattened) == list(range(len(group))) + assert all(len(coset) == len(orbit.stabilizer) + for coset in cosets) + + +def test_cubic_raman_components_reduce_to_three_orbits(): + group = SP.SymmetryGroup.from_matrices(_cubic_rotations()) + representations = SP.vector_representations_for_symmetric_raman(group) + vectors = [SP.symmetric_raman_vector( + component.coefficients("normalized")) + for component in SP.RAMAN_COMPONENTS] + orbits = SP.find_perturbation_orbits(vectors, representations, group) + + assert [orbit.members for orbit in orbits] == [ + (0,), (1, 2, 3), (4, 5, 6)] + assert [len(orbit.left_cosets) for orbit in orbits] == [1, 3, 3] + + +def test_identity_group_never_overreduces(): + group = SP.SymmetryGroup.from_matrices([np.eye(3)]) + vectors = np.eye(3) + orbits = SP.find_perturbation_orbits( + vectors, SP.vector_representations_for_ir(group), group) + assert [orbit.members for orbit in orbits] == [(0,), (1,), (2,)] + + +def test_orbit_analysis_rejects_a_mismatched_representation(): + group = SP.SymmetryGroup.from_matrices(_cubic_rotations()) + representations = list(SP.vector_representations_for_ir(group)) + representations[1] = np.eye(3) + with pytest.raises(ValueError, match="multiplication table"): + SP.find_perturbation_orbits(np.eye(3), representations, group) + + +def test_symmetry_group_rejects_non_groups(): + with pytest.raises(ValueError, match="closed"): + SP.SymmetryGroup.from_matrices([ + np.eye(3), + np.diag([-1, 1, 1]), + np.diag([1, -1, 1]), + ]) + + +def test_request_registry_has_one_canonical_manifest_representation(): + job = SP.Spectroscopy(None, backend="qspace", workdir="spectroscopy") + job.add_raman_polarized([1, 0, 0], [0, 1, 0], name="raman_xy") + job.add_raman_unpolarized(name="raman_powder") + job.add_ir_polarized([2, 0, 0], name="ir_x") + job.add_ir_unpolarized(name="ir_powder") + + manifest = job.manifest() + assert manifest["schema_version"] == 3 + assert manifest["backend"] == "qspace" + assert [request["name"] for request in manifest["requests"]] == [ + "raman_xy", "raman_powder", "ir_x", "ir_powder"] + assert len(manifest["requests"][1]["perturbations"]) == 7 + assert manifest["requests"][1]["weights"] == [45, 7, 7, 7, 7, 7, 7] + assert len(manifest["requests"][3]["perturbations"]) == 3 + + with pytest.raises(ValueError, match="already exists"): + job.add_ir_polarized([1, 0, 0], name="ir_x") + with pytest.raises(ValueError, match="letters"): + job.add_ir_unpolarized(name="bad/name") + assert job.requests["raman_powder"].observable == "raman_unpolarized" + + +def test_per_request_effective_charges_are_immutable(): + charges = np.arange(18, dtype=float).reshape(2, 3, 3) + job = SP.Spectroscopy(None) + job.add_ir_unpolarized("ir", effective_charges=charges) + charges[:] = 0 + stored = np.asarray(job.requests["ir"].source) + assert np.any(stored != 0) diff --git a/tests/test_spectroscopy/test_workflow.py b/tests/test_spectroscopy/test_workflow.py new file mode 100644 index 00000000..f0668ee5 --- /dev/null +++ b/tests/test_spectroscopy/test_workflow.py @@ -0,0 +1,395 @@ +"""End-to-end tests for spectroscopy planning, restart, and analysis.""" + +import json +from pathlib import Path + +import numpy as np +import pytest + +import cellconstructor as CC + +import tdscha.Spectroscopy as SP +from tdscha import _SpectroscopyWorkflow as workflow + + +class _Dyn: + def __init__(self): + structure = CC.Structure.Structure(1) + structure.unit_cell = np.eye(3) * 5.0 + structure.coords[0] = np.zeros(3) + structure.atoms = ["X"] + structure.masses = {"X": 1.0} + structure.has_unit_cell = True + self.structure = structure + self.dynmats = [np.eye(3)] + self.effective_charges = np.eye(3)[None, :, :] + self.dielectric_tensor = np.eye(3) * 2.5 + tensor = np.arange(27, dtype=float).reshape(3, 3, 3) / 10 + self.raman_tensor = (tensor + tensor.swapaxes(0, 1)) / 2 + + @staticmethod + def GetSupercell(): + return np.ones(3, dtype=int) + + +class _Ensemble: + def __init__(self): + self.current_dyn = _Dyn() + self.current_T = 300.0 + + +class _FakeEngine: + created = 0 + + def __init__(self, temperature): + type(self).created += 1 + self.T = temperature + self.a_coeffs = [] + self.b_coeffs = [] + self.c_coeffs = [] + self.perturbation_modulus = 1.0 + self.use_wigner = True + self.reverse_L = False + self.shift_value = 0.0 + + def init(self, use_symmetries=True): + self.use_symmetries = use_symmetries + + def _prepare_gamma_cartesian_perturbation(self, vector): + self.vector = np.asarray(vector) + self.perturbation_modulus = float(self.vector @ self.vector) + + def run_FT(self, count, verbose=False, **kwargs): + for _ in range(count): + self.a_coeffs.append(-0.4) + self.b_coeffs.append(0.02) + self.c_coeffs.append(0.02) + + def save_status(self, path): + np.savez_compressed( + path, a=self.a_coeffs, b=self.b_coeffs, c=self.c_coeffs) + + def load_status(self, path): + with np.load(path) as archive: + self.a_coeffs = list(archive["a"]) + self.b_coeffs = list(archive["b"]) + self.c_coeffs = list(archive["c"]) + + def save_abc(self, path): + abc = np.column_stack( + (self.a_coeffs, self.b_coeffs, self.c_coeffs)) + np.savetxt( + path, abc, + header="perturbation_modulus = {}\na; b; c".format( + self.perturbation_modulus)) + + +class _InterruptingEngine(_FakeEngine): + def run_FT(self, count, verbose=False, **kwargs): + if self.a_coeffs: + raise RuntimeError("simulated interruption") + super().run_FT(count, verbose=verbose, **kwargs) + + +def test_structure_symmetry_reduces_cubic_ir_to_one_run(): + ensemble = _Ensemble() + job = SP.Spectroscopy(ensemble, backend="real") + job.add_ir_unpolarized("powder") + plan = job.plan_calculations() + + assert plan["group_order"] == 48 + assert plan["n_requested_components"] == 3 + assert plan["n_independent_runs"] == 1 + + +def test_separately_named_symmetry_equivalent_requests_share_one_run(): + ensemble = _Ensemble() + job = SP.Spectroscopy(ensemble, backend="real") + job.add_ir_polarized([1, 0, 0], "ir_x") + job.add_ir_polarized([0, 1, 0], "ir_y") + plan = job.plan_calculations() + + assert plan["n_requested_components"] == 2 + assert plan["n_independent_runs"] == 1 + assert (plan["request_components"]["ir_x"][0]["run_id"] == + plan["request_components"]["ir_y"][0]["run_id"]) + + +def test_anisotropic_mesh_excludes_incompatible_cubic_rotations(): + ensemble = _Ensemble() + ensemble.current_dyn.GetSupercell = lambda: np.array([1, 2, 3]) + group, _ = SP.get_gamma_symmetry_representation( + ensemble.current_dyn.structure, + supercell=ensemble.current_dyn.GetSupercell()) + assert len(group) == 8 + + +def test_run_resume_load_and_analyze(monkeypatch, tmp_path): + ensemble = _Ensemble() + _FakeEngine.created = 0 + + def make_engine(_ensemble, _backend, _options, **_): + return _FakeEngine(_ensemble.current_T) + + monkeypatch.setattr(workflow, "create_backend", make_engine) + job = SP.Spectroscopy( + ensemble, backend="real", workdir=tmp_path / "spectroscopy") + job.add_ir_unpolarized("powder") + job.run(5, save_each=2, verbose=False) + + assert _FakeEngine.created == 1 + manifest_path = tmp_path / "spectroscopy" / "manifest.json" + with open(manifest_path, encoding="utf-8") as stream: + manifest = json.load(stream) + assert len(manifest["runs"]) == 1 + assert next(iter(manifest["runs"].values()))["state"] == "complete" + assert next(iter(manifest["runs"].values()))["completed_steps"] == 5 + analysis = next(iter(manifest["runs"].values()))["analysis"] + assert "symmetry_reduction" in analysis + run_id = next(iter(manifest["runs"])) + assert (tmp_path / "spectroscopy" / "runs" / run_id / + "status.npz").exists() + + # A completed resume does not construct or rerun the backend. + job.run(5, save_each=2, verbose=False) + assert _FakeEngine.created == 1 + + loaded = SP.Spectroscopy.load(tmp_path / "spectroscopy") + frequencies = np.linspace(0.1, 0.2, 5) + response = loaded.response( + "powder", frequencies, use_terminator=False, smearing=0.01) + assert response.shape == frequencies.shape + assert np.all(response >= 0) + np.testing.assert_allclose( + loaded.ir_susceptibility( + "powder", frequencies, use_terminator=False, smearing=0.01), + 2 * loaded.green_function( + "powder", frequencies, use_terminator=False, smearing=0.01)) + + epsilon = loaded.dielectric_function( + "powder", frequencies, epsilon_infinity=np.eye(3) * 2.5, + ionic_prefactor=0.0, use_terminator=False, smearing=0.01) + np.testing.assert_allclose(epsilon, 2.5) + + # The full tensor is persisted in the manifest, so load-only analysis + # needs no duplicate epsilon_infinity argument. + inferred = loaded.dielectric_function( + "powder", frequencies, ionic_prefactor=0.0, + use_terminator=False, smearing=0.01) + np.testing.assert_allclose(inferred, 2.5) + + # The portable abc files are sufficient for automatic load-only analysis. + result_path = (tmp_path / "spectroscopy" / "runs" / run_id / + "result.npz") + result_path.unlink() + abc_loaded = SP.Spectroscopy.load(tmp_path / "spectroscopy") + np.testing.assert_allclose( + abc_loaded.response( + "powder", frequencies, use_terminator=False, smearing=0.01), + response) + + +def test_dielectric_tensor_is_projected_not_scalarized(monkeypatch, tmp_path): + ensemble = _Ensemble() + ensemble.current_dyn.dielectric_tensor = np.array([ + [2.0, 0.4, 0.0], [0.4, 4.0, 0.0], [0.0, 0.0, 8.0]]) + monkeypatch.setattr( + workflow, "create_backend", + lambda ens, backend, options, **_: _FakeEngine(ens.current_T)) + job = SP.Spectroscopy( + ensemble, backend="real", workdir=tmp_path / "tensor") + direction = np.array([1.0, 1.0, 0.0]) / np.sqrt(2.0) + job.add_ir_polarized(direction, "polarized") + job.add_ir_unpolarized("powder") + job.run(2, verbose=False) + loaded = SP.Spectroscopy.load(tmp_path / "tensor") + frequencies = np.linspace(0.1, 0.2, 3) + + polarized = loaded.dielectric_function( + "polarized", frequencies, ionic_prefactor=0.0, + use_terminator=False, smearing=0.01) + powder = loaded.dielectric_function( + "powder", frequencies, ionic_prefactor=0.0, + use_terminator=False, smearing=0.01) + np.testing.assert_allclose(polarized, 3.4) + np.testing.assert_allclose(powder, 14.0 / 3.0) + + override = np.diag([10.0, 20.0, 30.0]) + np.testing.assert_allclose( + loaded.dielectric_function( + "polarized", frequencies, epsilon_infinity=override, + ionic_prefactor=0.0, use_terminator=False, smearing=0.01), + 15.0) + + +def test_dielectric_function_uses_supercell_volume(monkeypatch, tmp_path): + """The default IR prefactor must be 4*pi/V_supercell, not 4*pi/V_unit. + + The Lanczos perturbation carries sqrt(n_cell) (prepare_ir), so the Green + function already includes the n_cell factor and the volume in + epsilon_inf + (4*pi/Omega)*chi_ionic must be the supercell volume. + """ + ensemble = _Ensemble() + ensemble.current_dyn.GetSupercell = lambda: np.array([2, 1, 2]) + monkeypatch.setattr( + workflow, "create_backend", + lambda ens, backend, options, **_: _FakeEngine(ens.current_T)) + job = SP.Spectroscopy( + ensemble, backend="real", workdir=tmp_path / "supercell_ir") + job.add_ir_unpolarized("powder") + job.run(2, verbose=False) + loaded = SP.Spectroscopy.load(tmp_path / "supercell_ir") + + from cellconstructor.Units import A_TO_BOHR + manifest = loaded._manifest_data + assert manifest["supercell_volume_angstrom3"] == pytest.approx( + manifest["unit_cell_volume_angstrom3"] * 4) + assert manifest["unit_cell_volume_angstrom3"] == pytest.approx(125.0) + + frequencies = np.linspace(0.1, 0.2, 3) + options = dict(use_terminator=False, smearing=0.01) + default = loaded.dielectric_function("powder", frequencies, **options) + + supercell_bohr3 = ( + manifest["supercell_volume_angstrom3"] * float(A_TO_BOHR)**3) + explicit = loaded.dielectric_function( + "powder", frequencies, + ionic_prefactor=4 * np.pi / supercell_bohr3, **options) + np.testing.assert_allclose(default, explicit) + + unit_bohr3 = ( + manifest["unit_cell_volume_angstrom3"] * float(A_TO_BOHR)**3) + wrong = loaded.dielectric_function( + "powder", frequencies, + ionic_prefactor=4 * np.pi / unit_bohr3, **options) + assert not np.allclose(default, wrong) + + +def test_backend_physics_flags_are_public_and_legacy_compatible(): + ensemble = _Ensemble() + explicit = SP.Spectroscopy( + ensemble, ignore_v3=True, ignore_v4=False, + lo_to_split=[1, 2, 3]) + manifest = explicit.manifest() + assert manifest["ignore_v3"] is True + assert manifest["ignore_v4"] is False + assert manifest["lo_to_split"] == [1.0, 2.0, 3.0] + assert "ignore_v3" not in manifest["backend_options"] + + legacy = SP.Spectroscopy( + ensemble, + backend_options={"ignore_v3": True, "ignore_v4": True, + "lo_to_split": [0, 0, 1]}) + assert legacy.ignore_v3 and legacy.ignore_v4 + assert legacy.lo_to_split == [0.0, 0.0, 1.0] + + with pytest.raises(ValueError, match="Conflicting 'ignore_v3'"): + SP.Spectroscopy( + ensemble, ignore_v3=False, + backend_options={"ignore_v3": True}) + + +def test_restart_manifest_rejects_changed_requests(monkeypatch, tmp_path): + ensemble = _Ensemble() + monkeypatch.setattr( + workflow, "create_backend", + lambda ens, backend, options, **_: _FakeEngine(ens.current_T)) + workdir = tmp_path / "spectroscopy" + first = SP.Spectroscopy(ensemble, backend="real", workdir=workdir) + first.add_ir_polarized([1, 0, 0], "ir") + first.run(2, verbose=False) + + changed = SP.Spectroscopy(ensemble, backend="real", workdir=workdir) + changed.add_ir_polarized([0, 1, 0], "ir") + try: + changed.run(2, verbose=False) + except ValueError as error: + assert "incompatible" in str(error) + else: + raise AssertionError("Changed requests must invalidate a restart") + + +def test_interrupted_run_resumes_from_native_status(monkeypatch, tmp_path): + ensemble = _Ensemble() + workdir = tmp_path / "interrupted" + monkeypatch.setattr( + workflow, "create_backend", + lambda ens, backend, options, **_: _InterruptingEngine(ens.current_T)) + interrupted = SP.Spectroscopy( + ensemble, backend="real", workdir=workdir, + use_symmetries=False) + interrupted.add_ir_polarized([1, 0, 0], "ir") + with pytest.raises(RuntimeError, match="simulated interruption"): + interrupted.run(5, save_each=2, verbose=False) + + monkeypatch.setattr( + workflow, "create_backend", + lambda ens, backend, options, **_: _FakeEngine(ens.current_T)) + resumed = SP.Spectroscopy( + ensemble, backend="real", workdir=workdir, + use_symmetries=False) + resumed.add_ir_polarized([1, 0, 0], "ir") + resumed.run(5, save_each=2, verbose=False) + + with open(workdir / "manifest.json", encoding="utf-8") as stream: + manifest = json.load(stream) + entry = next(iter(manifest["runs"].values())) + assert entry["state"] == "complete" + assert entry["completed_steps"] == 5 + + +def test_unpolarized_and_polarized_raman_assembly(monkeypatch, tmp_path): + ensemble = _Ensemble() + monkeypatch.setattr( + workflow, "create_backend", + lambda ens, backend, options, **_: _FakeEngine(ens.current_T)) + job = SP.Spectroscopy( + ensemble, backend="real", workdir=tmp_path / "raman", + use_symmetries=False) + job.add_raman_unpolarized("powder") + job.add_raman_polarized( + [1, 1, 0], [0, 1, 1], name="polarized") + plan = job.plan_calculations() + assert plan["n_requested_components"] == 8 + job.run(2, verbose=False) + + frequencies = np.linspace(0.1, 0.2, 4) + response = job.raman_spectrum( + "powder", frequencies, kind="response", + use_terminator=False, smearing=0.01) + stokes = job.raman_spectrum( + "powder", frequencies, kind="stokes", + use_terminator=False, smearing=0.01) + anti = job.raman_spectrum( + "powder", frequencies, kind="anti_stokes", + use_terminator=False, smearing=0.01) + assert np.all(response >= 0) + assert np.all(stokes > anti) + np.testing.assert_allclose( + stokes - anti, response, rtol=1e-12, atol=1e-12) + + polarized = job.raman_spectrum( + "polarized", frequencies, kind="response", + use_terminator=False, smearing=0.01) + assert np.all(polarized >= 0) + + +@pytest.mark.parametrize("scale", [1.0, 1.0e-14]) +def test_symmetry_forbidden_raman_components_need_no_run(scale): + ensemble = _Ensemble() + ensemble.current_dyn.raman_tensor[:] = 0.0 + # A cubic T2-like Raman tensor: only the normalized xy component is + # active. The other six powder-average components are exactly zero and + # must contribute zero without creating invalid Lanczos perturbations. + ensemble.current_dyn.raman_tensor[0, 1, 0] = scale + ensemble.current_dyn.raman_tensor[1, 0, 0] = scale + + job = SP.Spectroscopy( + ensemble, backend="real", use_symmetries=False) + job.add_raman_unpolarized("powder") + plan = job.plan_calculations() + + assert plan["n_requested_components"] == 7 + assert plan["n_independent_runs"] == 1 + components = plan["request_components"]["powder"] + assert sum(component["run_id"] is None for component in components) == 6