ANYsolver 0.1.0__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 (70) hide show
  1. anysolver/__init__.py +631 -0
  2. anysolver/anystructure_fem_mode.py +942 -0
  3. anysolver/arc_length.py +758 -0
  4. anysolver/assembly.py +950 -0
  5. anysolver/baselines.py +303 -0
  6. anysolver/beam_shell_verification.py +4276 -0
  7. anysolver/beam_validity.py +110 -0
  8. anysolver/benchmarks.py +495 -0
  9. anysolver/boundary.py +476 -0
  10. anysolver/buckling.py +442 -0
  11. anysolver/buckling_validity.py +91 -0
  12. anysolver/capacity_workflow.py +350 -0
  13. anysolver/cases.py +173 -0
  14. anysolver/composite_strip_verification.py +297 -0
  15. anysolver/contact.py +3461 -0
  16. anysolver/corotational.py +371 -0
  17. anysolver/cylinder_benchmarks.py +364 -0
  18. anysolver/dynamics.py +723 -0
  19. anysolver/element_qualification.py +432 -0
  20. anysolver/elements.py +2724 -0
  21. anysolver/external_references.py +369 -0
  22. anysolver/fe_core.py +327 -0
  23. anysolver/fracture.py +551 -0
  24. anysolver/imperfections.py +390 -0
  25. anysolver/jit_compiler.py +108 -0
  26. anysolver/kernel_warmup.py +180 -0
  27. anysolver/linalg.py +760 -0
  28. anysolver/mass_properties.py +179 -0
  29. anysolver/material_curves.py +231 -0
  30. anysolver/matrix_assembly.py +558 -0
  31. anysolver/mesh_gen.py +1065 -0
  32. anysolver/mesh_load_bc_verification.py +609 -0
  33. anysolver/modal.py +282 -0
  34. anysolver/nonlinear.py +300 -0
  35. anysolver/nonlinear_performance.py +920 -0
  36. anysolver/nonlinear_performance_batch_b.py +592 -0
  37. anysolver/nonlinear_performance_batch_c.py +506 -0
  38. anysolver/nonlinear_performance_bootstrap.py +120 -0
  39. anysolver/nonlinear_reduced_assembly.py +760 -0
  40. anysolver/nonlinear_static.py +1518 -0
  41. anysolver/plasticity.py +419 -0
  42. anysolver/plasticity_qualification.py +314 -0
  43. anysolver/production_readiness.py +304 -0
  44. anysolver/quality_control.py +1497 -0
  45. anysolver/recovery.py +505 -0
  46. anysolver/recovery_policy.py +205 -0
  47. anysolver/reference_cases.py +425 -0
  48. anysolver/results.py +503 -0
  49. anysolver/runtime.py +9030 -0
  50. anysolver/s4_validity.py +326 -0
  51. anysolver/sesam_fem/__init__.py +55 -0
  52. anysolver/sesam_fem/__main__.py +80 -0
  53. anysolver/sesam_fem/diagnostics.py +65 -0
  54. anysolver/sesam_fem/document.py +814 -0
  55. anysolver/sesam_fem/exporter.py +84 -0
  56. anysolver/sesam_fem/importer.py +365 -0
  57. anysolver/sesam_fem/records.py +257 -0
  58. anysolver/sesam_fem/schema.py +107 -0
  59. anysolver/sesam_fem/sif_importer.py +397 -0
  60. anysolver/sesam_fem/validation.py +62 -0
  61. anysolver/shell_benchmarks.py +367 -0
  62. anysolver/test_cases.py +610 -0
  63. anysolver/validation.py +416 -0
  64. anysolver/vectorized_nonlinear.py +334 -0
  65. anysolver/vectorized_stiffness.py +571 -0
  66. anysolver-0.1.0.dist-info/METADATA +165 -0
  67. anysolver-0.1.0.dist-info/RECORD +70 -0
  68. anysolver-0.1.0.dist-info/WHEEL +5 -0
  69. anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
  70. anysolver-0.1.0.dist-info/top_level.txt +1 -0
