otter-hed 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. otter/__init__.py +45 -0
  2. otter/_version.py +8 -0
  3. otter/data/__init__.py +13 -0
  4. otter/data/elements.py +233 -0
  5. otter/data/helpers.py +131 -0
  6. otter/electronic/__init__.py +31 -0
  7. otter/electronic/continuum/__init__.py +32 -0
  8. otter/electronic/continuum/hybrid.py +124 -0
  9. otter/electronic/continuum/ideal.py +125 -0
  10. otter/electronic/continuum/interface.py +59 -0
  11. otter/electronic/continuum/scattering.py +4743 -0
  12. otter/electronic/continuum/tail.py +1630 -0
  13. otter/electronic/densities.py +298 -0
  14. otter/electronic/full_external.py +4933 -0
  15. otter/electronic/ks_dft.py +6620 -0
  16. otter/electronic/mixture.py +3104 -0
  17. otter/electronic/potential.py +380 -0
  18. otter/electronic/solvers/__init__.py +10 -0
  19. otter/electronic/solvers/bound.py +1053 -0
  20. otter/electronic/solvers/free.py +272 -0
  21. otter/electronic/thomas_fermi.py +1344 -0
  22. otter/electronic/xc.py +712 -0
  23. otter/experimental/__init__.py +17 -0
  24. otter/experimental/sc_feedback.py +544 -0
  25. otter/io/__init__.py +24 -0
  26. otter/io/_npz.py +43 -0
  27. otter/io/results.py +366 -0
  28. otter/io/state.py +1111 -0
  29. otter/ionic/__init__.py +100 -0
  30. otter/ionic/correlation.py +93 -0
  31. otter/ionic/lfc.py +762 -0
  32. otter/ionic/qoz.py +2074 -0
  33. otter/ionic/response.py +49 -0
  34. otter/literature.bib +523 -0
  35. otter/literature.py +459 -0
  36. otter/numerics/__init__.py +37 -0
  37. otter/numerics/constants.py +22 -0
  38. otter/numerics/grids.py +204 -0
  39. otter/numerics/interpolation.py +81 -0
  40. otter/numerics/transforms.py +168 -0
  41. otter/plotting.py +481 -0
  42. otter/workflows.py +1716 -0
  43. otter_hed-0.2.1.dist-info/METADATA +231 -0
  44. otter_hed-0.2.1.dist-info/RECORD +48 -0
  45. otter_hed-0.2.1.dist-info/WHEEL +5 -0
  46. otter_hed-0.2.1.dist-info/licenses/LICENSE +28 -0
  47. otter_hed-0.2.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +68 -0
  48. otter_hed-0.2.1.dist-info/top_level.txt +1 -0
