Coverage for eminus/occupations.py: 98.28%
233 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-21 12:19 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-21 12:19 +0000
1# SPDX-FileCopyrightText: 2023 The eminus developers
2# SPDX-License-Identifier: Apache-2.0
3"""Determine occupations for atomic systems from simple inputs."""
5import dataclasses
6import numbers
8from . import backend as xp
9from .logger import log
10from .tools import fermi_distribution, get_Efermi
13@dataclasses.dataclass
14class Occupations:
15 """Occupations class to save electronic state information in one place.
17 The attribute Nelec has to be given first after instantiation.
18 """
20 # Set the private variable for the attributes that are properties.
21 _Nelec: int = 0 #: Number of electrons.
22 _Nspin: int = 0 #: Number of spin states.
23 _spin: float = 0 #: Number of unpaired electrons.
24 _charge: int = 0 #: System charge.
25 _Nstate: int = 0 #: Number of states.
26 _Nempty: int = 0 #: Number of empty states.
27 _Nk: int = 1 #: Number of k-points.
28 _bands: int = 0 #: Number of bands.
29 _smearing: float = 0 #: Smearing width in Hartree.
30 is_filled: bool = False #: Determines the Occupations object fill status.
32 # ### Class properties ###
34 @property
35 def Nelec(self):
36 """Number of electrons."""
37 return self._Nelec
39 @Nelec.setter
40 def Nelec(self, value):
41 # Only update Nelec if it actually gets updated
42 if self._Nelec != int(value):
43 self._Nelec = int(value)
44 self.is_filled = False
46 @property
47 def Nspin(self):
48 """Number of spin states."""
49 return self._Nspin
51 @Nspin.setter
52 def Nspin(self, value):
53 # Use a spin-paired calculation for an even number of electrons
54 if value is None:
55 if self.Nelec % 2 == 0 and self.spin == 0:
56 value = 1
57 else:
58 value = 2
59 # Only update if needed
60 if self._Nspin != int(value):
61 self._Nspin = int(value)
62 self.spin = self.spin
63 self.is_filled = False
64 if hasattr(self, "f"):
65 log.warning("Reset previously set fillings.")
67 @property
68 def spin(self):
69 """Number of unpaired electrons."""
70 return self._spin
72 @spin.setter
73 def spin(self, value):
74 # If no spin is given try to determine it from the number of electrons
75 if value is None:
76 if self.Nelec % 2 == 0:
77 value = 0
78 else:
79 value = 1
80 if self._spin != value:
81 self.is_filled = False
82 # We have no spin in the spin-paired case
83 if self.Nspin == 1:
84 self._spin = 0
85 else:
86 self._spin = value
88 @property
89 def charge(self):
90 """System charge."""
91 return self._charge
93 @charge.setter
94 def charge(self, value):
95 # If we set a charge using this setter update the number of electrons
96 if hasattr(self, "Nelec"):
97 self.Nelec += self._charge - int(value)
98 if self._charge != int(value):
99 self._charge = int(value)
100 self.is_filled = False
102 @property
103 def f(self):
104 """Occupation numbers per state."""
105 return self._f
107 @f.setter
108 def f(self, value):
109 # Make sure the occupations are in a two-dimensional array
110 if isinstance(value, (list, tuple)) or xp.is_array(value):
111 value = xp.atleast_2d(xp.asarray(value, dtype=float))
112 self.is_filled = False
113 # This setter will only be called when explicitly setting f
114 # Call the fill function in that case
115 self.fill(value)
117 @property
118 def Nk(self):
119 """Number of k-points."""
120 return self._Nk
122 @Nk.setter
123 def Nk(self, value):
124 self._Nk = int(value)
125 self.is_filled = False
127 @property
128 def wk(self):
129 """k-point weights."""
130 return self._wk
132 @wk.setter
133 def wk(self, value):
134 self._wk = xp.asarray(value, dtype=float)
135 self._Nk = len(self._wk)
136 self.is_filled = False
138 @property
139 def bands(self):
140 """Total number of bands."""
141 return self._bands
143 @bands.setter
144 def bands(self, value):
145 self._bands = int(value)
146 self.is_filled = False
148 @property
149 def smearing(self):
150 """Smearing width in Hartree."""
151 return self._smearing
153 @smearing.setter
154 def smearing(self, value):
155 if value < 0:
156 log.error("The smearing width can not be negative.")
157 self._smearing = value
158 self.is_filled = False
159 if self.Nempty > 0:
160 log.warning("Empty states with smearing enabled found.")
162 @property
163 def magnetization(self):
164 """Magnetization from occupation numbers."""
165 # There is no magnetization in the spin-paired case
166 if self.Nspin == 1:
167 return 0
168 if not self.is_filled:
169 log.warning("Can not calculate magnetization for unfilled occupations.")
170 return 0
171 return xp.sum(self.wk * (self.f[:, 0] - self.f[:, 1])) / xp.sum(self.wk * self.f)
173 @magnetization.setter
174 def magnetization(self, value):
175 if value is not None:
176 if self.is_filled:
177 log.warning("Reset previously set fillings.")
178 self.is_filled = False
179 self.fill(None, value)
181 # ### Read-only properties ###
183 @property
184 def multiplicity(self):
185 """Multiplicity, i.e., 2S+1."""
186 return self.spin + 1
188 @property
189 def Nstate(self):
190 """Number of states."""
191 return self._Nstate
193 @property
194 def Nempty(self):
195 """Number of empty states."""
196 return self._Nempty
198 @property
199 def F(self):
200 """Diagonal matrices of f per k-point and spin."""
201 return [[xp.diag(f) for f in f_spin] for f_spin in self.f]
203 # ### Class methods ###
205 def fill(self, f=None, magnetization=None):
206 """Fill the states of the object.
208 Keyword Args:
209 f: Fillings.
210 magnetization: Magnetization.
211 """
212 # Do nothing if the object is already filled
213 if self.is_filled:
214 return self
215 # If no f is given just use the standard fillings: 2 for restricted and 1 for unrestricted
216 if f is None:
217 f = 2 / self.Nspin
218 self._update_from_fillings(f, magnetization)
219 self.is_filled = True
220 return self
222 kernel = fill
224 def _update_from_fillings(self, value, magnetization):
225 """Update fillings.
227 Args:
228 value: Fillings.
229 magnetization: Magnetization.
230 """
231 # Do not use the setter methods in this place to not trigger the setter effects
232 # If f is a number use this occupation for all states
233 if isinstance(value, numbers.Real) or isinstance(magnetization, numbers.Real):
234 # Do not leave the states array empty when no electrons are present
235 if self.Nelec <= 0:
236 self._Nstate = 1
237 self._f = xp.zeros((self.Nk, self.Nspin, 1))
238 # Always use the fractional fillings method if a magnetization is given
239 elif isinstance(magnetization, numbers.Real):
240 self._fractional_fillings(value, magnetization)
241 elif self.Nspin == 1 or self.Nelec % 2 == self.spin % 2:
242 self._integer_fillings(value)
243 else:
244 log.warning("Using fractional occupations.")
245 self._fractional_fillings(value)
246 # If f is an array redetermine all attributes from it
247 else:
248 self._f = value
249 self._Nspin = len(value)
250 self._Nstate = len(value[0])
251 self._charge += self.Nelec - int(xp.sum(value))
252 self.Nelec = int(xp.sum(value))
253 if self.Nspin == 1:
254 self.spin = 0
255 else:
256 self.spin = abs(xp.sum(self.f[0] - self.f[1]))
258 def _integer_fillings(self, f):
259 """Update fillings while maintaining integer occupations numbers.
261 Args:
262 f: Fillings.
263 """
264 # Determine the electrons per spin channel
265 Nup = self.Nelec // self.Nspin + self.spin // self.Nspin + self.Nelec % self.Nspin
266 Ndw = self.Nelec // self.Nspin - self.spin // self.Nspin
267 elecs = xp.asarray([Nup, Ndw])
269 # Get the number of states
270 Nstate = int(xp.ceil(max(elecs / f)))
271 # If no bands are set, set them now
272 if self.bands == 0:
273 self.bands = Nstate
274 self._Nstate = Nstate
275 if self.bands < Nstate:
276 log.error("Number of bands is smaller than the number of valence electrons.")
277 if self.bands == Nstate and self.smearing > 0:
278 log.warning("Smearing has been enabled but no extra bands have been set.")
279 # Set the number of empty states if no smearing is used
280 if self.smearing == 0:
281 self._Nstate = Nstate
282 self._Nempty = self.bands - Nstate
284 # Simply build the occupations array
285 self._f = f * xp.ones((self.Nspin, Nstate), dtype=int)
286 # If we have filled too much correct it in the second spin channel
287 # The following procedure ensures that we have no negative fillings
288 rest = xp.sum(self._f) - self.Nelec
289 i = 1
290 while rest > 0:
291 # If the occupation is greater than the rest we are done with removing electrons
292 if self._f[-1, -i] >= rest:
293 self._f[-1, -i] -= rest
294 break
295 # Otherwise zero out the state, update the rest, and move to the next state
296 rest -= self._f[-1, -i]
297 self._f[-1, -i] = 0
298 i += 1
300 if self.smearing > 0:
301 # Append extra states
302 self._f = xp.hstack((self._f, xp.zeros((self.Nspin, self.bands - Nstate))))
303 self._Nstate = self.bands
304 self._f = xp.vstack([xp.stack([self._f])] * self.Nk)
306 def _fractional_fillings(self, f, magnetization=None):
307 """Update fillings while allowing fractional occupation numbers.
309 Args:
310 f: Fillings.
312 Keyword Args:
313 magnetization: Magnetization.
314 """
315 # Determine the electrons per spin channel
316 if magnetization is None:
317 Nup = self.Nelec / self.Nspin + self.spin / self.Nspin
318 Ndw = self.Nelec / self.Nspin - self.spin / self.Nspin
319 else:
320 # M = (Nup - Ndw) / N = (Nup - N + Nup) / N
321 Nup = (magnetization * self.Nelec + self.Nelec) / 2
322 Ndw = self.Nelec - Nup
323 self.spin = abs(Nup - Ndw)
324 elecs = xp.asarray([Nup, Ndw])
326 # Get the number of states
327 Nstate = int(xp.ceil(max(elecs / f)))
328 # If no bands are set, set them now
329 if self.bands == 0:
330 self.bands = Nstate
331 self._Nstate = Nstate
332 if self.bands < Nstate:
333 log.error("Number of bands is smaller than the number of valence electrons.")
334 if self.bands == Nstate and self.smearing > 0:
335 log.warning("Smearing has been enabled but no extra bands have been set.")
336 # Set the number of empty states if no smearing is used
337 if self.smearing == 0:
338 self._Nstate = Nstate
339 self._Nempty = self.bands - Nstate
341 # Simply build the occupations array
342 self._f = f * xp.ones((self.Nspin, Nstate))
343 # If we have filled too much correct it in both spin channels
344 # The following procedure ensures that we have no negative fillings
345 rest = xp.sum(self._f, axis=1) - elecs
346 for spin in range(self.Nspin):
347 i = 1
348 while rest[spin] > 0:
349 # If the occupation is greater than the rest we are done with removing electrons
350 if self._f[spin, -i] >= rest[spin]:
351 self._f[spin, -i] -= rest[spin]
352 break
353 # Otherwise zero out the state, update the rest, and move to the next state
354 rest[spin] -= self._f[spin, -i]
355 self._f[spin, -i] = 0
356 i += 1
358 if self.smearing > 0:
359 # Append extra states
360 self._f = xp.hstack((self._f, xp.zeros((self.Nspin, self.bands - Nstate))))
361 self._Nstate = self.bands
362 self._f = xp.vstack([xp.stack([self._f])] * self.Nk)
364 def __repr__(self):
365 """Print the parameters stored in the Occupations object."""
366 return (
367 f"Spin handling: {'un' if self.Nspin == 2 else ''}restricted\n"
368 f"Number of electrons: {self.Nelec}\n"
369 f"Spin: {self.spin}\n"
370 f"Charge: {self.charge}\n"
371 f"Number of bands: {self.bands}\n"
372 f"Number of states: {self.Nstate}\n"
373 f"Number of empty states: {self.Nempty}\n"
374 f"Number of k-points: {self.Nk}\n"
375 f"Smearing width: {self.smearing} Eh\n"
376 f"Fillings: \n{self.f if self.is_filled else 'Not filled'}"
377 )
379 def smear(self, epsilon):
380 """Update fillings according to a Fermi distribution.
382 Args:
383 epsilon: Eigenenergies.
385 Returns:
386 Efermi: Fermi energy.
387 """
388 if self.smearing == 0:
389 log.info("Smearing is set to zero, nothing to do.")
390 return 0
392 Efermi = get_Efermi(self, epsilon)
393 self._f = fermi_distribution(epsilon, Efermi, self.smearing) * 2 / self.Nspin
394 return Efermi