anysolver/linalg.py ADDED
@@ -0,0 +1,760 @@
1
+ """Sparse linear algebra backends for FE analyses.
2
+
3
+ SciPy SuperLU is the universal fallback. ``AutoSparseSolverBackend`` can select
4
+ PyPardiso for sufficiently large systems, with matrix-class-aware mtypes,
5
+ pattern-slot symbolic reuse, bounded cache size, and general-matrix fallback.
6
+ All paths report backend, ordering, timings, reuse, and failure diagnostics
7
+ through the same factorization-handle API.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ctypes
13
+ import importlib.util
14
+ import site
15
+ import sys
16
+ import time
17
+ import warnings
18
+ import weakref
19
+ from dataclasses import dataclass, field
20
+ from enum import Enum
21
+ import hashlib
22
+ import json
23
+ import os
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Mapping, Optional, Tuple
26
+
27
+ import numpy as np
28
+ from scipy import sparse
29
+ from scipy.sparse import SparseEfficiencyWarning
30
+ from scipy.sparse.linalg import LinearOperator, splu
31
+
32
+ try:
33
+ _HAS_PYPARDISO = importlib.util.find_spec("pypardiso") is not None
34
+ except (ImportError, ModuleNotFoundError, ValueError):
35
+ _HAS_PYPARDISO = False
36
+
37
+ _PYPARDISO_SOLVER_CLASS: Any = None
38
+
39
+
40
+ def _new_pypardiso_solver(*, mtype: int) -> Any:
41
+ """Construct PyPardiso only after the backend has actually been selected."""
42
+
43
+ global _PYPARDISO_SOLVER_CLASS
44
+ if not _HAS_PYPARDISO:
45
+ raise ImportError("pypardiso is not installed")
46
+ if _PYPARDISO_SOLVER_CLASS is None:
47
+ from pypardiso import PyPardisoSolver as solver_class
48
+
49
+ _PYPARDISO_SOLVER_CLASS = solver_class
50
+ return _PYPARDISO_SOLVER_CLASS(mtype=int(mtype))
51
+
52
+ try:
53
+ from numba import njit, prange
54
+ _HAS_NUMBA = True
55
+ except ImportError:
56
+ def njit(*args, **kwargs):
57
+ def wrapper(func):
58
+ return func
59
+ if len(args) == 1 and callable(args[0]):
60
+ return args[0]
61
+ return wrapper
62
+ prange = range
63
+ _HAS_NUMBA = False
64
+
65
+
66
+ def _env_int(name: str, default: int) -> int:
67
+ value = os.environ.get(name)
68
+ if value is None or value == "":
69
+ return int(default)
70
+ try:
71
+ parsed = int(value)
72
+ except ValueError:
73
+ return int(default)
74
+ return parsed if parsed > 0 else int(default)
75
+
76
+
77
+ class MatrixClass(str, Enum):
78
+ """Declared numerical class of a sparse matrix."""
79
+
80
+ SPD = "spd"
81
+ SYMMETRIC_SEMIDEFINITE = "symmetric_semidefinite"
82
+ SYMMETRIC_INDEFINITE = "symmetric_indefinite"
83
+ GENERAL = "general"
84
+
85
+
86
+ @dataclass
87
+ class FactorizationHandle:
88
+ """Reusable sparse factorization and solve diagnostics."""
89
+
90
+ matrix_shape: tuple[int, int]
91
+ matrix_class: MatrixClass
92
+ backend_name: str
93
+ ordering: str
94
+ signature: Optional[str]
95
+ factorization_time: float
96
+ factorization_count: int = 1
97
+ solve_count: int = 0
98
+ solve_time: float = 0.0
99
+ status: str = "ok"
100
+ failure_reason: Optional[str] = None
101
+ metadata: Dict[str, Any] = field(default_factory=dict)
102
+ _solver: Any = field(default=None, repr=False)
103
+
104
+ def solve(self, rhs: np.ndarray) -> np.ndarray:
105
+ return solve(self, rhs)
106
+
107
+ def solve_many(self, rhs_matrix: np.ndarray) -> np.ndarray:
108
+ return solve_many(self, rhs_matrix)
109
+
110
+ def diagnostics(self) -> Dict[str, Any]:
111
+ return {
112
+ "backend": self.backend_name,
113
+ "matrix_class": self.matrix_class.value,
114
+ "ordering": self.ordering,
115
+ "signature": self.signature,
116
+ "shape": list(self.matrix_shape),
117
+ "status": self.status,
118
+ "failure_reason": self.failure_reason,
119
+ "factorization_time": self.factorization_time,
120
+ "factorization_count": self.factorization_count,
121
+ "solve_count": self.solve_count,
122
+ "solve_time": self.solve_time,
123
+ **self.metadata,
124
+ }
125
+
126
+
127
+ class SparseSolverBackend:
128
+ """SciPy/SuperLU sparse backend with a stable FE-facing interface."""
129
+
130
+ name = "scipy_superlu"
131
+
132
+ def factorize(
133
+ self,
134
+ matrix: sparse.spmatrix,
135
+ matrix_class: MatrixClass,
136
+ *,
137
+ signature: Optional[str] = None,
138
+ options: Optional[Mapping[str, Any]] = None,
139
+ ) -> FactorizationHandle:
140
+ options = dict(options or {})
141
+ ordering = str(options.get("ordering", "COLAMD"))
142
+ start = time.time()
143
+ try:
144
+ csc = sparse.csc_matrix(matrix)
145
+ solver = splu(csc, permc_spec=ordering)
146
+ except Exception as exc:
147
+ return FactorizationHandle(
148
+ matrix_shape=tuple(int(v) for v in matrix.shape),
149
+ matrix_class=matrix_class,
150
+ backend_name=self.name,
151
+ ordering=ordering,
152
+ signature=signature,
153
+ factorization_time=time.time() - start,
154
+ status="failed",
155
+ failure_reason=str(exc),
156
+ )
157
+ return FactorizationHandle(
158
+ matrix_shape=tuple(int(v) for v in matrix.shape),
159
+ matrix_class=matrix_class,
160
+ backend_name=self.name,
161
+ ordering=ordering,
162
+ signature=signature,
163
+ factorization_time=time.time() - start,
164
+ _solver=solver,
165
+ )
166
+
167
+
168
+ _MKL_RT_ENV_READY = False
169
+
170
+
171
+ def _ensure_mkl_rt_env() -> None:
172
+ """Locate mkl_rt once before importing PyPardiso.
173
+
174
+ Windows MKL wheels normally place the runtime directly in
175
+ ``<python>/Library/bin``. Probe a small set of standard locations
176
+ non-recursively; pypardiso's recursive fallback can otherwise dominate
177
+ first-use time.
178
+ """
179
+
180
+ global _MKL_RT_ENV_READY
181
+ if _MKL_RT_ENV_READY or not _HAS_PYPARDISO:
182
+ return
183
+ if os.environ.get("PYPARDISO_MKL_RT"):
184
+ _MKL_RT_ENV_READY = True
185
+ return
186
+ from ctypes.util import find_library
187
+
188
+ path = find_library("mkl_rt") or find_library("mkl_rt.1")
189
+ if path is None:
190
+ prefixes = [os.environ.get("CONDA_PREFIX"), sys.prefix, site.USER_BASE]
191
+ directories: List[Path] = []
192
+ for prefix in prefixes:
193
+ if not prefix:
194
+ continue
195
+ base = Path(prefix)
196
+ directories.extend((base / "Library" / "bin", base / "DLLs", base / "lib"))
197
+ candidates: List[Path] = []
198
+ for directory in directories:
199
+ try:
200
+ candidates.extend(directory.glob("mkl_rt*"))
201
+ except OSError:
202
+ continue
203
+ unique_candidates = {
204
+ os.path.normcase(os.path.abspath(str(candidate)))
205
+ for candidate in candidates
206
+ }
207
+ for candidate_text in sorted(unique_candidates, key=len):
208
+ try:
209
+ ctypes.CDLL(candidate_text)
210
+ except OSError:
211
+ continue
212
+ path = candidate_text
213
+ break
214
+ if path:
215
+ os.environ["PYPARDISO_MKL_RT"] = path
216
+ _MKL_RT_ENV_READY = True
217
+
218
+
219
+ def _pardiso_mtype_candidates(matrix_class: MatrixClass) -> Tuple[int, ...]:
220
+ """PARDISO mtypes to try for a declared matrix class, best first.
221
+
222
+ Symmetric classes factorize the upper triangle (mtype 2 Cholesky for SPD,
223
+ mtype -2 Bunch-Kaufman otherwise) with the general real path (mtype 11) as
224
+ the final fallback.
225
+ """
226
+
227
+ if matrix_class == MatrixClass.SPD:
228
+ return (2, -2, 11)
229
+ if matrix_class in (MatrixClass.SYMMETRIC_SEMIDEFINITE, MatrixClass.SYMMETRIC_INDEFINITE):
230
+ return (-2, 11)
231
+ return (11,)
232
+
233
+
234
+ def _pardiso_prepared_matrix(csr: sparse.csr_matrix, mtype: int) -> sparse.csr_matrix:
235
+ """Return the CSR matrix PARDISO should factorize for the given mtype.
236
+
237
+ Symmetric mtypes take the upper triangle only; MKL additionally requires
238
+ every diagonal entry to be structurally present, so missing diagonals are
239
+ inserted as explicit zeros.
240
+ """
241
+
242
+ if mtype == 11:
243
+ return csr
244
+ upper = sparse.triu(csr, format="csr")
245
+ diagonal = upper.diagonal()
246
+ if np.any(diagonal == 0.0):
247
+ with warnings.catch_warnings():
248
+ warnings.simplefilter("ignore", SparseEfficiencyWarning)
249
+ upper.setdiag(diagonal)
250
+ upper = sparse.csr_matrix(upper)
251
+ upper.sort_indices()
252
+ return upper
253
+
254
+
255
+ def _release_mkl_solver(solver: Any) -> None:
256
+ """Release MKL's internal factorization memory (PARDISO phase -1)."""
257
+ try:
258
+ solver.free_memory(everything=True)
259
+ except Exception:
260
+ pass
261
+
262
+
263
+ def _pardiso_full_factorize(solver: Any, prepared: sparse.csr_matrix) -> None:
264
+ solver._check_A(prepared)
265
+ solver.set_phase(12)
266
+ solver._call_pardiso(prepared, np.zeros((prepared.shape[0], 1)))
267
+
268
+
269
+ class _PardisoPatternSlot:
270
+ """One retained PARDISO instance per sparsity pattern for analysis reuse."""
271
+
272
+ __slots__ = ("solver", "mtype", "shape", "indptr", "indices", "generation")
273
+
274
+ def __init__(self, solver: Any, mtype: int, prepared: sparse.csr_matrix):
275
+ self.solver = solver
276
+ self.mtype = int(mtype)
277
+ self.shape = tuple(int(v) for v in prepared.shape)
278
+ self.indptr = prepared.indptr.copy()
279
+ self.indices = prepared.indices.copy()
280
+ self.generation = 0
281
+
282
+ def matches(self, prepared: sparse.csr_matrix, mtype: int) -> bool:
283
+ return (
284
+ self.solver is not None
285
+ and self.mtype == int(mtype)
286
+ and self.shape == tuple(int(v) for v in prepared.shape)
287
+ and self.indptr.size == prepared.indptr.size
288
+ and self.indices.size == prepared.indices.size
289
+ and np.array_equal(self.indptr, prepared.indptr)
290
+ and np.array_equal(self.indices, prepared.indices)
291
+ )
292
+
293
+ def release(self) -> None:
294
+ self.generation += 1
295
+ solver, self.solver = self.solver, None
296
+ if solver is not None:
297
+ _release_mkl_solver(solver)
298
+
299
+
300
+ class _PardisoFactorization:
301
+ """Solve interface bound to a pattern slot.
302
+
303
+ The slot's PARDISO instance holds the factorization of the *most recent*
304
+ matrix with that sparsity pattern. A handle created earlier therefore
305
+ checks a generation token before solving; if the slot has moved on (or was
306
+ evicted), the handle transparently refactorizes its own matrix into a
307
+ private solver whose MKL memory is released by a finalizer when the handle
308
+ is garbage collected.
309
+ """
310
+
311
+ def __init__(self, slot: _PardisoPatternSlot, prepared: sparse.csr_matrix, mtype: int):
312
+ self._slot: Optional[_PardisoPatternSlot] = slot
313
+ self._generation = slot.generation
314
+ self._matrix = prepared
315
+ self._mtype = int(mtype)
316
+ self._private_solver: Any = None
317
+ self.stale_rebuild_count = 0
318
+
319
+ def _active_solver(self) -> Any:
320
+ if self._private_solver is not None:
321
+ return self._private_solver
322
+ slot = self._slot
323
+ if slot is not None and slot.solver is not None and slot.generation == self._generation:
324
+ return slot.solver
325
+ solver = _new_pypardiso_solver(mtype=self._mtype)
326
+ _pardiso_full_factorize(solver, self._matrix)
327
+ self._private_solver = solver
328
+ weakref.finalize(self, _release_mkl_solver, solver)
329
+ self._slot = None
330
+ self.stale_rebuild_count += 1
331
+ return solver
332
+
333
+ def solve(self, rhs: np.ndarray) -> np.ndarray:
334
+ solver = self._active_solver()
335
+ b = solver._check_b(self._matrix, np.asarray(rhs, dtype=np.float64))
336
+ solver.set_phase(33)
337
+ return solver._call_pardiso(self._matrix, b)
338
+
339
+
340
+ class PyPardisoSolverBackend:
341
+ """Intel MKL PARDISO backend using pypardiso.
342
+
343
+ Optimizations over naive pypardiso usage:
344
+
345
+ - mkl_rt is resolved once per process (``PYPARDISO_MKL_RT``) instead of
346
+ re-searching the filesystem on every ``PyPardisoSolver`` construction;
347
+ - symmetric matrix classes factorize the upper triangle with symmetric
348
+ mtypes (2 / -2), falling back to the general path on numerical failure;
349
+ - refactorizations with an unchanged sparsity pattern reuse the symbolic
350
+ analysis (PARDISO phase 22) through a small LRU of pattern slots;
351
+ - MKL internal memory is bounded and released: evicted slots and privately
352
+ rebuilt factorizations free their memory (phase -1) via finalizers.
353
+
354
+ Not thread-safe; matches the existing single-threaded solver usage.
355
+ """
356
+
357
+ name = "pypardiso"
358
+
359
+ def __init__(self, *, max_pattern_slots: int = 4):
360
+ self.max_pattern_slots = _env_int("FE_SOLVER_PYPARDISO_MAX_PATTERN_SLOTS", int(max_pattern_slots))
361
+ self._slots: List[_PardisoPatternSlot] = []
362
+
363
+ def release_pattern_slots(self) -> None:
364
+ """Release all retained MKL factorization memory."""
365
+ while self._slots:
366
+ self._slots.pop().release()
367
+
368
+ @property
369
+ def initialized(self) -> bool:
370
+ """Whether this backend has completed a retained factorization."""
371
+
372
+ return bool(self._slots)
373
+
374
+ def _factorize_prepared(self, prepared: sparse.csr_matrix, mtype: int) -> Tuple[_PardisoFactorization, bool]:
375
+ for slot in self._slots:
376
+ if slot.matches(prepared, mtype):
377
+ slot.generation += 1
378
+ try:
379
+ slot.solver.set_phase(22)
380
+ slot.solver._call_pardiso(prepared, np.zeros((prepared.shape[0], 1)))
381
+ except Exception:
382
+ _pardiso_full_factorize(slot.solver, prepared)
383
+ self._slots.remove(slot)
384
+ self._slots.insert(0, slot)
385
+ return _PardisoFactorization(slot, prepared, mtype), True
386
+ solver = _new_pypardiso_solver(mtype=int(mtype))
387
+ _pardiso_full_factorize(solver, prepared)
388
+ slot = _PardisoPatternSlot(solver, mtype, prepared)
389
+ self._slots.insert(0, slot)
390
+ while len(self._slots) > max(int(self.max_pattern_slots), 1):
391
+ self._slots.pop().release()
392
+ return _PardisoFactorization(slot, prepared, mtype), False
393
+
394
+ def factorize(
395
+ self,
396
+ matrix: sparse.spmatrix,
397
+ matrix_class: MatrixClass,
398
+ *,
399
+ signature: Optional[str] = None,
400
+ options: Optional[Mapping[str, Any]] = None,
401
+ ) -> FactorizationHandle:
402
+ start = time.time()
403
+ _ensure_mkl_rt_env()
404
+ failure_reasons: List[str] = []
405
+ try:
406
+ csr = sparse.csr_matrix(matrix)
407
+ if csr is matrix:
408
+ csr = csr.copy()
409
+ csr.sort_indices()
410
+ except Exception as exc:
411
+ failure_reasons.append(str(exc))
412
+ csr = None
413
+ wrapper: Optional[_PardisoFactorization] = None
414
+ used_mtype: Optional[int] = None
415
+ symbolic_reused = False
416
+ if csr is not None:
417
+ for mtype in _pardiso_mtype_candidates(matrix_class):
418
+ try:
419
+ prepared = _pardiso_prepared_matrix(csr, mtype)
420
+ wrapper, symbolic_reused = self._factorize_prepared(prepared, mtype)
421
+ used_mtype = int(mtype)
422
+ break
423
+ except Exception as exc:
424
+ failure_reasons.append(f"mtype={mtype}: {exc}")
425
+ if wrapper is None:
426
+ return FactorizationHandle(
427
+ matrix_shape=tuple(int(v) for v in matrix.shape),
428
+ matrix_class=matrix_class,
429
+ backend_name=self.name,
430
+ ordering="MKL PARDISO",
431
+ signature=signature,
432
+ factorization_time=time.time() - start,
433
+ status="failed",
434
+ failure_reason="; ".join(failure_reasons) or "unknown pypardiso failure",
435
+ )
436
+ handle = FactorizationHandle(
437
+ matrix_shape=tuple(int(v) for v in matrix.shape),
438
+ matrix_class=matrix_class,
439
+ backend_name=self.name,
440
+ ordering="MKL PARDISO",
441
+ signature=signature,
442
+ factorization_time=time.time() - start,
443
+ _solver=wrapper,
444
+ )
445
+ handle.metadata["pardiso_mtype"] = used_mtype
446
+ handle.metadata["pardiso_symbolic_reused"] = bool(symbolic_reused)
447
+ handle.metadata["pardiso_pattern_slots"] = len(self._slots)
448
+ if failure_reasons:
449
+ handle.metadata["pardiso_fallback_attempts"] = list(failure_reasons)
450
+ return handle
451
+
452
+
453
+ class AutoSparseSolverBackend:
454
+ """Size-aware backend selector with SciPy fallback.
455
+
456
+ PyPardiso has substantial setup overhead on tiny systems. The auto backend
457
+ keeps SuperLU as the fast small-matrix path and uses PyPardiso only once the
458
+ matrix is large enough to amortize that overhead. Callers may force a
459
+ backend with ``options={"backend": "pypardiso"}`` or
460
+ ``options={"backend": "scipy_superlu"}``.
461
+ """
462
+
463
+ name = "auto"
464
+
465
+ def __init__(
466
+ self,
467
+ *,
468
+ scipy_backend: Optional[SparseSolverBackend] = None,
469
+ pardiso_backend: Optional[PyPardisoSolverBackend] = None,
470
+ pypardiso_min_dimension: int = 10_000,
471
+ pypardiso_min_nnz: int = 250_000,
472
+ pypardiso_warm_min_dimension: int = 1_000,
473
+ pypardiso_warm_min_nnz: int = 25_000,
474
+ ):
475
+ self.scipy_backend = scipy_backend or SparseSolverBackend()
476
+ self.pardiso_backend = (pardiso_backend or PyPardisoSolverBackend()) if _HAS_PYPARDISO else None
477
+ self.pypardiso_min_dimension = _env_int("FE_SOLVER_PYPARDISO_MIN_DIMENSION", int(pypardiso_min_dimension))
478
+ self.pypardiso_min_nnz = _env_int("FE_SOLVER_PYPARDISO_MIN_NNZ", int(pypardiso_min_nnz))
479
+ self.pypardiso_warm_min_dimension = _env_int(
480
+ "FE_SOLVER_PYPARDISO_WARM_MIN_DIMENSION",
481
+ int(pypardiso_warm_min_dimension),
482
+ )
483
+ self.pypardiso_warm_min_nnz = _env_int(
484
+ "FE_SOLVER_PYPARDISO_WARM_MIN_NNZ",
485
+ int(pypardiso_warm_min_nnz),
486
+ )
487
+
488
+ def release_pattern_slots(self) -> None:
489
+ """Release MKL factorization memory retained for pattern reuse."""
490
+ if self.pardiso_backend is not None:
491
+ self.pardiso_backend.release_pattern_slots()
492
+
493
+ def _selection_thresholds(self, options: Mapping[str, Any]) -> Tuple[int, int, bool]:
494
+ is_warm = bool(self.pardiso_backend and self.pardiso_backend.initialized)
495
+ default_dimension = self.pypardiso_warm_min_dimension if is_warm else self.pypardiso_min_dimension
496
+ default_nnz = self.pypardiso_warm_min_nnz if is_warm else self.pypardiso_min_nnz
497
+ min_dimension = int(options.get("pypardiso_min_dimension", default_dimension))
498
+ min_nnz = int(options.get("pypardiso_min_nnz", default_nnz))
499
+ return min_dimension, min_nnz, is_warm
500
+
501
+ def _use_pypardiso(
502
+ self,
503
+ matrix: sparse.spmatrix,
504
+ options: Mapping[str, Any],
505
+ min_dimension: int,
506
+ min_nnz: int,
507
+ ) -> bool:
508
+ requested = str(options.get("backend", options.get("solver", "auto"))).lower()
509
+ if requested in {"scipy", "scipy_superlu", "superlu"}:
510
+ return False
511
+ if requested in {"pypardiso", "pardiso", "mkl_pardiso"}:
512
+ return self.pardiso_backend is not None
513
+ if self.pardiso_backend is None:
514
+ return False
515
+ return max(int(matrix.shape[0]), int(matrix.shape[1])) >= min_dimension and int(matrix.nnz) >= min_nnz
516
+
517
+ def factorize(
518
+ self,
519
+ matrix: sparse.spmatrix,
520
+ matrix_class: MatrixClass,
521
+ *,
522
+ signature: Optional[str] = None,
523
+ options: Optional[Mapping[str, Any]] = None,
524
+ ) -> FactorizationHandle:
525
+ options_dict = dict(options or {})
526
+ active_dimension, active_nnz, was_initialized = self._selection_thresholds(options_dict)
527
+ selection_metadata = {
528
+ "pypardiso_active_min_dimension": active_dimension,
529
+ "pypardiso_active_min_nnz": active_nnz,
530
+ "pypardiso_initialized_before_selection": was_initialized,
531
+ }
532
+ if not self._use_pypardiso(matrix, options_dict, active_dimension, active_nnz):
533
+ handle = self.scipy_backend.factorize(matrix, matrix_class, signature=signature, options=options_dict)
534
+ handle.metadata.setdefault("auto_backend_policy", "scipy_small_matrix")
535
+ handle.metadata.setdefault("pypardiso_min_dimension", self.pypardiso_min_dimension)
536
+ handle.metadata.setdefault("pypardiso_min_nnz", self.pypardiso_min_nnz)
537
+ for key, value in selection_metadata.items():
538
+ handle.metadata.setdefault(key, value)
539
+ handle.metadata.setdefault(
540
+ "pypardiso_initialized",
541
+ bool(self.pardiso_backend and self.pardiso_backend.initialized),
542
+ )
543
+ return handle
544
+
545
+ assert self.pardiso_backend is not None
546
+ handle = self.pardiso_backend.factorize(matrix, matrix_class, signature=signature, options=options_dict)
547
+ handle.metadata.setdefault("auto_backend_policy", "pypardiso_large_matrix")
548
+ handle.metadata.setdefault("pypardiso_min_dimension", self.pypardiso_min_dimension)
549
+ handle.metadata.setdefault("pypardiso_min_nnz", self.pypardiso_min_nnz)
550
+ for key, value in selection_metadata.items():
551
+ handle.metadata.setdefault(key, value)
552
+ handle.metadata.setdefault("pypardiso_initialized", bool(self.pardiso_backend.initialized))
553
+ if handle.status == "ok":
554
+ return handle
555
+
556
+ fallback = self.scipy_backend.factorize(matrix, matrix_class, signature=signature, options=options_dict)
557
+ fallback.metadata["auto_backend_policy"] = "scipy_after_pypardiso_failure"
558
+ fallback.metadata["pypardiso_min_dimension"] = self.pypardiso_min_dimension
559
+ fallback.metadata["pypardiso_min_nnz"] = self.pypardiso_min_nnz
560
+ fallback.metadata.update(selection_metadata)
561
+ fallback.metadata["fallback_from_backend"] = handle.backend_name
562
+ fallback.metadata["fallback_failure_reason"] = handle.failure_reason
563
+ return fallback
564
+
565
+
566
+ DEFAULT_BACKEND = AutoSparseSolverBackend() if _HAS_PYPARDISO else SparseSolverBackend()
567
+
568
+
569
+ def _options_signature(options: Optional[Mapping[str, Any]]) -> str:
570
+ return json.dumps(dict(options or {}), sort_keys=True, default=str, separators=(",", ":"))
571
+
572
+
573
+ def sparse_matrix_signature(matrix: sparse.spmatrix) -> str:
574
+ """Content signature for a sparse matrix used in local factorization caches."""
575
+
576
+ csr = sparse.csr_matrix(matrix)
577
+ digest = hashlib.sha256()
578
+ digest.update(str(tuple(int(v) for v in csr.shape)).encode("ascii"))
579
+ digest.update(str(int(csr.nnz)).encode("ascii"))
580
+ digest.update(np.asarray(csr.indptr, dtype=np.int64).tobytes())
581
+ digest.update(np.asarray(csr.indices, dtype=np.int64).tobytes())
582
+ digest.update(np.asarray(csr.data, dtype=np.float64).tobytes())
583
+ return digest.hexdigest()
584
+
585
+
586
+ @dataclass
587
+ class FactorizationCache:
588
+ """Explicit local cache for sparse factorizations.
589
+
590
+ The cache is intentionally not global. Analyses that can safely reuse a
591
+ matrix factorization own the cache and therefore own its lifetime.
592
+ """
593
+
594
+ name: str = "factorization_cache"
595
+ max_entries: int = 8
596
+ backend: Optional[SparseSolverBackend] = None
597
+ _handles: Dict[Tuple[str, str, str, str], FactorizationHandle] = field(default_factory=dict, init=False, repr=False)
598
+ hits: int = 0
599
+ misses: int = 0
600
+ factorization_failures: int = 0
601
+
602
+ def key(
603
+ self,
604
+ matrix: sparse.spmatrix,
605
+ matrix_class: MatrixClass | str,
606
+ *,
607
+ signature: Optional[str] = None,
608
+ options: Optional[Mapping[str, Any]] = None,
609
+ ) -> Tuple[str, str, str, str]:
610
+ matrix_key = str(signature) if signature is not None else sparse_matrix_signature(matrix)
611
+ return (
612
+ matrix_key,
613
+ _coerce_matrix_class(matrix_class).value,
614
+ _options_signature(options),
615
+ tuple(int(v) for v in matrix.shape).__repr__(),
616
+ )
617
+
618
+ def factorize(
619
+ self,
620
+ matrix: sparse.spmatrix,
621
+ matrix_class: MatrixClass | str,
622
+ *,
623
+ signature: Optional[str] = None,
624
+ options: Optional[Mapping[str, Any]] = None,
625
+ ) -> FactorizationHandle:
626
+ cache_key = self.key(matrix, matrix_class, signature=signature, options=options)
627
+ if cache_key in self._handles:
628
+ self.hits += 1
629
+ handle = self._handles[cache_key]
630
+ handle.metadata["cache_name"] = self.name
631
+ handle.metadata["cache_hit"] = True
632
+ return handle
633
+ self.misses += 1
634
+ handle = factorize(
635
+ matrix,
636
+ matrix_class,
637
+ signature=signature or cache_key[0],
638
+ options=options,
639
+ backend=self.backend,
640
+ )
641
+ handle.metadata["cache_name"] = self.name
642
+ handle.metadata["cache_hit"] = False
643
+ if handle.status == "ok":
644
+ if self.max_entries > 0 and len(self._handles) >= self.max_entries:
645
+ oldest = next(iter(self._handles))
646
+ self._handles.pop(oldest, None)
647
+ if self.max_entries != 0:
648
+ self._handles[cache_key] = handle
649
+ else:
650
+ self.factorization_failures += 1
651
+ return handle
652
+
653
+ def linear_operator(
654
+ self,
655
+ matrix: sparse.spmatrix,
656
+ matrix_class: MatrixClass | str,
657
+ *,
658
+ signature: Optional[str] = None,
659
+ options: Optional[Mapping[str, Any]] = None,
660
+ ) -> tuple[LinearOperator, FactorizationHandle]:
661
+ """Return a LinearOperator that applies the cached inverse."""
662
+
663
+ handle = self.factorize(matrix, matrix_class, signature=signature, options=options)
664
+ if handle.status != "ok":
665
+ raise RuntimeError(f"Cannot create inverse operator from failed factorization: {handle.failure_reason}")
666
+
667
+ def matvec(rhs: np.ndarray) -> np.ndarray:
668
+ return handle.solve(rhs)
669
+
670
+ operator = LinearOperator(matrix.shape, matvec=matvec, dtype=float)
671
+ return operator, handle
672
+
673
+ def diagnostics(self) -> Dict[str, Any]:
674
+ return {
675
+ "name": self.name,
676
+ "max_entries": int(self.max_entries),
677
+ "entries": int(len(self._handles)),
678
+ "hits": int(self.hits),
679
+ "misses": int(self.misses),
680
+ "factorization_failures": int(self.factorization_failures),
681
+ "backend": (self.backend or DEFAULT_BACKEND).name,
682
+ }
683
+
684
+ def clear(self) -> None:
685
+ self._handles.clear()
686
+
687
+
688
+ def _coerce_matrix_class(matrix_class: MatrixClass | str) -> MatrixClass:
689
+ if isinstance(matrix_class, MatrixClass):
690
+ return matrix_class
691
+ return MatrixClass(str(matrix_class))
692
+
693
+
694
+ def factorize(
695
+ matrix: sparse.spmatrix,
696
+ matrix_class: MatrixClass | str,
697
+ *,
698
+ signature: Optional[str] = None,
699
+ options: Optional[Mapping[str, Any]] = None,
700
+ backend: Optional[SparseSolverBackend] = None,
701
+ ) -> FactorizationHandle:
702
+ """Factorize a sparse matrix and return a reusable handle."""
703
+ backend = backend or DEFAULT_BACKEND
704
+ return backend.factorize(matrix, _coerce_matrix_class(matrix_class), signature=signature, options=options)
705
+
706
+
707
+ def factorize_cached(
708
+ matrix: sparse.spmatrix,
709
+ matrix_class: MatrixClass | str,
710
+ *,
711
+ cache: Optional[FactorizationCache] = None,
712
+ signature: Optional[str] = None,
713
+ options: Optional[Mapping[str, Any]] = None,
714
+ backend: Optional[SparseSolverBackend] = None,
715
+ ) -> FactorizationHandle:
716
+ """Factorize through a local cache when supplied."""
717
+
718
+ if cache is None:
719
+ return factorize(matrix, matrix_class, signature=signature, options=options, backend=backend)
720
+ if backend is not None and cache.backend is None:
721
+ cache.backend = backend
722
+ return cache.factorize(matrix, matrix_class, signature=signature, options=options)
723
+
724
+
725
+ def cached_inverse_operator(
726
+ matrix: sparse.spmatrix,
727
+ matrix_class: MatrixClass | str,
728
+ *,
729
+ cache: Optional[FactorizationCache] = None,
730
+ signature: Optional[str] = None,
731
+ options: Optional[Mapping[str, Any]] = None,
732
+ ) -> tuple[LinearOperator, FactorizationHandle]:
733
+ """Build a sparse inverse operator, using a local cache if supplied."""
734
+
735
+ local_cache = cache or FactorizationCache(name="single_inverse_operator", max_entries=1)
736
+ return local_cache.linear_operator(matrix, matrix_class, signature=signature, options=options)
737
+
738
+
739
+ def solve(handle: FactorizationHandle, rhs: np.ndarray) -> np.ndarray:
740
+ """Solve one right-hand side using an existing factorization."""
741
+ if handle.status != "ok" or handle._solver is None:
742
+ raise RuntimeError(f"Cannot solve with failed factorization: {handle.failure_reason}")
743
+ rhs = np.asarray(rhs, dtype=float)
744
+ start = time.time()
745
+ result = np.asarray(handle._solver.solve(rhs), dtype=float)
746
+ handle.solve_time += time.time() - start
747
+ handle.solve_count += 1 if rhs.ndim == 1 else int(rhs.shape[1])
748
+ if np.any(~np.isfinite(result)):
749
+ raise RuntimeError("Sparse solve produced NaN/Inf values")
750
+ return result
751
+
752
+
753
+ def solve_many(handle: FactorizationHandle, rhs_matrix: np.ndarray) -> np.ndarray:
754
+ """Solve one or more right-hand sides with one numerical factorization."""
755
+ rhs = np.asarray(rhs_matrix, dtype=float)
756
+ if rhs.ndim == 1:
757
+ return solve(handle, rhs)
758
+ if rhs.ndim != 2:
759
+ raise ValueError("rhs_matrix must be one- or two-dimensional")
760
+ return solve(handle, rhs)