otter/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """Public API for otter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from otter._version import __version__
6
+ from otter.workflows import (
7
+ PlasmaWorkflowConfig,
8
+ continue_plasma_workflow_from_electronic_result,
9
+ parse_formula_composition,
10
+ resolve_plasma_composition,
11
+ run_formula_workflow,
12
+ solve_plasma_workflow,
13
+ )
14
+ from otter.io.state import (
15
+ StateExportOptions,
16
+ load_plasma_state,
17
+ save_plasma_state,
18
+ )
19
+ from otter.literature import (
20
+ CitationMixin,
21
+ bibliography_entries,
22
+ get_bibtex_ref_string,
23
+ get_cite_ref_string,
24
+ get_formatted_ref_string,
25
+ write_citations_markdown,
26
+ )
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "CitationMixin",
31
+ "PlasmaWorkflowConfig",
32
+ "StateExportOptions",
33
+ "continue_plasma_workflow_from_electronic_result",
34
+ "bibliography_entries",
35
+ "get_bibtex_ref_string",
36
+ "get_cite_ref_string",
37
+ "get_formatted_ref_string",
38
+ "parse_formula_composition",
39
+ "load_plasma_state",
40
+ "resolve_plasma_composition",
41
+ "run_formula_workflow",
42
+ "save_plasma_state",
43
+ "solve_plasma_workflow",
44
+ "write_citations_markdown",
45
+ ]
otter/_version.py ADDED
@@ -0,0 +1,8 @@
1
+ """Source version shared by the public API and runtime reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.1"
6
+
7
+
8
+ __all__ = ["__version__"]
otter/data/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Chemical element data and density helpers."""
2
+ from __future__ import annotations
3
+
4
+ from otter.data.elements import Element, atomic_weight, element
5
+ from otter.data.helpers import ion_density_bohr3, mu_guess_from_density
6
+
7
+ __all__ = [
8
+ "Element",
9
+ "atomic_weight",
10
+ "element",
11
+ "ion_density_bohr3",
12
+ "mu_guess_from_density",
13
+ ]
otter/data/elements.py ADDED
@@ -0,0 +1,233 @@
1
+ """
2
+ otter/data/elements.py
3
+
4
+ Periodic-table lookup utilities used by tests and example scripts.
5
+
6
+ Design goals
7
+ ------------
8
+ - Keep one canonical mapping: atomic number (Z), symbol, atomic mass.
9
+ - Allow case-insensitive symbol lookup.
10
+ - Provide simple helpers for converting between Z/symbol/mass.
11
+
12
+ Notes
13
+ -----
14
+ Atomic masses are standard atomic weights for stable elements and conventional
15
+ representative values for short-lived/transuranic elements.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Dict, Iterable, Tuple
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Element:
25
+ """Periodic-table entry."""
26
+
27
+ z: int
28
+ symbol: str
29
+ atomic_mass: float
30
+
31
+
32
+ _ELEMENT_ROWS: Tuple[Tuple[int, str, float], ...] = (
33
+ (1, "H", 1.008),
34
+ (2, "He", 4.002602),
35
+ (3, "Li", 6.94),
36
+ (4, "Be", 9.0121831),
37
+ (5, "B", 10.81),
38
+ (6, "C", 12.011),
39
+ (7, "N", 14.007),
40
+ (8, "O", 15.999),
41
+ (9, "F", 18.998403163),
42
+ (10, "Ne", 20.1797),
43
+ (11, "Na", 22.98976928),
44
+ (12, "Mg", 24.305),
45
+ (13, "Al", 26.9815385),
46
+ (14, "Si", 28.085),
47
+ (15, "P", 30.973761998),
48
+ (16, "S", 32.06),
49
+ (17, "Cl", 35.45),
50
+ (18, "Ar", 39.948),
51
+ (19, "K", 39.0983),
52
+ (20, "Ca", 40.078),
53
+ (21, "Sc", 44.955908),
54
+ (22, "Ti", 47.867),
55
+ (23, "V", 50.9415),
56
+ (24, "Cr", 51.9961),
57
+ (25, "Mn", 54.938044),
58
+ (26, "Fe", 55.845),
59
+ (27, "Co", 58.933194),
60
+ (28, "Ni", 58.6934),
61
+ (29, "Cu", 63.546),
62
+ (30, "Zn", 65.38),
63
+ (31, "Ga", 69.723),
64
+ (32, "Ge", 72.63),
65
+ (33, "As", 74.921595),
66
+ (34, "Se", 78.971),
67
+ (35, "Br", 79.904),
68
+ (36, "Kr", 83.798),
69
+ (37, "Rb", 85.4678),
70
+ (38, "Sr", 87.62),
71
+ (39, "Y", 88.90584),
72
+ (40, "Zr", 91.224),
73
+ (41, "Nb", 92.90637),
74
+ (42, "Mo", 95.95),
75
+ (43, "Tc", 98.0),
76
+ (44, "Ru", 101.07),
77
+ (45, "Rh", 102.90550),
78
+ (46, "Pd", 106.42),
79
+ (47, "Ag", 107.8682),
80
+ (48, "Cd", 112.414),
81
+ (49, "In", 114.818),
82
+ (50, "Sn", 118.710),
83
+ (51, "Sb", 121.760),
84
+ (52, "Te", 127.60),
85
+ (53, "I", 126.90447),
86
+ (54, "Xe", 131.293),
87
+ (55, "Cs", 132.90545196),
88
+ (56, "Ba", 137.327),
89
+ (57, "La", 138.90547),
90
+ (58, "Ce", 140.116),
91
+ (59, "Pr", 140.90766),
92
+ (60, "Nd", 144.242),
93
+ (61, "Pm", 145.0),
94
+ (62, "Sm", 150.36),
95
+ (63, "Eu", 151.964),
96
+ (64, "Gd", 157.25),
97
+ (65, "Tb", 158.92535),
98
+ (66, "Dy", 162.500),
99
+ (67, "Ho", 164.93033),
100
+ (68, "Er", 167.259),
101
+ (69, "Tm", 168.93422),
102
+ (70, "Yb", 173.045),
103
+ (71, "Lu", 174.9668),
104
+ (72, "Hf", 178.49),
105
+ (73, "Ta", 180.94788),
106
+ (74, "W", 183.84),
107
+ (75, "Re", 186.207),
108
+ (76, "Os", 190.23),
109
+ (77, "Ir", 192.217),
110
+ (78, "Pt", 195.084),
111
+ (79, "Au", 196.966569),
112
+ (80, "Hg", 200.592),
113
+ (81, "Tl", 204.38),
114
+ (82, "Pb", 207.2),
115
+ (83, "Bi", 208.98040),
116
+ (84, "Po", 209.0),
117
+ (85, "At", 210.0),
118
+ (86, "Rn", 222.0),
119
+ (87, "Fr", 223.0),
120
+ (88, "Ra", 226.0),
121
+ (89, "Ac", 227.0),
122
+ (90, "Th", 232.0377),
123
+ (91, "Pa", 231.03588),
124
+ (92, "U", 238.02891),
125
+ (93, "Np", 237.0),
126
+ (94, "Pu", 244.0),
127
+ (95, "Am", 243.0),
128
+ (96, "Cm", 247.0),
129
+ (97, "Bk", 247.0),
130
+ (98, "Cf", 251.0),
131
+ (99, "Es", 252.0),
132
+ (100, "Fm", 257.0),
133
+ (101, "Md", 258.0),
134
+ (102, "No", 259.0),
135
+ (103, "Lr", 266.0),
136
+ (104, "Rf", 267.0),
137
+ (105, "Db", 268.0),
138
+ (106, "Sg", 269.0),
139
+ (107, "Bh", 270.0),
140
+ (108, "Hs", 277.0),
141
+ (109, "Mt", 278.0),
142
+ (110, "Ds", 281.0),
143
+ (111, "Rg", 282.0),
144
+ (112, "Cn", 285.0),
145
+ (113, "Nh", 286.0),
146
+ (114, "Fl", 289.0),
147
+ (115, "Mc", 290.0),
148
+ (116, "Lv", 293.0),
149
+ (117, "Ts", 294.0),
150
+ (118, "Og", 294.0),
151
+ )
152
+
153
+ _BY_Z: Dict[int, Element] = {z: Element(z=z, symbol=symbol, atomic_mass=mass) for z, symbol, mass in _ELEMENT_ROWS}
154
+ _BY_SYMBOL: Dict[str, Element] = {item.symbol.upper(): item for item in _BY_Z.values()}
155
+
156
+
157
+ def _normalize_symbol(symbol: str) -> str:
158
+ """Return canonical case-insensitive symbol key."""
159
+ s = str(symbol).strip()
160
+ if not s:
161
+ raise ValueError("Element symbol cannot be empty.")
162
+ return s.upper()
163
+
164
+
165
+ def all_elements() -> Tuple[Element, ...]:
166
+ """Return all element entries ordered by atomic number."""
167
+ return tuple(_BY_Z[z] for z in sorted(_BY_Z))
168
+
169
+
170
+ def element_from_z(z: int) -> Element:
171
+ """Return Element for atomic number z."""
172
+ z_int = int(z)
173
+ if z_int not in _BY_Z:
174
+ raise KeyError(f"Unknown atomic number: {z_int}")
175
+ return _BY_Z[z_int]
176
+
177
+
178
+ def element_from_symbol(symbol: str) -> Element:
179
+ """Return Element for chemical symbol (case-insensitive)."""
180
+ key = _normalize_symbol(symbol)
181
+ if key not in _BY_SYMBOL:
182
+ raise KeyError(f"Unknown element symbol: {symbol}")
183
+ return _BY_SYMBOL[key]
184
+
185
+
186
+ def element(key: int | str) -> Element:
187
+ """Return Element from atomic number or symbol string."""
188
+ if isinstance(key, int):
189
+ return element_from_z(key)
190
+ if isinstance(key, str):
191
+ s = key.strip()
192
+ if s.isdigit():
193
+ return element_from_z(int(s))
194
+ return element_from_symbol(s)
195
+ raise TypeError("Element key must be int (Z) or str (symbol).")
196
+
197
+
198
+ def atomic_number(symbol: str) -> int:
199
+ """Return atomic number for symbol."""
200
+ return element_from_symbol(symbol).z
201
+
202
+
203
+ def atomic_symbol(z: int) -> str:
204
+ """Return symbol for atomic number."""
205
+ return element_from_z(z).symbol
206
+
207
+
208
+ def atomic_weight(key: int | str) -> float:
209
+ """Return atomic mass (atomic weight) for atomic number or symbol."""
210
+ return element(key).atomic_mass
211
+
212
+
213
+ def has_element(key: int | str) -> bool:
214
+ """Return True if key is a known element symbol or atomic number."""
215
+ try:
216
+ element(key)
217
+ return True
218
+ except (TypeError, ValueError, KeyError):
219
+ return False
220
+
221
+
222
+ __all__ = [
223
+ "Element",
224
+ "all_elements",
225
+ "element",
226
+ "element_from_z",
227
+ "element_from_symbol",
228
+ "atomic_number",
229
+ "atomic_symbol",
230
+ "atomic_weight",
231
+ "has_element",
232
+ ]
233
+
otter/data/helpers.py ADDED
@@ -0,0 +1,131 @@
1
+ """
2
+ otter/data/helpers.py
3
+
4
+ Shared helpers for tests and example scripts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import numpy as np
10
+
11
+ from otter.numerics.constants import CM_TO_BOHR
12
+ from otter.data.elements import atomic_weight as element_atomic_weight
13
+
14
+
15
+ def ion_density_bohr3(rho_g_cc: float, atomic_weight: float) -> float:
16
+ """Return ion number density (Bohr^-3) from mass density and atomic weight."""
17
+ if atomic_weight <= 0.0:
18
+ raise ValueError("atomic_weight must be positive.")
19
+ avogadro = 6.02214076e23
20
+ n_i_cm3 = rho_g_cc / atomic_weight * avogadro
21
+ return n_i_cm3 / (CM_TO_BOHR ** 3)
22
+
23
+
24
+ def resolve_atomic_weight(spec: float | int | str) -> float:
25
+ """
26
+ Resolve atomic weight from a direct mass, atomic number, or symbol.
27
+
28
+ Rules
29
+ -----
30
+ - float -> treated as explicit atomic weight.
31
+ - int -> treated as atomic number Z.
32
+ - str -> symbol (e.g. \"Al\") or integer string (e.g. \"13\").
33
+ """
34
+ if isinstance(spec, bool):
35
+ raise TypeError("Atomic weight spec must not be bool.")
36
+ if isinstance(spec, float):
37
+ if spec <= 0.0:
38
+ raise ValueError("Atomic weight must be positive.")
39
+ return float(spec)
40
+ if isinstance(spec, int):
41
+ return float(element_atomic_weight(spec))
42
+ if isinstance(spec, str):
43
+ s = spec.strip()
44
+ if not s:
45
+ raise ValueError("Atomic weight spec string cannot be empty.")
46
+ if s.replace(".", "", 1).isdigit() and "." in s:
47
+ val = float(s)
48
+ if val <= 0.0:
49
+ raise ValueError("Atomic weight must be positive.")
50
+ return val
51
+ return float(element_atomic_weight(s))
52
+ raise TypeError("Atomic weight spec must be float, int(Z), or str(symbol/Z).")
53
+
54
+
55
+ def mu_guess_from_density(n_i: float, zbar: float = 3.0) -> float:
56
+ """Return a free-electron mu guess from ion density and target Zbar."""
57
+ n0 = zbar * n_i
58
+ return 0.5 * (3.0 * np.pi**2 * n0) ** (2.0 / 3.0)
59
+
60
+
61
+ def grid_step_from_r(r: np.ndarray, grid_kind: str) -> float:
62
+ """Return the grid step for the specified grid kind."""
63
+ if r.size < 2:
64
+ raise ValueError("Grid must have at least two points.")
65
+ grid_kind = str(grid_kind).lower()
66
+ if grid_kind == "log":
67
+ return float(np.log(r[1] / r[0]))
68
+ if grid_kind == "sqrt":
69
+ return float(np.sqrt(r[1]) - np.sqrt(r[0]))
70
+ if grid_kind == "linear":
71
+ return float(r[1] - r[0])
72
+ raise ValueError(f"Unsupported grid_kind: {grid_kind}")
73
+
74
+
75
+ def trapz_integral(y: np.ndarray, x: np.ndarray) -> float:
76
+ """Return a version-independent one-dimensional trapezoidal integral."""
77
+ y_arr = np.asarray(y, dtype=float)
78
+ x_arr = np.asarray(x, dtype=float)
79
+ if y_arr.ndim != 1 or x_arr.ndim != 1 or y_arr.size != x_arr.size:
80
+ raise ValueError("trapz_integral requires equal one-dimensional arrays.")
81
+ if y_arr.size < 2:
82
+ return 0.0
83
+ return float(
84
+ np.sum(0.5 * (y_arr[1:] + y_arr[:-1]) * np.diff(x_arr))
85
+ )
86
+
87
+
88
+ def ion_level_weight(energy: float, gamma: float) -> float:
89
+ """Return the M(e) weight from :cite:`StarrettSaumon2013`, Eq. (81)."""
90
+ if gamma <= 0.0:
91
+ return 1.0
92
+ arg = -2.0 * math.sqrt(math.log(2.0)) * energy / gamma
93
+ m_val = math.erf(arg)
94
+ return float(min(max(m_val, 0.0), 1.0))
95
+
96
+
97
+ def electron_count(r: np.ndarray,
98
+ n: np.ndarray,
99
+ r_ws: float | None = None,
100
+ r_min: float | None = None) -> float:
101
+ """Integrate electron density on r (optionally between r_min and r_ws)."""
102
+ r = np.asarray(r, dtype=float)
103
+ n = np.asarray(n, dtype=float)
104
+ mask = np.ones_like(r, dtype=bool)
105
+ if r_min is not None:
106
+ mask &= r >= float(r_min)
107
+ if r_ws is not None:
108
+ mask &= r <= float(r_ws)
109
+ if not np.any(mask):
110
+ return 0.0
111
+ integrand = n[mask] * r[mask] ** 2
112
+ if hasattr(np, "trapezoid"):
113
+ integral = np.trapezoid(integrand, r[mask])
114
+ else:
115
+ integral = np.trapz(integrand, r[mask])
116
+ return float(4.0 * np.pi * integral)
117
+
118
+
119
+ def tail_stats(r: np.ndarray, n_full: np.ndarray, n_ext: np.ndarray) -> dict:
120
+ """Return tail means for full/ext/pa/scr densities."""
121
+ tail_mask = r >= 0.7 * r[-1]
122
+ if not np.any(tail_mask):
123
+ tail_mask = slice(None)
124
+ n_pa = n_full - n_ext
125
+ n_scr = n_pa
126
+ return {
127
+ "n_full": float(np.mean(n_full[tail_mask])),
128
+ "n_ext": float(np.mean(n_ext[tail_mask])),
129
+ "n_pa": float(np.mean(n_pa[tail_mask])),
130
+ "n_scr": float(np.mean(n_scr[tail_mask])),
131
+ }
@@ -0,0 +1,31 @@
1
+ """Electronic-structure solvers and average-atom workflow drivers."""
2
+ from __future__ import annotations
3
+
4
+ from otter.electronic.full_external import FullExternalConfig, solve_full_only, solve_full_then_external
5
+ from otter.electronic.ks_dft import KSDTFConfig, solve_ks_dft_is
6
+ from otter.electronic.mixture import MixtureConfig, solve_mixture_full_then_ext, solve_mixture_full_only
7
+ from otter.electronic.thomas_fermi import (
8
+ FermiHalfEvaluator,
9
+ ThomasFermiConfig,
10
+ incomplete_fermi_half_lower,
11
+ incomplete_fermi_half_upper,
12
+ solve_thomas_fermi_full_then_external,
13
+ )
14
+ from otter.electronic.xc import xc_provenance
15
+
16
+ __all__ = [
17
+ "FermiHalfEvaluator",
18
+ "FullExternalConfig",
19
+ "KSDTFConfig",
20
+ "MixtureConfig",
21
+ "ThomasFermiConfig",
22
+ "incomplete_fermi_half_lower",
23
+ "incomplete_fermi_half_upper",
24
+ "solve_full_only",
25
+ "solve_full_then_external",
26
+ "solve_ks_dft_is",
27
+ "solve_mixture_full_only",
28
+ "solve_mixture_full_then_ext",
29
+ "solve_thomas_fermi_full_then_external",
30
+ "xc_provenance",
31
+ ]
@@ -0,0 +1,32 @@
1
+ """Continuum-electron models."""
2
+ from __future__ import annotations
3
+
4
+ from otter.electronic.continuum.hybrid import QuantumContinuumHybrid
5
+ from otter.electronic.continuum.ideal import IdealContinuum, ideal_unbound_density
6
+ from otter.electronic.continuum.scattering import (
7
+ QuantumContinuumFree,
8
+ QuantumContinuumScattering,
9
+ continuum_density_free,
10
+ continuum_density_scattering,
11
+ continuum_density_scattering_adaptive,
12
+ continuum_density_scattering_basis,
13
+ fermi_dirac,
14
+ gamma_from_phase_shift_cache,
15
+ )
16
+ from otter.electronic.continuum.tail import apply_tail_match, tail_parameters
17
+
18
+ __all__ = [
19
+ "IdealContinuum",
20
+ "QuantumContinuumFree",
21
+ "QuantumContinuumHybrid",
22
+ "QuantumContinuumScattering",
23
+ "apply_tail_match",
24
+ "continuum_density_free",
25
+ "continuum_density_scattering",
26
+ "continuum_density_scattering_adaptive",
27
+ "continuum_density_scattering_basis",
28
+ "fermi_dirac",
29
+ "gamma_from_phase_shift_cache",
30
+ "ideal_unbound_density",
31
+ "tail_parameters",
32
+ ]
@@ -0,0 +1,124 @@
1
+ """
2
+ otter/electronic/continuum/hybrid.py
3
+
4
+ Purpose
5
+ -------
6
+ Provide a hybrid continuum model: quantum scattering at low energies
7
+ plus an ideal-gas high-energy tail.
8
+
9
+ Methods
10
+ -------
11
+ - Compute low-energy continuum with Numerov scattering (A3).
12
+ - Add a uniform high-energy tail using the ideal free-electron DOS.
13
+ - Default n0_mode uses an integral-based density for consistency.
14
+
15
+ Equations
16
+ ---------
17
+ Ideal DOS contribution (atomic units):
18
+ n = (sqrt(2)/pi^2) * integral_{e0}^{e1} e^(1/2) f(e; mu, T) de
19
+
20
+ Hybrid density:
21
+ n_c(r) = n_low(r; e in [e_min, e_cut]) + n_high (uniform)
22
+
23
+ References
24
+ ----------
25
+ - :cite:`StarrettSaumon2014`, Appendix A/B. The high-energy ideal-gas tail
26
+ and its splice controls are Otter numerical implementation choices.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from typing import Dict
31
+ import numpy as np
32
+
33
+ from .interface import ContinuumModel
34
+ from .scattering import fermi_dirac, QuantumContinuumScattering
35
+ from .ideal import ideal_unbound_density
36
+
37
+
38
+ def _trapz(y: np.ndarray, x: np.ndarray) -> np.ndarray:
39
+ if hasattr(np, "trapezoid"):
40
+ return np.trapezoid(y, x)
41
+ return np.trapz(y, x)
42
+
43
+
44
+ def ideal_density_range(mu: float,
45
+ temperature: float,
46
+ e_min: float,
47
+ e_max: float,
48
+ n_samples: int = 800) -> float:
49
+ """
50
+ Integrate the ideal free-electron density over a finite energy range.
51
+
52
+ Parameters
53
+ ----------
54
+ mu : float
55
+ Chemical potential (Ha).
56
+ temperature : float
57
+ Temperature (Ha).
58
+ e_min : float
59
+ Lower energy bound (Ha).
60
+ e_max : float
61
+ Upper energy bound (Ha).
62
+ n_samples : int
63
+ Number of energy samples.
64
+
65
+ Returns
66
+ -------
67
+ float
68
+ Density contribution over [e_min, e_max] (Bohr^-3).
69
+ """
70
+ if e_max <= e_min:
71
+ return 0.0
72
+ e_min = max(float(e_min), 0.0)
73
+ e_max = float(e_max)
74
+ n_samples = max(int(n_samples), 16)
75
+
76
+ e_grid = np.linspace(e_min, e_max, n_samples)
77
+ occ = fermi_dirac(e_grid, mu, temperature)
78
+ integrand = np.sqrt(np.maximum(e_grid, 0.0)) * occ
79
+ pref = np.sqrt(2.0) / (np.pi ** 2)
80
+ return float(pref * _trapz(integrand, e_grid))
81
+
82
+
83
+ class QuantumContinuumHybrid(ContinuumModel):
84
+ """
85
+ Hybrid continuum: scattering at low energy + ideal high-energy tail.
86
+ """
87
+
88
+ def density(self,
89
+ r: np.ndarray,
90
+ mu: float,
91
+ temperature: float,
92
+ params: Dict[str, float] | None = None) -> np.ndarray:
93
+ params = params or {}
94
+
95
+ v_eff = params.get("v_eff", None)
96
+ if v_eff is None:
97
+ raise ValueError("params['v_eff'] is required for hybrid continuum.")
98
+
99
+ e_min = float(params.get("e_min", 1e-6))
100
+ e_cut = float(params.get("e_cut", max(mu + 4.0 * temperature, 1.0)))
101
+ e_high_max = float(params.get("e_high_max", max(e_cut + 20.0 * temperature, mu + 30.0 * temperature)))
102
+ n_e_low = int(params.get("n_e_low", 120))
103
+ n_e_high = int(params.get("n_e_high", 400))
104
+
105
+ n0_mode = str(params.get("n0_mode", "integral")).lower()
106
+ if n0_mode not in ("ideal", "integral"):
107
+ raise ValueError("n0_mode must be 'ideal' or 'integral'.")
108
+
109
+ scatter = QuantumContinuumScattering()
110
+ low_params = dict(params)
111
+ low_params["e_max"] = e_cut
112
+ low_params["n_e"] = n_e_low
113
+ n_low = scatter.density(r, mu, temperature, params=low_params)
114
+
115
+ if n0_mode == "ideal":
116
+ n0_total = ideal_unbound_density(mu, temperature)
117
+ n0_low = ideal_density_range(mu, temperature, e_min, e_cut, n_samples=n_e_high)
118
+ n_high = max(n0_total - n0_low, 0.0)
119
+ else:
120
+ n0_total = ideal_density_range(mu, temperature, 0.0, e_high_max, n_samples=n_e_high)
121
+ n_high = ideal_density_range(mu, temperature, e_cut, e_high_max, n_samples=n_e_high)
122
+
123
+ n_hybrid = n_low + n_high
124
+ return n_hybrid