YALIP 0.9.2__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.
- yalip/__init__.py +26 -0
- yalip/ameli.py +420 -0
- yalip/fits.py +656 -0
- yalip/lanthanide.py +162 -0
- yalip/levels.py +269 -0
- yalip/matrix.py +189 -0
- yalip/spectrum.py +209 -0
- yalip/states.py +442 -0
- yalip-0.9.2.dist-info/METADATA +168 -0
- yalip-0.9.2.dist-info/RECORD +13 -0
- yalip-0.9.2.dist-info/WHEEL +5 -0
- yalip-0.9.2.dist-info/licenses/LICENSE +21 -0
- yalip-0.9.2.dist-info/top_level.txt +1 -0
yalip/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
##########################################################################
|
|
2
|
+
# Copyright (c) 2026 Reinhard Caspary #
|
|
3
|
+
# <reinhard.caspary@phoenixd.uni-hannover.de> #
|
|
4
|
+
# This program is free software under the terms of the MIT license. #
|
|
5
|
+
##########################################################################
|
|
6
|
+
#
|
|
7
|
+
# This module provides all names exported by the package.
|
|
8
|
+
#
|
|
9
|
+
##########################################################################
|
|
10
|
+
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
__version__ = "0.9.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Coupling(Enum):
|
|
17
|
+
Product = 0
|
|
18
|
+
SLJM = 1
|
|
19
|
+
SLJ = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
from .lanthanide import LANTHANIDES, RADIAL, JUDD_OFELT, MATERIAL, config2ion, ion2config
|
|
23
|
+
from .spectrum import CONST_e, CONST_eps0, CONST_me, CONST_h, CONST_c, CONST_gs, Cauchy, Sellmeier
|
|
24
|
+
from .states import States
|
|
25
|
+
from .levels import Levels
|
|
26
|
+
from .fits import Fits
|
yalip/ameli.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
##########################################################################
|
|
2
|
+
# Copyright (c) 2026 Reinhard Caspary #
|
|
3
|
+
# <reinhard.caspary@phoenixd.uni-hannover.de> #
|
|
4
|
+
# This program is free software under the terms of the MIT license. #
|
|
5
|
+
##########################################################################
|
|
6
|
+
#
|
|
7
|
+
# This module provides the interface to the AMELI repository of tensor
|
|
8
|
+
# operator matrices on Zenodo. The symbolic matrix elements are
|
|
9
|
+
# carefully converted to floating point values at full IEEE double
|
|
10
|
+
# precision.
|
|
11
|
+
#
|
|
12
|
+
# A cache of local copies of the AMELI files is maintained. Since the
|
|
13
|
+
# repository is expected to be stable, it is checked for new versions
|
|
14
|
+
# at most twice a day.
|
|
15
|
+
#
|
|
16
|
+
# Converted matrices are cached in a local HDF5 cache file and in memory,
|
|
17
|
+
# the latter using functools.lru_cache.
|
|
18
|
+
#
|
|
19
|
+
##########################################################################
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
from functools import total_ordering, lru_cache
|
|
24
|
+
import h5py
|
|
25
|
+
import io
|
|
26
|
+
import logging
|
|
27
|
+
import math
|
|
28
|
+
import numpy as np
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from platformdirs import user_cache_dir
|
|
31
|
+
import requests
|
|
32
|
+
import tempfile
|
|
33
|
+
import zipfile
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("yalip.ameli")
|
|
36
|
+
|
|
37
|
+
AMELI_PATH = Path(user_cache_dir(appname="YALIP", appauthor="REINCAS")) / "ameli"
|
|
38
|
+
MATRIX_PATH = Path(user_cache_dir(appname="YALIP", appauthor="REINCAS")) / "matrix"
|
|
39
|
+
|
|
40
|
+
ZENODO_API = "https://zenodo.org/api"
|
|
41
|
+
ZENODO_RECORD = f"{ZENODO_API}/records"
|
|
42
|
+
ZENODO_REFRESH_HOURS = 12
|
|
43
|
+
RECORDS = {
|
|
44
|
+
"f1": 19130697,
|
|
45
|
+
"f2": 19139480,
|
|
46
|
+
"f3": 19144764,
|
|
47
|
+
"f4": 19154321,
|
|
48
|
+
"f5": 19154326,
|
|
49
|
+
"f6": 19158643,
|
|
50
|
+
"f7": 19158647,
|
|
51
|
+
"f8": 19158658,
|
|
52
|
+
"f9": 19158660,
|
|
53
|
+
"f10": 19158667,
|
|
54
|
+
"f11": 19158671,
|
|
55
|
+
"f12": 19158675,
|
|
56
|
+
"f13": 19158677,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
##########################################################################
|
|
61
|
+
# Update of AMELI matrices
|
|
62
|
+
##########################################################################
|
|
63
|
+
|
|
64
|
+
@total_ordering
|
|
65
|
+
class Version:
|
|
66
|
+
""" Class of a version string with major, minor, and patch numbers. Provides convenient value comparison
|
|
67
|
+
operations. """
|
|
68
|
+
|
|
69
|
+
def __init__(self, version_str):
|
|
70
|
+
self.original_str = version_str
|
|
71
|
+
cleaned = version_str.lower().lstrip('v')
|
|
72
|
+
parts = cleaned.split('.')
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
self.major = int(parts[0]) if len(parts) > 0 else 0
|
|
76
|
+
self.minor = int(parts[1]) if len(parts) > 1 else 0
|
|
77
|
+
self.patch = int(parts[2]) if len(parts) > 2 else 0
|
|
78
|
+
except (ValueError, IndexError) as e:
|
|
79
|
+
raise ValueError(f"Invalid version format: {version_str}") from e
|
|
80
|
+
|
|
81
|
+
def _as_tuple(self):
|
|
82
|
+
return (self.major, self.minor, self.patch)
|
|
83
|
+
|
|
84
|
+
def __eq__(self, other):
|
|
85
|
+
if not isinstance(other, Version):
|
|
86
|
+
return NotImplemented
|
|
87
|
+
return self._as_tuple() == other._as_tuple()
|
|
88
|
+
|
|
89
|
+
def __lt__(self, other):
|
|
90
|
+
if not isinstance(other, Version):
|
|
91
|
+
return NotImplemented
|
|
92
|
+
return self._as_tuple() < other._as_tuple()
|
|
93
|
+
|
|
94
|
+
def __repr__(self):
|
|
95
|
+
return f"Version({self.major}.{self.minor}.{self.patch})"
|
|
96
|
+
|
|
97
|
+
def __str__(self):
|
|
98
|
+
return f"{self.major}.{self.minor}.{self.patch}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_local_version(config_path):
|
|
102
|
+
""" Read the first non-empty line from file "VERSION" in the configuration folder and return it as Version
|
|
103
|
+
and datetime objects. Return None as version if the file is missing yet. """
|
|
104
|
+
|
|
105
|
+
version_file = Path(config_path) / "VERSION"
|
|
106
|
+
|
|
107
|
+
# Return None if the file doesn't exist
|
|
108
|
+
if not version_file.exists():
|
|
109
|
+
return None, None
|
|
110
|
+
|
|
111
|
+
# Read file and split into lines
|
|
112
|
+
content = version_file.read_text(encoding="utf-8").strip()
|
|
113
|
+
assert content, "Empty VERSION file!"
|
|
114
|
+
assert "\n" not in content, "VERSION file contains multiple lines!"
|
|
115
|
+
|
|
116
|
+
# This will raise an Exception internally if the content is invalid
|
|
117
|
+
version, timestamp = content.split(":", 1)
|
|
118
|
+
version = Version(version.strip())
|
|
119
|
+
timestamp = datetime.fromisoformat(timestamp.strip())
|
|
120
|
+
return version, timestamp
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_zenodo_version(concept_id):
|
|
124
|
+
""" Return version string of given Zenodo concept ID or None in case of an error. """
|
|
125
|
+
|
|
126
|
+
url = f"{ZENODO_RECORD}/{concept_id}"
|
|
127
|
+
|
|
128
|
+
# Try to read Zenodo record, but ignore it if it not accessible yet
|
|
129
|
+
try:
|
|
130
|
+
response = requests.get(url, timeout=3)
|
|
131
|
+
response.raise_for_status()
|
|
132
|
+
except requests.Timeout:
|
|
133
|
+
logger.info(f"Zenodo timeout on record {concept_id}")
|
|
134
|
+
return None
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.warning(f"Warning: Access to record {concept_id} failed ({e})")
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
data = response.json()
|
|
140
|
+
version = data["metadata"]["version"]
|
|
141
|
+
return Version(version)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def download_files(concept_id, filenames, config_path):
|
|
145
|
+
""" Download multiple ZIP files from the given Zenodo record to a temporary folder. Extract them into the
|
|
146
|
+
config_path if all downloads succeed, then clean up. """
|
|
147
|
+
|
|
148
|
+
# Sanity check
|
|
149
|
+
config_path = Path(config_path)
|
|
150
|
+
assert config_path.exists()
|
|
151
|
+
|
|
152
|
+
# Get record metadata
|
|
153
|
+
url = f"{ZENODO_RECORD}/{concept_id}"
|
|
154
|
+
response = requests.get(url)
|
|
155
|
+
response.raise_for_status()
|
|
156
|
+
data = response.json()
|
|
157
|
+
|
|
158
|
+
# Record version
|
|
159
|
+
version = Version(data["metadata"]["version"])
|
|
160
|
+
|
|
161
|
+
# Metadata of all files in the record
|
|
162
|
+
files_in_record = data.get('files', [])
|
|
163
|
+
|
|
164
|
+
# Use a temporary directory context manager. Folder and contents are deleted when the "with" block is left.
|
|
165
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
166
|
+
tmp_path = Path(tmp_dir)
|
|
167
|
+
|
|
168
|
+
# Initialise list of temporary local file paths
|
|
169
|
+
downloaded_paths = []
|
|
170
|
+
|
|
171
|
+
# Download all files to the temporary folder
|
|
172
|
+
for filename in filenames:
|
|
173
|
+
assert filename.endswith(".zip")
|
|
174
|
+
|
|
175
|
+
# Metadata of this file
|
|
176
|
+
file_data = next((f for f in files_in_record if f['key'] == filename), None)
|
|
177
|
+
if not file_data:
|
|
178
|
+
message = f"Error: File '{filename}' not found in Zenodo record {concept_id}"
|
|
179
|
+
logger.error(message)
|
|
180
|
+
raise Exception(message)
|
|
181
|
+
|
|
182
|
+
# Download the file as stream to stay memory-efficient for large files
|
|
183
|
+
local_tmp_file = tmp_path / filename
|
|
184
|
+
with requests.get(file_data["links"]["self"], stream=True) as r:
|
|
185
|
+
r.raise_for_status()
|
|
186
|
+
with open(local_tmp_file, 'wb') as f:
|
|
187
|
+
for chunk in r.iter_content(chunk_size=8192):
|
|
188
|
+
f.write(chunk)
|
|
189
|
+
|
|
190
|
+
# Append temporary file path
|
|
191
|
+
downloaded_paths.append(local_tmp_file)
|
|
192
|
+
|
|
193
|
+
# Extract files
|
|
194
|
+
for zip_path in downloaded_paths:
|
|
195
|
+
with zipfile.ZipFile(zip_path) as zip_ref:
|
|
196
|
+
zip_ref.extractall(config_path)
|
|
197
|
+
|
|
198
|
+
# Remove hashes file
|
|
199
|
+
hashes = config_path / "hashes.json"
|
|
200
|
+
if hashes.exists():
|
|
201
|
+
hashes.unlink()
|
|
202
|
+
|
|
203
|
+
# Update the VERSION file
|
|
204
|
+
update_version(version, config_path)
|
|
205
|
+
logger.debug(f"Zenodo record {concept_id} updated to {version} in {config_path}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def update_version(version, path):
|
|
209
|
+
""" Update VERSION file with current timestamp. """
|
|
210
|
+
|
|
211
|
+
version_file = path / "VERSION"
|
|
212
|
+
timestamp = datetime.now().isoformat(timespec='seconds')
|
|
213
|
+
content = f"{version}: {timestamp}"
|
|
214
|
+
version_file.write_text(content, encoding="utf-8")
|
|
215
|
+
logger.debug(f"Zenodo version {version} timestamp updated in {path}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def remove_vault(config):
|
|
219
|
+
""" Remove the cache file of converted matrices. """
|
|
220
|
+
|
|
221
|
+
path = MATRIX_PATH / config
|
|
222
|
+
if path.exists():
|
|
223
|
+
for file in path.iterdir():
|
|
224
|
+
file.unlink()
|
|
225
|
+
path.rmdir()
|
|
226
|
+
logger.debug(f"Local matrix vault for configuration {config} removed")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def update(config, force=False):
|
|
230
|
+
logger.debug(f"Updating configuration {config} with force={force}")
|
|
231
|
+
|
|
232
|
+
path = AMELI_PATH / config
|
|
233
|
+
if not path.exists():
|
|
234
|
+
path.mkdir(parents=True)
|
|
235
|
+
|
|
236
|
+
local_version, timestamp = get_local_version(path)
|
|
237
|
+
if local_version is None:
|
|
238
|
+
logger.debug(f"Found no local version of configuration {config}")
|
|
239
|
+
else:
|
|
240
|
+
logger.debug(f"Found local version {local_version} of configuration {config} ({timestamp})")
|
|
241
|
+
if local_version is not None and datetime.now() - timestamp < timedelta(hours=ZENODO_REFRESH_HOURS) and not force:
|
|
242
|
+
logger.debug(f"Local version {local_version} is fresh")
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
concept_id = RECORDS[config]
|
|
246
|
+
zenodo_version = get_zenodo_version(concept_id)
|
|
247
|
+
logger.debug(f"Zenodo record {concept_id} has version {zenodo_version}")
|
|
248
|
+
|
|
249
|
+
if zenodo_version is None:
|
|
250
|
+
assert local_version is not None, f"Cannot download data from Zenodo record {concept_id}!"
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
if local_version is None or local_version < zenodo_version:
|
|
254
|
+
filenames = ("product.zip", "sljm.zip", "slj.zip")
|
|
255
|
+
download_files(concept_id, filenames, path)
|
|
256
|
+
remove_vault(config)
|
|
257
|
+
else:
|
|
258
|
+
update_version(local_version, path)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
##########################################################################
|
|
262
|
+
# Decode square roots of rationals
|
|
263
|
+
##########################################################################
|
|
264
|
+
|
|
265
|
+
def decode_scalar(s: int, n: int, d: int) -> float:
|
|
266
|
+
""" Return (-1)^s * sqrt(n/d) for the given sign, numerator and denominator in optimised numerical precision. """
|
|
267
|
+
|
|
268
|
+
assert n > 0
|
|
269
|
+
assert d > 0
|
|
270
|
+
assert s in (0, 1)
|
|
271
|
+
|
|
272
|
+
# Size of n/d in bits
|
|
273
|
+
current_diff = n.bit_length() - d.bit_length()
|
|
274
|
+
|
|
275
|
+
# Mantissa of Python float is 53 bits and 60 bits provides a safety margin
|
|
276
|
+
shift = 60 - current_diff // 2
|
|
277
|
+
|
|
278
|
+
# Scale numerator or denominator
|
|
279
|
+
if shift > 0:
|
|
280
|
+
numerator = n << 2 * shift
|
|
281
|
+
denominator = d
|
|
282
|
+
else:
|
|
283
|
+
numerator = n
|
|
284
|
+
denominator = d << -2 * shift
|
|
285
|
+
|
|
286
|
+
# Calculate scaled square root
|
|
287
|
+
root_scaled = math.isqrt(numerator // denominator)
|
|
288
|
+
|
|
289
|
+
# Scale back and avoid potential exponent overflow
|
|
290
|
+
value = math.ldexp(root_scaled, -shift)
|
|
291
|
+
if s:
|
|
292
|
+
value = -value
|
|
293
|
+
return value
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# Vectorised decoder function
|
|
297
|
+
v_decode = np.frompyfunc(decode_scalar, 3, 1)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def decode_vector(s, n, d):
|
|
301
|
+
""" Return (-1)^s * sqrt(n/d) for each element of the given lists. """
|
|
302
|
+
|
|
303
|
+
assert len(s) == len(n) == len(d)
|
|
304
|
+
return v_decode(s, n, d).astype(float)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def decode_uint_array(meta, name):
|
|
308
|
+
""" Decode an array encoded by 'encode_large()' with given base-name from the dictionary meta and return it as
|
|
309
|
+
list. Each processed item is removed from meta. """
|
|
310
|
+
|
|
311
|
+
# Single storage array
|
|
312
|
+
if name in meta:
|
|
313
|
+
array = meta[name][:].astype(object)
|
|
314
|
+
|
|
315
|
+
# Combine large-element array stored in multiple parts
|
|
316
|
+
else:
|
|
317
|
+
keys = [key for key in meta if key.startswith(f"{name}_part")]
|
|
318
|
+
keys.sort()
|
|
319
|
+
array = meta[keys[-1]][:].astype(object)
|
|
320
|
+
for key in reversed(keys[:-1]):
|
|
321
|
+
bits = meta[key].dtype.itemsize * 8
|
|
322
|
+
array = (array << bits) | meta[key][:].astype(object)
|
|
323
|
+
|
|
324
|
+
# Return restored array as list
|
|
325
|
+
return array
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
##########################################################################
|
|
329
|
+
# Decode AMELI matrices
|
|
330
|
+
##########################################################################
|
|
331
|
+
|
|
332
|
+
def matrix_path(config, state_space, name):
|
|
333
|
+
""" Return path to the given AMELI matrix container. """
|
|
334
|
+
|
|
335
|
+
name = name.replace("/", "_").replace(",", "_")
|
|
336
|
+
path = AMELI_PATH / config / state_space.lower() / f"{name}.zdc"
|
|
337
|
+
if not path.exists():
|
|
338
|
+
message = f"Matrix file '{path}' does not exist!"
|
|
339
|
+
try:
|
|
340
|
+
concept_id = RECORDS[config]
|
|
341
|
+
url = f"{ZENODO_RECORD}/{concept_id}"
|
|
342
|
+
message += f" Check {url}."
|
|
343
|
+
except:
|
|
344
|
+
pass
|
|
345
|
+
logger.error(message)
|
|
346
|
+
raise ValueError(message)
|
|
347
|
+
return path
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def read_matrix(path, item):
|
|
351
|
+
""" Return a float representation of the given AMELI matrix. """
|
|
352
|
+
|
|
353
|
+
with zipfile.ZipFile(path, "r") as z:
|
|
354
|
+
with z.open(item) as f:
|
|
355
|
+
data = io.BytesIO(f.read())
|
|
356
|
+
|
|
357
|
+
root = h5py.File(data, "r")
|
|
358
|
+
|
|
359
|
+
sign = decode_uint_array(root, "sign")
|
|
360
|
+
numerator = decode_uint_array(root, "numerator")
|
|
361
|
+
denominator = decode_uint_array(root, "denominator")
|
|
362
|
+
values = decode_vector(sign, numerator, denominator)
|
|
363
|
+
|
|
364
|
+
is_symmetric = root.attrs["isSymmetric"]
|
|
365
|
+
num_states = root.attrs["numStates"]
|
|
366
|
+
matrix = np.zeros((num_states, num_states), dtype=float)
|
|
367
|
+
for row, col, index in zip(root["rows"], root["columns"], root["elements"]):
|
|
368
|
+
value = values[index]
|
|
369
|
+
matrix[row, col] = value
|
|
370
|
+
if row != col and is_symmetric:
|
|
371
|
+
matrix[col, row] = value
|
|
372
|
+
return matrix
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def read_indices(path, item):
|
|
376
|
+
""" Return a float representation of the given AMELI matrix. """
|
|
377
|
+
|
|
378
|
+
with zipfile.ZipFile(path, "r") as z:
|
|
379
|
+
with z.open(item) as f:
|
|
380
|
+
data = io.BytesIO(f.read())
|
|
381
|
+
return np.array(h5py.File(data, "r")["indices"])
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def read_json(path, item):
|
|
385
|
+
""" Return a JSON file from a data container. """
|
|
386
|
+
|
|
387
|
+
with zipfile.ZipFile(path, "r") as z:
|
|
388
|
+
with z.open(item) as f:
|
|
389
|
+
return json.loads(f.read())
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
##########################################################################
|
|
393
|
+
# AMELI interface
|
|
394
|
+
##########################################################################
|
|
395
|
+
|
|
396
|
+
@lru_cache
|
|
397
|
+
def get_ameli_matrix(name, config, state_space):
|
|
398
|
+
""" Read and convert AMELI matrix. """
|
|
399
|
+
|
|
400
|
+
assert state_space in ("slj_reduced", "slj", "sljm", "product")
|
|
401
|
+
path = matrix_path(config, state_space, name)
|
|
402
|
+
return read_matrix(path, "data/matrix.hdf5")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@lru_cache
|
|
406
|
+
def get_ameli_transform(config):
|
|
407
|
+
""" Read basis states from the AMELI container transform.zdc. """
|
|
408
|
+
|
|
409
|
+
update(config)
|
|
410
|
+
path = AMELI_PATH / config / "transform.zdc"
|
|
411
|
+
meta = read_json(path, "data/transform.json")
|
|
412
|
+
transform = {
|
|
413
|
+
"electronPool": meta["row_states"]["electronPool"],
|
|
414
|
+
"rowStates": read_indices(path, "data/row_states.hdf5"),
|
|
415
|
+
"tensorChain": meta["col_states"]["tensorChain"],
|
|
416
|
+
"irreducibleRepresentations": meta["col_states"]["irreducibleRepresentations"],
|
|
417
|
+
"colStates": read_indices(path, "data/col_states.hdf5"),
|
|
418
|
+
"transform": read_matrix(path, "data/matrix.hdf5"),
|
|
419
|
+
}
|
|
420
|
+
return transform
|