Skip to content

Scipy Operator

scipy_operator

Explicit eager SciPy adapters for banded and matrix-free linear solves.

BandedOperator(bands, lower, upper) dataclass

Real NumPy operator in SciPy's diagonal-ordered band storage.

bands[upper + i - j, j] stores matrix entry (i, j). Construction copies the input and application touches only valid band entries. The retained storage is read-only; construct a new operator when coefficients, mesh or stage change. No dense matrix or factorization cache is created.

T property

Return the real Euclidean transpose without dense assembly.

dtype property

Return the native storage dtype.

shape property

Return square operator dimensions.

__matmul__(other)

Apply valid bands to a vector or independent RHS columns.

Returns:

Type Description
NDArray[floating]

NumPy array with the same shape and dtype as the RHS.

Raises:

Type Description
TypeError

If namespace or dtype differs.

ValueError

If RHS dimensions disagree.

Source code in src/op_engine/scipy_operator.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def __matmul__(self, other: Array, /) -> NDArray[np.floating]:
    """Apply valid bands to a vector or independent RHS columns.

    Returns:
        NumPy array with the same shape and dtype as the RHS.

    Raises:
        TypeError: If namespace or dtype differs.
        ValueError: If RHS dimensions disagree.
    """
    if not isinstance(other, np.ndarray) or other.dtype != self.dtype:
        msg = "banded application requires the same NumPy dtype"
        raise TypeError(msg)
    n = self.shape[0]
    if other.ndim not in {1, 2} or other.shape[0] != n:
        msg = "banded application requires vector/matrix RHS with matching rows"
        raise ValueError(msg)
    result = np.zeros_like(other)
    for offset in range(-self.upper, self.lower + 1):
        start, stop = max(0, -offset), min(n, n - offset)
        weights = self.bands[self.upper + offset, start:stop]
        if other.ndim == 2:
            weights = weights[:, None]
        result[start + offset : stop + offset] += weights * other[start:stop]
    return result

__post_init__()

Copy and validate storage and static bandwidths.

Raises:

Type Description
ValueError

If dimensions, bandwidths or valid band entries are invalid.

TypeError

If storage is not native NumPy float32/float64.

Source code in src/op_engine/scipy_operator.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def __post_init__(self) -> None:
    """Copy and validate storage and static bandwidths.

    Raises:
        ValueError: If dimensions, bandwidths or valid band entries are invalid.
        TypeError: If storage is not native NumPy float32/float64.
    """
    real_dtypes = (np.float32, np.float64)
    if (
        not isinstance(self.bands, np.ndarray)
        or self.bands.dtype not in real_dtypes
    ):
        msg = "band storage must be a NumPy float32/float64 array"
        raise TypeError(msg)
    if self.bands.ndim != 2 or self.bands.shape[1] < 1:
        msg = "band storage must have shape (lower + upper + 1, n), n >= 1"
        raise ValueError(msg)
    n = self.bands.shape[1]
    if (
        any(
            not isinstance(v, Integral) or isinstance(v, bool) or not 0 <= v < n
            for v in (self.lower, self.upper)
        )
        or self.bands.shape[0] != self.lower + self.upper + 1
    ):
        msg = "bandwidths must be integers in [0, n) matching storage rows"
        raise ValueError(msg)
    data = self.bands.copy()
    # Canonicalize unused padding; it is not part of the represented matrix.
    for offset in range(-self.upper, self.lower + 1):
        start, stop = max(0, -offset), min(n, n - offset)
        row = self.upper + offset
        data[row, :start] = 0
        data[row, stop:] = 0
    if not np.isfinite(data).all():
        msg = "valid band entries must be finite"
        raise ValueError(msg)
    data.flags.writeable = False
    object.__setattr__(self, "bands", data)

ScipyGmresSolver(restart=20) dataclass

Uncached eager GMRES with explicit left inverse preconditioning.

The requested iteration budget is inner iterations per RHS column. iterations in the candidate/checked diagnostics is the maximum used by any column, not the total across columns. Each column starts at zero. There is no warm-start, basis, factorization or preconditioner cache. SciPy minimizes a preconditioned residual; solve_linear independently gates the original residual before stage adoption. No differentiation is supplied.

__call__(request)

Solve independent columns without forming the represented matrix.

Returns:

Type Description
LinearSolveCandidate

Candidate and solver termination evidence for original-residual checks.

