openseespy-solvers 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.
@@ -0,0 +1,41 @@
1
+ """Ready-to-use PythonSparse solvers for OpenSeesPy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import import_module
6
+ from typing import TYPE_CHECKING
7
+
8
+ from openseespy_solvers.exceptions import (
9
+ BackendNotAvailableError,
10
+ InvalidOpenSeesDataError,
11
+ OpenSeesSolverError,
12
+ SolverConvergenceError,
13
+ UnsupportedComputeDtypeError,
14
+ UnsupportedStorageSchemeError,
15
+ )
16
+ from openseespy_solvers.hybrid import hybrid
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "BackendNotAvailableError",
22
+ "InvalidOpenSeesDataError",
23
+ "OpenSeesSolverError",
24
+ "SolverConvergenceError",
25
+ "UnsupportedComputeDtypeError",
26
+ "UnsupportedStorageSchemeError",
27
+ "__version__",
28
+ "cupy",
29
+ "hybrid",
30
+ "scipy",
31
+ ]
32
+
33
+ if TYPE_CHECKING:
34
+ from openseespy_solvers import cupy as cupy
35
+ from openseespy_solvers import scipy as scipy
36
+
37
+
38
+ def __getattr__(name: str):
39
+ if name in {"scipy", "cupy"}:
40
+ return import_module(f"openseespy_solvers.{name}")
41
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,428 @@
1
+ """Base solver classes for the OpenSees PythonSparse interfaces.
2
+
3
+ These bases own the parts that are *identical for every backend*: wrapping the
4
+ OpenSees memoryviews, the ``matrix_status`` caching pattern, writing the solution
5
+ in place, statistics, ``formAp``, ``to_openseespy`` and ``copy`` support.
6
+
7
+ Backend-specific numerics are provided by small hooks that each namespace
8
+ (``openseespy_solvers.scipy``, ``.cupy``, ...) implements:
9
+
10
+ * ``_build_matrix`` / ``_update_matrix`` - assemble the cached sparse matrix
11
+ * ``_to_device`` / ``_to_host`` - host<->device transfer (identity on CPU)
12
+ * ``_matvec`` - ``A @ v``
13
+ * ``_is_sparse`` / ``_is_linear_operator`` - preconditioner detection
14
+ * ``_solve_system`` (linear) / ``_solve_eigen`` (eigen) - the actual library call
15
+
16
+ A backend that does not fit this shape (e.g. a distributed PETSc backend) can
17
+ override ``solve`` directly; nothing here is mandatory beyond the hooks a given
18
+ solver uses.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import copy
24
+ import time
25
+ from abc import ABC, abstractmethod
26
+ from typing import Any
27
+
28
+ import numpy as np
29
+
30
+ from openseespy_solvers._dtype import OPENSEES_BUFFER_DTYPE, resolve_compute_dtype
31
+ from openseespy_solvers._sparse import parse_eigen_values, parse_sparse_arrays
32
+ from openseespy_solvers.exceptions import InvalidOpenSeesDataError
33
+ from openseespy_solvers.stats import EigenSolverStats, LinearSolverStats
34
+
35
+
36
+ def _normalize_writable(writable: str | list[str]) -> str:
37
+ if isinstance(writable, list):
38
+ return ",".join(writable)
39
+ return writable
40
+
41
+
42
+ class BaseOpenSeesSolver(ABC):
43
+ """Shared behavior for linear and eigen solvers."""
44
+
45
+ #: Backend name, set as a class attribute by each namespace mixin.
46
+ backend: str = "base"
47
+ #: Whether matrices/vectors live on a device (GPU) rather than host memory.
48
+ _on_device: bool = False
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ scheme: str = "CSR",
54
+ debug: bool = False,
55
+ dtype: Any = np.float64,
56
+ ) -> None:
57
+ self.scheme = scheme
58
+ self.debug = debug
59
+ self._compute_dtype = resolve_compute_dtype(dtype)
60
+ self._matrix: Any | None = None
61
+ self._k_matrix: Any | None = None
62
+ self._m_matrix: Any | None = None
63
+ self._params: dict[str, Any] = {}
64
+
65
+ # -- backend hooks ---------------------------------------------------
66
+
67
+ @abstractmethod
68
+ def _build_matrix(
69
+ self,
70
+ values: np.ndarray,
71
+ indices: np.ndarray,
72
+ indptr: np.ndarray,
73
+ shape: tuple[int, int],
74
+ fmt: str,
75
+ ) -> Any:
76
+ """Build a new sparse matrix in the backend's format (owns its data)."""
77
+
78
+ @abstractmethod
79
+ def _update_matrix(self, matrix: Any, values: np.ndarray) -> Any:
80
+ """Refresh coefficients (in place where possible) and return the matrix."""
81
+
82
+ @abstractmethod
83
+ def _to_device(self, array: np.ndarray) -> Any:
84
+ """Transfer a host array to the backend (identity on CPU)."""
85
+
86
+ @abstractmethod
87
+ def _to_host(self, array: Any) -> np.ndarray:
88
+ """Transfer a backend array back to a NumPy host array."""
89
+
90
+ @abstractmethod
91
+ def _matvec(self, matrix: Any, vector: Any) -> Any:
92
+ """Return ``matrix @ vector`` in the backend."""
93
+
94
+ # -- OpenSees config -------------------------------------------------
95
+
96
+ def to_openseespy(
97
+ self,
98
+ *,
99
+ scheme: str | None = None,
100
+ writable: str | list[str] | None = None,
101
+ ) -> dict[str, Any]:
102
+ """Return the configuration dict for OpenSeesPy ``PythonSparse`` commands.
103
+
104
+ Parameters
105
+ ----------
106
+ scheme : {'CSR', 'CSC'}, optional
107
+ Sparse storage scheme. Default is the value given at construction.
108
+ writable : str or list of str, optional
109
+ Writable buffers declared to OpenSees. Overrides the constructor
110
+ value when provided.
111
+
112
+ Returns
113
+ -------
114
+ config : dict
115
+ Dictionary with keys ``solver``, ``scheme``, and optionally
116
+ ``writable``. Pass directly to ``ops.system('PythonSparse', ...)``
117
+ or ``ops.eigen('PythonSparse', ...)``.
118
+
119
+ Examples
120
+ --------
121
+ >>> from openseespy_solvers.scipy import cg
122
+ >>> solver = cg()
123
+ >>> cfg = solver.to_openseespy()
124
+ >>> sorted(cfg.keys())
125
+ ['scheme', 'solver', 'writable']
126
+ """
127
+ cfg: dict[str, Any] = {
128
+ "solver": self,
129
+ "scheme": scheme or self.scheme,
130
+ }
131
+ if writable is not None:
132
+ cfg["writable"] = _normalize_writable(writable)
133
+ elif hasattr(self, "writable"):
134
+ cfg["writable"] = _normalize_writable(self.writable)
135
+ return cfg
136
+
137
+ # -- matrix caching --------------------------------------------------
138
+
139
+ def _values_for_compute(self, values: np.ndarray) -> np.ndarray:
140
+ return np.asarray(values, dtype=self._compute_dtype)
141
+
142
+ def _write_opensees_buffer(self, buf: np.ndarray, value: Any) -> None:
143
+ buf[:] = np.asarray(self._to_host(value), dtype=OPENSEES_BUFFER_DTYPE)
144
+
145
+ def _current_matrix(self, kwargs: dict[str, Any], *, values_key: str = "values") -> Any:
146
+ arrays = parse_sparse_arrays(kwargs, values_key=values_key)
147
+ values = self._values_for_compute(arrays.values)
148
+ status = kwargs["matrix_status"]
149
+ if status == "STRUCTURE_CHANGED" or self._matrix is None:
150
+ self._matrix = self._build_matrix(
151
+ values, arrays.indices, arrays.indptr, arrays.shape, arrays.fmt
152
+ )
153
+ elif status == "COEFFICIENTS_CHANGED":
154
+ self._matrix = self._update_matrix(self._matrix, values)
155
+ return self._matrix
156
+
157
+ def _current_eigen_matrices(self, kwargs: dict[str, Any]) -> tuple[Any, Any]:
158
+ arrays = parse_sparse_arrays(kwargs, values_key="k_values")
159
+ k_values = self._values_for_compute(arrays.values)
160
+ m_values = self._values_for_compute(parse_eigen_values(kwargs))
161
+ status = kwargs["matrix_status"]
162
+ if status == "STRUCTURE_CHANGED" or self._k_matrix is None:
163
+ self._k_matrix = self._build_matrix(
164
+ k_values, arrays.indices, arrays.indptr, arrays.shape, arrays.fmt
165
+ )
166
+ self._m_matrix = self._build_matrix(
167
+ m_values, arrays.indices, arrays.indptr, arrays.shape, arrays.fmt
168
+ )
169
+ elif status == "COEFFICIENTS_CHANGED":
170
+ self._k_matrix = self._update_matrix(self._k_matrix, k_values)
171
+ self._m_matrix = self._update_matrix(self._m_matrix, m_values)
172
+ return self._k_matrix, self._m_matrix
173
+
174
+ # -- copy ------------------------------------------------------------
175
+
176
+ def copy(self) -> BaseOpenSeesSolver:
177
+ return copy.copy(self)
178
+
179
+ def __copy__(self) -> BaseOpenSeesSolver:
180
+ return type(self)(**self._params)
181
+
182
+ def __deepcopy__(self, memo: dict[int, Any]) -> BaseOpenSeesSolver:
183
+ return self.__copy__()
184
+
185
+
186
+ class LinearSolver(BaseOpenSeesSolver, ABC):
187
+ """Base class for linear system solvers (``Ax = b``).
188
+
189
+ Instances are created by backend factories such as :func:`openseespy_solvers.scipy.cg`.
190
+ OpenSees calls :meth:`solve` with sparse buffer memoryviews; the solution is
191
+ written in place to the ``x`` buffer.
192
+
193
+ Attributes
194
+ ----------
195
+ A : sparse matrix or None
196
+ Cached system matrix from the last :meth:`solve` or :meth:`formAp` call.
197
+ b : ndarray
198
+ Right-hand side from the last :meth:`solve` call.
199
+ x : ndarray
200
+ Last solution vector.
201
+ stats : LinearSolverStats
202
+ Runtime statistics updated after each :meth:`solve`.
203
+ """
204
+
205
+ stats: LinearSolverStats
206
+
207
+ def __init__(
208
+ self,
209
+ *,
210
+ scheme: str = "CSR",
211
+ writable: str | list[str] = "none",
212
+ debug: bool = False,
213
+ preconditioner: Any = None,
214
+ dtype: Any = np.float64,
215
+ ) -> None:
216
+ super().__init__(scheme=scheme, debug=debug, dtype=dtype)
217
+ self.writable = _normalize_writable(writable)
218
+ self._preconditioner = preconditioner
219
+ self._preconditioner_cached: Any | None = None
220
+ self.stats = LinearSolverStats()
221
+ self._A: Any | None = None
222
+ self._b: Any | None = None
223
+ self._x: Any | None = None
224
+
225
+ @property
226
+ def A(self) -> Any | None:
227
+ return self._A
228
+
229
+ @property
230
+ def b(self) -> Any | None:
231
+ return self._b
232
+
233
+ @property
234
+ def x(self) -> Any | None:
235
+ return self._x
236
+
237
+ def _resolve_preconditioner(self, A: Any, matrix_status: str) -> Any | None:
238
+ M = self._preconditioner
239
+ if M is None:
240
+ return None
241
+ if self._is_sparse(M) or self._is_linear_operator(M):
242
+ return M
243
+ if callable(M):
244
+ if matrix_status == "UNCHANGED" and self._preconditioner_cached is not None:
245
+ return self._preconditioner_cached
246
+ self._preconditioner_cached = M(A)
247
+ return self._preconditioner_cached
248
+ return M
249
+
250
+ def solve(self, **kwargs: Any) -> int:
251
+ """Solve ``Ax = b`` using buffers supplied by OpenSees.
252
+
253
+ This method is called by OpenSeesPy; application code normally does not
254
+ invoke it directly.
255
+
256
+ Parameters
257
+ ----------
258
+ **kwargs
259
+ OpenSees ``PythonSparse`` buffers, including ``values``, ``rhs``,
260
+ ``x``, ``num_eqn``, ``nnz``, ``matrix_status``, and
261
+ ``storage_scheme``.
262
+
263
+ Returns
264
+ -------
265
+ info : int
266
+ ``0`` if the solve succeeded; a negative value otherwise. When
267
+ ``debug=True``, failures raise the underlying exception instead.
268
+ """
269
+ try:
270
+ matrix_status = kwargs["matrix_status"]
271
+ num_eqn = int(kwargs["num_eqn"])
272
+ if "rhs" not in kwargs or "x" not in kwargs:
273
+ raise InvalidOpenSeesDataError("Linear solve requires rhs and x buffers")
274
+
275
+ A = self._current_matrix(kwargs)
276
+ rhs = np.frombuffer(kwargs["rhs"], dtype=OPENSEES_BUFFER_DTYPE, count=num_eqn)
277
+ x_buf = np.frombuffer(kwargs["x"], dtype=OPENSEES_BUFFER_DTYPE, count=num_eqn)
278
+
279
+ self._A = A
280
+ b = self._to_device(np.asarray(rhs, dtype=self._compute_dtype))
281
+ self._b = b
282
+
283
+ M = self._resolve_preconditioner(A, matrix_status)
284
+ start = time.perf_counter()
285
+ result, info, num_iter = self._solve_system(A, b, M, matrix_status)
286
+ elapsed = time.perf_counter() - start
287
+
288
+ self._write_opensees_buffer(x_buf, result)
289
+ self._x = result if self._on_device else x_buf
290
+
291
+ residual = None
292
+ if num_eqn > 0:
293
+ ax = np.asarray(self._to_host(self._matvec(A, result)), dtype=OPENSEES_BUFFER_DTYPE)
294
+ r = rhs - ax
295
+ norm_b = float(np.linalg.norm(rhs))
296
+ residual = (
297
+ float(np.linalg.norm(r) / norm_b) if norm_b > 0 else float(np.linalg.norm(r))
298
+ )
299
+
300
+ self.stats.num_solves += 1
301
+ self.stats.last_solve_time = elapsed
302
+ self.stats.last_info = info
303
+ self.stats.last_num_iterations = num_iter
304
+ self.stats.last_residual_norm = residual
305
+ self.stats.last_error = None
306
+ return 0 if info == 0 else -abs(int(info))
307
+ except Exception as exc:
308
+ self.stats.last_error = exc
309
+ if self.debug:
310
+ raise
311
+ return -1
312
+
313
+ def formAp(self, **kwargs: Any) -> int:
314
+ try:
315
+ num_eqn = int(kwargs["num_eqn"])
316
+ if "p" not in kwargs or "Ap" not in kwargs:
317
+ raise InvalidOpenSeesDataError("formAp requires p and Ap buffers")
318
+
319
+ A = self._current_matrix(kwargs)
320
+ self._A = A
321
+ p = np.frombuffer(kwargs["p"], dtype=OPENSEES_BUFFER_DTYPE, count=num_eqn)
322
+ ap_buf = np.frombuffer(kwargs["Ap"], dtype=OPENSEES_BUFFER_DTYPE, count=num_eqn)
323
+
324
+ result = self._matvec(A, self._to_device(np.asarray(p, dtype=self._compute_dtype)))
325
+ self._write_opensees_buffer(ap_buf, result)
326
+ return 0
327
+ except Exception as exc:
328
+ self.stats.last_error = exc
329
+ if self.debug:
330
+ raise
331
+ return -1
332
+
333
+ @abstractmethod
334
+ def _is_sparse(self, obj: Any) -> bool:
335
+ """Return True if *obj* is a sparse matrix for this backend."""
336
+
337
+ @abstractmethod
338
+ def _is_linear_operator(self, obj: Any) -> bool:
339
+ """Return True if *obj* is a LinearOperator for this backend."""
340
+
341
+ @abstractmethod
342
+ def _solve_system(
343
+ self,
344
+ A: Any,
345
+ b: Any,
346
+ M: Any | None,
347
+ matrix_status: str,
348
+ ) -> tuple[Any, int, int | None]:
349
+ """Return (solution, info, num_iterations)."""
350
+
351
+
352
+ class EigenSolver(BaseOpenSeesSolver, ABC):
353
+ """Base class for generalized eigenvalue solvers (``K phi = lambda M phi``)."""
354
+
355
+ stats: EigenSolverStats
356
+
357
+ def __init__(
358
+ self, *, scheme: str = "CSR", debug: bool = False, dtype: Any = np.float64
359
+ ) -> None:
360
+ super().__init__(scheme=scheme, debug=debug, dtype=dtype)
361
+ self.stats = EigenSolverStats()
362
+ self._K: Any | None = None
363
+ self._M: Any | None = None
364
+
365
+ @property
366
+ def K(self) -> Any | None:
367
+ return self._K
368
+
369
+ @property
370
+ def M(self) -> Any | None:
371
+ return self._M
372
+
373
+ def solve(self, **kwargs: Any) -> None:
374
+ num_modes = int(kwargs["num_modes"])
375
+ num_eqn = int(kwargs["num_eqn"])
376
+ find_smallest = bool(kwargs["find_smallest"])
377
+
378
+ eigenvalues_buf = np.frombuffer(
379
+ kwargs["eigenvalues"], dtype=OPENSEES_BUFFER_DTYPE, count=num_modes
380
+ )
381
+ eigenvectors_buf = np.frombuffer(
382
+ kwargs["eigenvectors"], dtype=OPENSEES_BUFFER_DTYPE, count=num_modes * num_eqn
383
+ )
384
+
385
+ K, M = self._current_eigen_matrices(kwargs)
386
+ self._K = K
387
+ self._M = M
388
+
389
+ start = time.perf_counter()
390
+ try:
391
+ eigvals, eigvecs = self._solve_eigen(
392
+ K, M, num_modes=num_modes, find_smallest=find_smallest
393
+ )
394
+ elapsed = time.perf_counter() - start
395
+
396
+ eigvals_host = np.asarray(self._to_host(eigvals), dtype=OPENSEES_BUFFER_DTYPE)
397
+ eigvecs_host = np.asarray(self._to_host(eigvecs), dtype=OPENSEES_BUFFER_DTYPE)
398
+
399
+ order = np.argsort(eigvals_host)
400
+ if not find_smallest:
401
+ order = order[::-1]
402
+ eigvals_host = eigvals_host[order]
403
+ eigvecs_host = eigvecs_host[:, order]
404
+
405
+ eigenvalues_buf[:] = eigvals_host[:num_modes]
406
+ eigenvectors_buf[:] = eigvecs_host[:, :num_modes].T.reshape(-1)
407
+
408
+ self.stats.num_solves += 1
409
+ self.stats.last_solve_time = elapsed
410
+ self.stats.last_num_modes = num_modes
411
+ self.stats.last_info = 0
412
+ self.stats.last_eigenvalues = eigvals_host[:num_modes].copy()
413
+ self.stats.last_error = None
414
+ except Exception as exc:
415
+ self.stats.last_error = exc
416
+ self.stats.last_info = -1
417
+ raise
418
+
419
+ @abstractmethod
420
+ def _solve_eigen(
421
+ self,
422
+ K: Any,
423
+ M: Any,
424
+ *,
425
+ num_modes: int,
426
+ find_smallest: bool,
427
+ ) -> tuple[Any, Any]:
428
+ """Return (eigenvalues, eigenvectors) with eigenvectors column-major."""
@@ -0,0 +1,64 @@
1
+ """Shared NumPy-style docstring fragments for public factory functions."""
2
+
3
+ _OPENSEES_LINEAR = """
4
+ scheme : {'CSR', 'CSC'}, optional
5
+ Sparse storage scheme for :meth:`~openseespy_solvers._base.BaseOpenSeesSolver.to_openseespy`.
6
+ Default is ``'CSR'``.
7
+ writable : str or list of str, optional
8
+ Writable buffers declared to OpenSees. Default is ``'none'``.
9
+ debug : bool, optional
10
+ If ``True``, exceptions raised during :meth:`~openseespy_solvers._base.LinearSolver.solve`
11
+ or :meth:`~openseespy_solvers._base.LinearSolver.formAp` are re-raised. Otherwise a
12
+ negative status code is returned. Default is ``False``.
13
+ dtype : dtype or str, optional
14
+ Floating-point precision for the numerical solve (``float32`` or ``float64``).
15
+ OpenSees buffer I/O remains ``float64``; values are cast at the boundary.
16
+ Default is ``float64``.
17
+ """
18
+
19
+ _OPENSEES_EIGEN = """
20
+ scheme : {'CSR', 'CSC'}, optional
21
+ Sparse storage scheme for :meth:`~openseespy_solvers._base.BaseOpenSeesSolver.to_openseespy`.
22
+ Default is ``'CSR'``.
23
+ debug : bool, optional
24
+ If ``True``, exceptions raised during
25
+ :meth:`~openseespy_solvers._base.EigenSolver.solve` are re-raised. Default is ``False``.
26
+ dtype : dtype or str, optional
27
+ Floating-point precision for the eigen solve (``float32`` or ``float64``).
28
+ OpenSees buffer I/O remains ``float64``. Default is ``float64``.
29
+ """
30
+
31
+ _LINEAR_RETURNS = """
32
+ Returns
33
+ -------
34
+ solver : LinearSolver
35
+ Configured linear solver. Pass ``solver.to_openseespy()`` to
36
+ ``ops.system('PythonSparse', ...)``.
37
+ """
38
+
39
+ _EIGEN_RETURNS = """
40
+ Returns
41
+ -------
42
+ solver : EigenSolver
43
+ Configured eigen solver. Pass ``solver.to_openseespy()`` to
44
+ ``ops.eigen('PythonSparse', num_modes, ...)``.
45
+ """
46
+
47
+ _LINEAR_NOTES = """
48
+ Notes
49
+ -----
50
+ OpenSees assembles the sparse system matrix and right-hand side and calls
51
+ :meth:`~openseespy_solvers._base.LinearSolver.solve` with buffer memoryviews.
52
+ The solution is written in place to the ``x`` buffer. After each solve,
53
+ ``solver.A``, ``solver.b``, and ``solver.x`` refer to the cached matrix and
54
+ vectors from the last call.
55
+ """
56
+
57
+ _EIGEN_NOTES = """
58
+ Notes
59
+ -----
60
+ OpenSees assembles ``K`` and ``M`` and calls
61
+ :meth:`~openseespy_solvers._base.EigenSolver.solve`. Eigenvalues and
62
+ eigenvectors are written in place to the output buffers. After each solve,
63
+ ``solver.K`` and ``solver.M`` refer to the cached matrices from the last call.
64
+ """
@@ -0,0 +1,45 @@
1
+ """Compute precision for solver numerics (OpenSees buffers remain float64)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from openseespy_solvers.exceptions import UnsupportedComputeDtypeError
10
+
11
+ # OpenSees PythonSparse memoryviews are double precision.
12
+ OPENSEES_BUFFER_DTYPE = np.dtype(np.float64)
13
+
14
+ _ALLOWED_COMPUTE_DTYPES = frozenset({np.dtype(np.float32), np.dtype(np.float64)})
15
+
16
+
17
+ def resolve_compute_dtype(dtype: Any = np.float64) -> np.dtype:
18
+ """Normalize ``dtype`` to ``float32`` or ``float64`` for internal solves.
19
+
20
+ Parameters
21
+ ----------
22
+ dtype : dtype or str, optional
23
+ ``numpy.float32``, ``numpy.float64``, ``'float32'``, ``'f32'``, etc.
24
+ Default is ``float64``.
25
+
26
+ Returns
27
+ -------
28
+ numpy.dtype
29
+ Resolved compute dtype.
30
+
31
+ Raises
32
+ ------
33
+ UnsupportedComputeDtypeError
34
+ If *dtype* is not single- or double-precision float.
35
+ """
36
+ try:
37
+ resolved = np.dtype(dtype)
38
+ except TypeError as exc:
39
+ raise UnsupportedComputeDtypeError(f"Invalid dtype {dtype!r}") from exc
40
+ if resolved not in _ALLOWED_COMPUTE_DTYPES:
41
+ raise UnsupportedComputeDtypeError(
42
+ f"Compute dtype must be float32 or float64, got {resolved!r}. "
43
+ "OpenSees buffers are always float64; lower precision applies inside the solver."
44
+ )
45
+ return resolved
@@ -0,0 +1,86 @@
1
+ """Apply an inner direct solver's cached factorization to a vector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from openseespy_solvers._base import LinearSolver
10
+ from openseespy_solvers._sparse import _host_array, csr_linear_kwargs_from_matrix
11
+ from openseespy_solvers.exceptions import SolverConvergenceError
12
+
13
+
14
+ def apply_inner_factorization(
15
+ inner: LinearSolver,
16
+ matrix: Any,
17
+ vec: Any,
18
+ *,
19
+ refactor: bool,
20
+ on_device: bool,
21
+ structure_changed: bool = False,
22
+ ) -> Any:
23
+ """Apply ``inner`` direct solver's factorization of ``matrix`` to ``vec``.
24
+
25
+ When ``refactor`` is ``True``, the inner solver (re)factorizes ``matrix`` and
26
+ applies the factorization. When ``False``, the cached factorization is reused
27
+ (``matrix_status='UNCHANGED'``) even if ``matrix`` coefficients were updated
28
+ elsewhere.
29
+
30
+ Parameters
31
+ ----------
32
+ inner : LinearSolver
33
+ Direct solver whose factorization is applied.
34
+ matrix : sparse matrix
35
+ System matrix in the inner solver's backend format.
36
+ vec : array
37
+ Right-hand side vector (host or device).
38
+ refactor : bool
39
+ If ``True``, refresh the factorization before applying it.
40
+ on_device : bool
41
+ If ``True``, call ``inner._solve_system`` without a host round-trip.
42
+ If ``False``, marshal through OpenSees-style buffers and ``inner.solve``.
43
+ structure_changed : bool, optional
44
+ When ``refactor`` is ``True``, use ``STRUCTURE_CHANGED`` instead of
45
+ ``COEFFICIENTS_CHANGED``.
46
+
47
+ Returns
48
+ -------
49
+ array
50
+ Solution vector on the inner solver's compute device.
51
+
52
+ Raises
53
+ ------
54
+ SolverConvergenceError
55
+ If the inner solve reports failure.
56
+ """
57
+ if refactor:
58
+ matrix_status = "STRUCTURE_CHANGED" if structure_changed else "COEFFICIENTS_CHANGED"
59
+ else:
60
+ matrix_status = "UNCHANGED"
61
+
62
+ if on_device:
63
+ rhs = inner._to_device(np.asarray(_host_array(vec), dtype=inner._compute_dtype))
64
+ result, info, _ = inner._solve_system(matrix, rhs, None, matrix_status)
65
+ if info != 0:
66
+ raise SolverConvergenceError(
67
+ f"Inner direct solve failed with info={info} (matrix_status={matrix_status!r})"
68
+ )
69
+ return result
70
+
71
+ n = matrix.shape[0]
72
+ rhs_host = np.asarray(_host_array(vec), dtype=np.float64).ravel()
73
+ x_host = np.zeros(n, dtype=np.float64)
74
+ lin_kwargs = csr_linear_kwargs_from_matrix(
75
+ matrix,
76
+ rhs_host,
77
+ matrix_status=matrix_status,
78
+ x=x_host,
79
+ )
80
+ info = inner.solve(**lin_kwargs)
81
+ if info != 0:
82
+ raise SolverConvergenceError(
83
+ f"Inner direct solve failed with info={info} (matrix_status={matrix_status!r})"
84
+ )
85
+ solution = np.frombuffer(lin_kwargs["x"], dtype=np.float64, count=n).copy()
86
+ return inner._to_device(solution)