Skip to content

qlroms.operators

Common operator layer for all qlROM model families.

Every model family (qlGalerkin, qlOpinf, qlESN) exposes the same forecast form on augmented chart-local states apod (r+1, 1) with the cluster id in the last row:

apod^{n+1} = model.step(apod^n)          # discrete map, all families

so the outer forecast/DA workflow (qlroms, the external DA layer) never depends on model-family internals. Continuous families (Galerkin/OpInf) realize step() by an ETDRK4 integration of their da/dt = g(a); qlESN is natively discrete.

This module owns the case-agnostic operator machinery: the contour-integration ETDRK4 coefficient builder (build_etdrk4_coeffs) and the dimension-agnostic quadratic reduced-space steppers (quadratic_etdrk4_step, quadratic_map_step). The non-intrusive regression core (fit_opinf_operators, fit_quadratic_map, stabilize_operator) lives in qlroms.data_driven_qlroms.regression.

build_etdrk4_coeffs(L, dt, M=32, R=15.0)

Unified ETDRK4 coefficients builder via contour integration.

Computes (E, E2, Q, f1, f2, f3) from an unscaled linear operator L via scipy.linalg.expm for exponentials and contour integration for resolvent integrals. Works for both reduced (r x r) and full-order (Nx x Nx) operators.

Parameters:

Name Type Description Default
L

Unscaled linear operator matrix (n x n), typically a spectral matrix or reduced operator.

required
dt

Time step size (used to scale L internally).

required
M

Number of contour quadrature points (default 32).

32
R

Contour radius for resolvent integral (default 15.0).

15.0

Returns:

Type Description

Tuple (E, E2, Q, f1, f2, f3), each (n x n) real numpy arrays.

Source code in qlroms/operators.py
34
35
36
37
38
39
40
41
42
43
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
83
84
85
86
87
88
def build_etdrk4_coeffs(L, dt, M=32, R=15.0):
    """Unified ETDRK4 coefficients builder via contour integration.

    Computes (E, E2, Q, f1, f2, f3) from an unscaled linear operator L via
    scipy.linalg.expm for exponentials and contour integration for resolvent integrals.
    Works for both reduced (r x r) and full-order (Nx x Nx) operators.

    Args:
        L: Unscaled linear operator matrix (n x n), typically a spectral matrix or reduced operator.
        dt: Time step size (used to scale L internally).
        M: Number of contour quadrature points (default 32).
        R: Contour radius for resolvent integral (default 15.0).

    Returns:
        Tuple (E, E2, Q, f1, f2, f3), each (n x n) real numpy arrays.
    """
    # Convert to numpy if needed
    L_np = _to_numpy(L)
    rdim = L_np.shape[0]

    # Scale by dt to get the matrix exponential argument
    A = dt * L_np.astype(complex)

    # Exponentials via scipy
    E = expm(A)
    E2 = expm(A / 2.0)

    # Resolvent integrals via contour integration
    I = np.eye(rdim, dtype=complex)
    Q = np.zeros((rdim, rdim), dtype=complex)
    f1 = np.zeros((rdim, rdim), dtype=complex)
    f2 = np.zeros((rdim, rdim), dtype=complex)
    f3 = np.zeros((rdim, rdim), dtype=complex)

    m = np.arange(1, M + 1)
    Rvals = R * np.exp(1j * np.pi * (m - 0.5) / M)

    for z in Rvals:
        zIA = np.linalg.inv(z * I - A)
        ez2 = np.exp(z / 2.0)
        ez = np.exp(z)

        Q += dt * zIA * (ez2 - 1.0)
        f1 += dt * zIA * ((-4.0 - z + ez * (4.0 - 3.0 * z + z * z)) / (z * z))
        f2 += dt * zIA * ((2.0 + z + ez * (z - 2.0)) / (z * z))
        f3 += dt * zIA * ((-4.0 - 3.0 * z - z * z + ez * (4.0 - z)) / (z * z))

    Q = np.real(Q / M)
    f1 = np.real(f1 / M)
    f2 = np.real(f2 / M)
    f3 = np.real(f3 / M)
    E = np.real(E)
    E2 = np.real(E2)

    return E, E2, Q, f1, f2, f3

quadratic_etdrk4_step(a, b, B, etdrk4_coeffs)

One ETDRK4 step of da/dt = b + B(a, a) (A already folded into etdrk4_coeffs).

Dimension-agnostic: only ever reads (r,)-sized tensors, so the same function steps OpInf-fitted 1D and 2D clusters. etdrk4_coeffs are the (E, E2, Q, f1, f2, f3) coefficients built from the fitted linear operator A (see qlOpinf.get_stepping_coeffs); b, B are that cluster's fitted affine/quadratic parts. a is a pure (r, 1) reduced-coordinate column (no cluster-id row).

Source code in qlroms/operators.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def quadratic_etdrk4_step(a: torch.Tensor, b: torch.Tensor, B: torch.Tensor, etdrk4_coeffs: tuple) -> torch.Tensor:
    """One ETDRK4 step of  da/dt = b + B(a, a)  (A already folded into etdrk4_coeffs).

    Dimension-agnostic: only ever reads (r,)-sized tensors, so the same function
    steps OpInf-fitted 1D and 2D clusters. `etdrk4_coeffs` are the (E, E2, Q, f1,
    f2, f3) coefficients built from the fitted linear operator A (see
    qlOpinf.get_stepping_coeffs); b, B are that cluster's fitted affine/quadratic
    parts. `a` is a pure (r, 1) reduced-coordinate column (no cluster-id row).
    """
    E, E2, Q, f1, f2, f3 = etdrk4_coeffs

    def nfun(av: torch.Tensor) -> torch.Tensor:
        return b[:, None] + torch.einsum("ijk,jm,km->im", B, av, av)

    Nu = nfun(a)
    aa = E2 @ a + Q @ Nu
    Na = nfun(aa)
    bb = E2 @ a + Q @ Na
    Nb = nfun(bb)
    cc = E2 @ aa + Q @ (2.0 * Nb - Nu)
    Nc = nfun(cc)
    return E @ a + f1 @ Nu + 2.0 * f2 @ (Na + Nb) + f3 @ Nc

quadratic_map_step(a, b, A, B=None)

One DISCRETE step of the map a^{n+1} = b + A a^n + B(a^n, a^n).

The direct one-step analog of quadratic_etdrk4_step (no ODE, no integrator): the fitted operators ARE the map, applied as-is (qlOpinf(discrete=True)). a is a pure (r, 1) reduced-coordinate column. B=None (or empty) gives the affine/linear map b + A a -- classic DMD.

Source code in qlroms/operators.py
115
116
117
118
119
120
121
122
123
124
125
126
127
def quadratic_map_step(a: torch.Tensor, b: torch.Tensor, A: torch.Tensor,
                       B: torch.Tensor | None = None) -> torch.Tensor:
    """One DISCRETE step of the map  a^{n+1} = b + A a^n + B(a^n, a^n).

    The direct one-step analog of quadratic_etdrk4_step (no ODE, no integrator): the
    fitted operators ARE the map, applied as-is (qlOpinf(discrete=True)). `a` is a pure
    (r, 1) reduced-coordinate column. B=None (or empty) gives the affine/linear map
    b + A a -- classic DMD.
    """
    out = b[:, None] + A @ a
    if B is not None and B.numel():
        out = out + torch.einsum("ijk,jm,km->im", B, a, a)
    return out