Source code in src/op_engine/scipy_operator.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
def __call__(self, request: LinearSolveRequest) -> LinearSolveCandidate:
    """Solve independent columns without forming the represented matrix.

    Returns:
        Candidate and solver termination evidence for original-residual checks.
    """
    n = request.rhs.shape[0]
    operator = LinearOperator(
        (n, n),
        dtype=request.rhs.dtype,
        matvec=lambda v: _apply_numpy(request.operator, v),
    )
    inverse = request.preconditioner
    preconditioner = (
        None
        if inverse is None
        else LinearOperator(
            (n, n),
            dtype=request.rhs.dtype,
            matvec=lambda v: _apply_numpy(inverse, v),
        )
    )
    columns = request.rhs[:, None] if request.rhs.ndim == 1 else request.rhs
    result = np.empty_like(columns)
    counts: list[int] = []
    statuses: list[int] = []
    # The legacy callback mode fixes maxiter to inner iterations on both
    # old (tol) and current (rtol) SciPy; explicit atol avoids legacy defaults.
    tolerance_name = "rtol" if "rtol" in signature(gmres).parameters else "tol"
    for column in range(columns.shape[1]):
        count = 0

        def update(_residual: object) -> None:
            nonlocal count
            count += 1

        value, status = cast("Any", gmres)(
            operator,
            columns[:, column],
            **{tolerance_name: request.options.rtol},
            atol=request.options.atol,
            restart=self.restart,
            maxiter=request.options.max_iterations,
            M=preconditioner,
            callback=update,
            callback_type="legacy",
        )
        result[:, column] = value
        counts.append(count)
        statuses.append(int(status))
    output = result[:, 0] if request.rhs.ndim == 1 else result
    return LinearSolveCandidate(
        output,
        success=all(status == 0 for status in statuses),
        iterations=max(counts, default=0),
        message=f"GMRES inner iterations per column={counts}; info={statuses}",
    )

__post_init__()

Validate the static restart length.

Raises:

Type Description
ValueError

If restart is not a positive integer.

Source code in src/op_engine/scipy_operator.py
234
235
236
237
238
239
240
241
242
243
244
245
246
def __post_init__(self) -> None:
    """Validate the static restart length.

    Raises:
        ValueError: If restart is not a positive integer.
    """
    if (
        not isinstance(self.restart, Integral)
        or isinstance(self.restart, bool)
        or self.restart < 1
    ):
        msg = "GMRES restart must be a positive integer"
        raise ValueError(msg)

scipy_banded_adapter(*, ecosystem_id='scipy-banded', options=None)

Build an explicitly registered, uncached eager banded direct adapter.

Returns:

Type Description
SparseAdapter

Existing-registry adapter recognizing a BandedOperator left operand.

Source code in src/op_engine/scipy_operator.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def scipy_banded_adapter(
    *,
    ecosystem_id: str = "scipy-banded",
    options: LinearSolveOptions | None = None,
) -> SparseAdapter:
    """Build an explicitly registered, uncached eager banded direct adapter.

    Returns:
        Existing-registry adapter recognizing a BandedOperator left operand.
    """
    return SparseAdapter(
        ecosystem_id,
        lambda op: isinstance(op, BandedOperator),
        linear_solver=_banded_solve,
        solve_options=options if options is not None else LinearSolveOptions(),
        accepts_right=_accepted_right,
    )

scipy_gmres_adapter(*, ecosystem_id='scipy-gmres', restart=20, options=None, preconditioner=None, context=None)

Build an explicit eager matrix-free adapter in the existing registry.

Returns:

Type Description
SparseAdapter

Adapter for ActionOperator left operands, with left inverse preconditioning.

Source code in src/op_engine/scipy_operator.py
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def scipy_gmres_adapter(
    *,
    ecosystem_id: str = "scipy-gmres",
    restart: int = 20,
    options: LinearSolveOptions | None = None,
    preconditioner: ActionOperator | None = None,
    context: object | None = None,
) -> SparseAdapter:
    """Build an explicit eager matrix-free adapter in the existing registry.

    Returns:
        Adapter for ActionOperator left operands, with left inverse preconditioning.
    """
    return SparseAdapter(
        ecosystem_id,
        lambda op: isinstance(op, ActionOperator),
        linear_solver=ScipyGmresSolver(restart),
        solve_options=options if options is not None else LinearSolveOptions(),
        preconditioner=preconditioner,
        context=context,
        accepts_right=_accepted_right,
    )