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 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