MORFE.jl/Code Documentation

Code Documentation

21 modules · 382 documented entries · generated 5 October 2026

Module Multiindices — graded-lexicographic multiindex sets and combinatorial utilities.

A multiindex is an exponent vector α ∈ ℕᴺ that identifies the monomial z₁^α₁ ⋯ zₙ^αₙ in N variables. This module provides:

FactorisationEntry

One result from a factorisation function: an ordered list of multiindex-set indices (one per factor slot) together with the number of distinct ordered arrangements of that combination.

All three factorisation functions return Vector{FactorisationEntry}, so call sites do not need to know which variant produced the data.

Fields

  • factor_indices::Vector{Int} — indices into the multiindex set, one per factor slot.

  • multiplier::Int — number of distinct orderings (permutations) of this combination. Always 1 for factorisations_asymmetric, which enumerates each ordering as a separate entry instead of folding it into a count.

MultiindexSet{N}

A fixed collection of exponent vectors (multiindices) stored as a vector of SVector{N, Int}. The set is guaranteed to be sorted according to the graded lexicographic (Grlex) order.

Fields

  • exponents::Vector{SVector{N, Int}}: the sorted list of exponent vectors.

  • degree_offsets::Vector{Int}: precomputed boundary table; degree_offsets[d+1] is the last index in exponents with total degree < d, enabling O(1) degree-range queries.

function _last_index_below_degree(set::MultiindexSet, max_total_deg::Int64) _last_index_below_degree(set::MultiindexSet, max_total_deg::Int64, allowed_indices::AbstractVector{Int64})
_last_index_below_degree(set::MultiindexSet, max_total_deg::Int) -> Int

Return the last column index i in the Grlex‑sorted set such that the total degree of set[i] is strictly less than max_total_deg. If no such index exists, return 0. O(1) via the precomputed degree_offsets field.

_last_index_below_degree(set::MultiindexSet, max_total_deg::Int,
						 allowed_indices::AbstractVector{Int}) -> Int

Same as the 2‑argument version, but restricted to the indices listed in allowed_indices (must be sorted in increasing order). Uses the O(1) degree_offsets boundary then searchsortedlast to locate the answer; no per-element degree sums are computed.

function _lex_cmp_same_degree(v::StaticArraysCore.SVector{N, Int64}, e) where N
_lex_cmp_same_degree(v::SVector{N,Int}, e) -> Int

Three‑way descending‑lexicographic comparison of two exponents of equal total degree: -1 if v precedes e, +1 if e precedes v, 0 if they are equal. Since the degrees are known to match, no degree sums are computed.

function _multinomial(e::Int64, k::Vector{Int64})
_multinomial(e::Int, k::Vector{Int}) -> Int

Multinomial coefficient: e! / (k₁! k₂! … kₚ!) where sum(k) = e. Uses iterative multiplication of binomial coefficients to avoid overflow.

function _total_degree(exp, ::Val{N}) where N
_total_degree(exp, ::Val{N}) -> Int

Total degree of an N-component exponent. Written as an explicit loop so that it works uniformly for Vector, SVector and NTuple, and returns 0 for N == 0, where sum on an empty tuple is not defined.

function all_multiindices_in_box(bound::Vector{Int64})
all_multiindices_in_box(bound::Vector{Int}) -> MultiindexSet

Generate all multi-indices v of length length(bound) such that 0 ≤ v[i] ≤ bound[i] for all i. The vectors are generated and then sorted according to graded lexicographic order.

function all_multiindices_up_to(nvars::Int64, max_degree::Int64; min_degree)
all_multiindices_up_to(nvars::Int, max_degree::Int) -> MultiindexSet

Generate all exponent vectors with nvars variables whose total degree ≤ max_degree, sorted according to graded lexicographic order. Returns a MultiindexSet.

function bounded_index_tuples(M::Int64, exp::StaticArraysCore.SVector{0, T}) where T bounded_index_tuples(M::Int64, exp::StaticArraysCore.SVector{N, Int64}) where N
bounded_index_tuples(M, exp::SVector{N,Int})

Enumerate all index tuples of length M whose component counts are bounded by exp.

This function generates all tuples (i₁, ..., i_M) with entries in 1:N such that:

  • Each index k appears at most exp[k] times

  • The total length of the tuple is exactly M

Each tuple is represented in its canonical sorted form (non-decreasing order), so that it uniquely corresponds to a count vector (multiindex).

For each valid tuple, the function also returns:

  • The associated multiindex alpha, where alpha[k] is the number of times k appears

  • The number of distinct permutations of the tuple

Returns a vector of tuples: (indextuple, multiindex, permutationcount)

where:

  • index_tuple::NTuple{M,Int} is the sorted tuple

  • multiindex::SVector{N,Int} contains the counts

  • permutation_count::Int is the number of distinct permutations

function build_exponent_index_map(set::MultiindexSet{N}) where N
build_exponent_index_map(set::MultiindexSet) -> Dict{SVector{N,Int}, Int}

Build a dictionary mapping each exponent SVector to its 1-based column index in set. Useful for O(1) lookups without repeated binary searches.

function delete_multiindices(set::MultiindexSet{N}, victims::MultiindexSet{M}) where {N, M} delete_multiindices(set::MultiindexSet{N}, exps) where N delete_multiindices(pred, set::MultiindexSet{N}) where N
delete_multiindices(set::MultiindexSet{N}, exps) -> MultiindexSet{N}

Return a new MultiindexSet holding every exponent of set except those listed in exps. set itself is never modified — there is no in-place variant.

exps may be a single exponent (SVector, Vector{Int} or NTuple{N,Int}), any iterable of such exponents, or another MultiindexSet{N}. Exponents that are not members of set are ignored, matching Base.setdiff; an exponent whose length differs from N throws an ArgumentError.

Deleting cannot reorder a Grlex-sorted list, so the result is built through the pre-sorted path: no re-sort is performed, and degree_offsets is rebuilt so the O(1) degree-boundary queries stay correct even when an entire degree block disappears.

Removing an exponent can break downward closure, which parametrise(model, spectral, mset) requires — check the result with is_downward_closed.

Examples

S = all_multiindices_up_to(2, 2)              # 6 monomials
T = delete_multiindices(S, [[2, 0], [0, 2]])  # drop the two pure squares
length(S), length(T)                          # (6, 4) — S is unchanged
delete_multiindices(pred, set::MultiindexSet{N}) -> MultiindexSet{N}

Return a new MultiindexSet holding the exponents of set for which pred(α) is false; set itself is never modified. pred receives each exponent as an SVector{N,Int}. This is the exact complement of filter(pred, set): together the two partition set.

Examples

S = all_multiindices_up_to(3, 4)
delete_multiindices(α -> sum(α) > 2, S) == all_multiindices_up_to(3, 2)   # true
function divides(a::AbstractVector{Int64}, b::AbstractVector{Int64})
divides(a::AbstractVector{Int}, b::AbstractVector{Int}) -> Bool

Check whether a divides b componentwise, i.e., a[i] ≤ b[i] for all i.

function factorisations_asymmetric(set::MultiindexSet{N}, exp::AbstractVector{Int64}, num_factors::Int64, candidate_indices::AbstractVector{Int64}) where N
factorisations_asymmetric(set, exp, num_factors, candidate_indices) -> Vector{FactorisationEntry}

Return every ordered num_factors-tuple of indices from candidate_indices whose exponent vectors sum to exp. Each ordering is a separate entry with multiplier = 1.

function factorisations_fully_symmetric(set::MultiindexSet{N}, exp::AbstractVector{Int64}, num_factors::Int64, candidate_indices::AbstractVector{Int64}) where N
factorisations_fully_symmetric(set, exp, num_factors, candidate_indices) -> Vector{FactorisationEntry}

Return all unordered (non-decreasing index) num_factors-tuples from candidate_indices whose exponent vectors sum to exp. multiplier is the multinomial coefficient num_factors! / (m₁! m₂! … mₖ!) counting the distinct ordered arrangements of each combination.

Duplicate entries in candidate_indices are removed internally.

function factorisations_groupwise_symmetric(set::MultiindexSet{N}, exp::AbstractVector{Int64}, group_sizes::NTuple{M, Int64}, candidate_indices::AbstractVector{Int64}) where {N, M}
factorisations_groupwise_symmetric(set, exp, group_sizes, candidate_indices) -> Vector{FactorisationEntry}

Return factorisations of exp into M = length(group_sizes) groups, where group i has group_sizes[i] factor slots and is internally symmetric.

factor_indices in each entry is the concatenation of per-group indices in group order (each group sorted non-decreasingly). multiplier is the product of the per-group permutation counts — the total number of ordered arrangements.

function filter(pred, set::MultiindexSet{N}) where N
filter(pred, set::MultiindexSet{N}) -> MultiindexSet{N}

Return a new MultiindexSet holding the exponents of set for which pred(α) is true, leaving set untouched. pred receives each exponent as an SVector{N,Int}.

Filtering preserves Grlex order, so the result is built through the pre-sorted path. Use it to intersect independent conditions, e.g. a total-degree bound on the master coordinates together with a per-parameter box:

mset = filter(α -> α[1] + α[2] ≤ 4 && α[3] ≤ 2, all_multiindices_in_box([4, 4, 2]))

See delete_multiindices for the removal-side counterpart.

function find_in_set(set::MultiindexSet{N}, exp::AbstractVector{Int64}) where N find_in_set(set::MultiindexSet{N}, exp::NTuple{N, Int64}) where N
find_in_set(set::MultiindexSet, exp::AbstractVector{Int}) -> Union{Int, Nothing}

Return the column index of exp in set.exponents, or nothing if exp is not present (including when its length differs from the number of variables).

The total degree of exp brackets the search to a single degree block via degree_offsets, after which a binary search in Grlex (here plain descending lexicographic, the degrees being equal) locates the exponent.

find_in_set(set::MultiindexSet, exp::NTuple{N,Int}) where N -> Union{Int, Nothing}

Tuple version – avoids allocating a vector for the exponent.

function grlex_precede(a::AbstractVector{<:Integer}, b::AbstractVector{<:Integer})
grlex_precede(a::AbstractVector{<:Integer}, b::AbstractVector{<:Integer}) -> Bool

Graded lexicographic order: compare total degree first, then lexicographic descending. Returns true if a comes before b in this order.

function indices_in_box_with_bounded_degree(set::MultiindexSet{N}, box_upper::AbstractVector{Int64}, degree_lower_bound::Int64, total_deg_upper::Int64) where N indices_in_box_with_bounded_degree(set::MultiindexSet{N}, box_upper::AbstractVector{Int64}, degree_lower_bound::Int64, total_deg_upper::Int64, allowed_indices::AbstractVector{Int64}) where N
indices_in_box_with_bounded_degree(set::MultiindexSet, box_upper::AbstractVector{Int},
								   degree_lower_bound::Int, total_deg_upper::Int) -> Vector{Int}

Return the column indices of all multiindices v in set such that

  • v ≤ box_upper componentwise,

  • degree_lower_bound ≤ sum(v[i]) < total_deg_upper.

Uses the degree bounds to limit the search to relevant columns.

indices_in_box_with_bounded_degree(set::MultiindexSet, box_upper::AbstractVector{Int},
								   degree_lower_bound::Int, total_deg_upper::Int,
								   allowed_indices::AbstractVector{Int}) -> Vector{Int}

Same as the 4‑argument version, but the search is restricted to the indices listed in allowed_indices (which must be sorted). Only those indices that also satisfy the componentwise bound are returned.

function is_conjugate_closed(set::MultiindexSet{N}, perm::AbstractVector{Int64}) where N
is_conjugate_closed(set::MultiindexSet{N}, perm::AbstractVector{Int}) -> Bool

Return true when the conjugate partner of every member of set is itself a member.

perm is an N-length permutation pairing conjugate coordinates (self-paired entries for real ones). It acts on the components of an exponent,

(P·α)[k] = α[perm[k]],

the same convention the cohomological solve uses when it fills a conjugate monomial from its partner. Monomials with P·α == α are self-paired and trivially closed.

parametrise(model, spectral, mset; conjugate_permutation = ...) requires this: a member whose partner is absent is solved directly instead of being filled by conjugation, which costs the pairing optimisation and returns a parametrisation without the conjugate structure the permutation asserts. It is enforced through validate_multiindex_set, which reports which partner is missing; use this predicate when a plain Bool is enough.

Throws an ArgumentError if length(perm) != N.

function is_constant(exp::AbstractVector{Int64})
is_constant(exp::AbstractVector{Int}) -> Bool

Return true if the exponent vector is all zeros.

function is_downward_closed(set::MultiindexSet{N}) where N
is_downward_closed(set::MultiindexSet{N}) -> Bool

Return true when every divisor of every member of set is itself a member.

This is the downward closure property that parametrise(model, spectral, mset) requires: the graded solve reads lower-order coefficients W[α - eᵢ] while working on α, so a missing divisor would be silently read as zero. Combinatorial truncations (all_multiindices_up_to, all_multiindices_in_box) are closed by construction, but a spectral criterion such as |⟨λ, α⟩| ≤ R generally is not.

parametrise and solve_parametrisation enforce this through validate_multiindex_set, which reports which divisor is missing; use this predicate when a plain Bool is enough.

The zero multiindex is exempt: DPIM sets are built with min_degree = 1, so a degree-1 exponent is not asked to have its constant divisor present.

Only the immediate predecessors α - eᵢ are probed; by induction over the total degree that is equivalent to checking every divisor. Cost is O(N · |set| · log) through find_in_set.

function monomial_rank(exp::AbstractVector{Int64}, nvars::Int64, max_degree::Int64)
monomial_rank(exp::AbstractVector{Int}, nvars::Int, max_degree::Int) -> Int

Return the 1‑based rank of the exponent vector exp in the complete set of all multiindices of length nvars with total degree ≤ max_degree, sorted according to graded lexicographic order (Grlex).

The vector exp must satisfy length(exp) == nvars and sum(exp) ≤ max_degree. Uses combinatorial counting formulas for efficiency (O(nvars) time).

If the computed rank exceeds typemax(Int), an error is thrown.

function multiindex(components::Int64...)
multiindex(components::Int...) -> Vector{Int}

Convenience constructor for an exponent vector.

function multiindices_with_total_degree(nvars::Int64, degree::Int64)
multiindices_with_total_degree(nvars::Int, deg::Int) -> MultiindexSet

Generate all exponent vectors of length nvars with total degree exactly deg, sorted in lexicographic order (the tie‑breaker for Grlex). Returns a MultiindexSet.

function num_multiindices_up_to(nvars::Int64, max_degree::Int64)
num_multiindices_up_to(nvars::Int, max_degree::Int) -> Integer

Return the number of exponent vectors of length nvars with total degree ≤ max_degree. For nvars = 0 the set contains one element if max_degree ≥ 0, otherwise zero.

function zero_multiindex(n::Int64)
zero_multiindex(n::Int) -> Vector{Int}

Return the zero exponent vector of length n (all components zero).

Module Polynomials — dense multivariate polynomial type aligned to a MultiindexSet.

A DensePolynomial{T, NVAR, RANK} stores coefficients in a single contiguous array whose last axis indexes monomials in the same GrLex order as the associated MultiindexSet. The layout is chosen so that polynomial evaluation reduces to a single BLAS gemv / dot call regardless of the output rank:

  • Scalar polynomial (RANK = 1): coefficient array of shape (L,).

  • Vector polynomial (RANK = 2): coefficient array of shape (K, L).

  • Tensor polynomial (RANK = k+1): coefficient array of shape (d₁, …, dₖ, L).

Key operations: evaluate, extract_component, each_term, similar_poly, linear_matrix_of_polynomial, coefficient.

DensePolynomial{T, NVAR, N, A}

Cache-friendly dense polynomial with a single contiguous coefficient array.

Type parameters

parammeaning
TScalar element type (Float64, ComplexF64, …). Must be a concrete bits type for best performance.
NVARNumber of input variables.
Nndims(coefficients). Number of axes: N = 1 for scalar, N = 2 for vector-valued, etc.
AConcrete array type (Array{T,N} normally; can be an Mmap array).

Fields

  • coefficients::A — shape (d1, …, d_{N-1}, L). The last axis indexes the L monomials in multiindex_set. Leading axes describe the coefficient shape (none for scalar, (K,) for a K-vector, etc.).

  • multiindex_set::MultiindexSet{NVAR} — Grlex-ordered monomial basis.

  • max_exponents::SVector{NVAR,Int} — per-variable max exponent (used to pre-allocate the power table in evaluate).

DensePolynomial(coeff_vec::Vector{SVector{K,T}}, mset)

Vector-valued polynomial from the old Vector{SVector} layout. Converts to a contiguous (K × L) matrix, filling columns in parallel.

DensePolynomial(dict::Dict{Vector{Int}, T}) where T<:Number

Scalar polynomial from an exponent → coefficient dictionary.

DensePolynomial(dict::Dict{Vector{Int}, SVector{K,T}}) where {K,T}

Vector-valued polynomial from an exponent → SVector dictionary. Coefficients are laid out column-by-column (parallel fill).

function _monomial_vector!(m::Vector{Tv}, poly::DensePolynomial{T, NVAR, N, A} where {N, A<:AbstractArray{T, N}}, vals::AbstractVector{<:Number}) where {Tv, T, NVAR}
_monomial_vector!(m, poly, vals)

Fill the pre-allocated vector m (length L) with the value of each monomial in poly.multiindex_set evaluated at vals. This is the central loop; called once per evaluate.

function _precompute_powers(::Type{T}, vals::AbstractVector{<:Number}, max_exps::StaticArraysCore.SVector{NVAR, Int64}) where {T, NVAR}
_precompute_powers(T, vals, max_exps) -> NTuple of Vectors

For each variable j, store vals[j]^e for e = 0 … max_exps[j]. Avoids repeated ^ calls inside the inner loop.

function coeff_shape(p::DensePolynomial{T, NVAR, N, A} where A<:AbstractArray{T, N}) where {T, NVAR, N}
coeff_shape(p) -> NTuple

Leading axes of the coefficient array. () for scalar, (K,) for K-vector, (m,n) for a matrix-valued polynomial, etc.

function coefficient(p::DensePolynomial{T, NVAR, 1, A} where A<:AbstractVector{T}, exp::AbstractVector{Int64}) where {T, NVAR} coefficient(p::DensePolynomial{T, NVAR, 2, A} where A<:AbstractMatrix{T}, exp::AbstractVector{Int64}) where {T, NVAR}
coefficient(p, exp) -> scalar or view

Return the coefficient of the monomial with exponent exp. For scalar polynomials this is a T; for vector-valued it is a Vector{T} view into the backing array — no allocation.

function compose_linear(poly::DensePolynomial, M::Matrix{TA}) where TA
compose_linear(poly::DensePolynomial, M::Matrix{TA}) where TA -> DensePolynomial

Compose a multivariate polynomial with a linear map.

Arguments

  • poly: polynomial in variables x₁, …, x_n. (The coefficient type can be numeric or array‑valued.)

  • M: an n × p matrix. Composition means replacing x_i by ∑_{j=1}^p M[i,j] * y_j, where y₁, …, y_p are new variables.

Returns

A new polynomial in the variables y₁, …, y_p. The returned polynomial has the same coefficient type as the input poly.

Element type

The accumulators are typed from poly, so a real polynomial composed with a complex M cannot store the products back. Promote the polynomial first, e.g. DensePolynomial(promote_type(eltype(coefficients(p)), TA).(coefficients(p)), multiindex_set(p)).

function each_term(poly::DensePolynomial{T, NVAR, 1, A} where A<:AbstractVector{T}) where {T, NVAR} each_term(poly::DensePolynomial{T, NVAR, 2, A} where A<:AbstractMatrix{T}) where {T, NVAR}
each_term(poly) -> generator of (exponent, coefficient)

Yields (SVector{NVAR,Int}, coeff) for every nonzero monomial.

  • Scalar (N=1): coeff is a T.

  • Vector (N=2): coeff is a SubArray{T,1} view (no allocation).

function evaluate(poly::DensePolynomial{T, NVAR, 1, A} where A<:AbstractVector{T}, vals::AbstractVector{<:Number}) where {T, NVAR} evaluate(poly::DensePolynomial{T, NVAR, 2, A} where A<:AbstractMatrix{T}, vals::AbstractVector{<:Number}) where {T, NVAR} evaluate(poly::DensePolynomial{T, NVAR, N, A} where A<:AbstractArray{T, N}, vals::AbstractVector{<:Number}) where {T, NVAR, N} evaluate(poly::DensePolynomial{T, NVAR, 2, A} where A<:AbstractMatrix{T}, vals::AbstractVector{<:Number}, component::Int64) where {T, NVAR}
evaluate(poly::DensePolynomial{T,NVAR,1}, vals) -> T

Scalar polynomial evaluation: sum(c_i * m_i) without conjugation.

evaluate(poly::DensePolynomial{T,NVAR,2}, vals) -> Vector{T}

Vector-valued polynomial evaluation via BLAS gemv:

result = coefficients  (K × L)  *  m  (L,)  →  (K,)

A single BLAS call, no per-term allocation.

evaluate(poly::DensePolynomial{T,NVAR,N}, vals) -> Array

General tensor-valued case: reshape to (prod(leading_dims), L), gemv, reshape back.

evaluate(poly::DensePolynomial{T,NVAR,2}, vals, component::Int) -> T

Evaluate a single component of a vector-valued polynomial without allocating a full output vector.

function extract_component(poly::DensePolynomial{T, NVAR, 2, A} where A<:AbstractMatrix{T}, idx::Int64) where {T, NVAR}
extract_component(poly::DensePolynomial{T,NVAR,2}, idx) -> DensePolynomial{T,NVAR,1}

Return the idx-th component as a scalar polynomial. The coefficient vector is a copy of row idx of the matrix.

function linear_matrix_of_polynomial(poly::DensePolynomial{T, NVAR, 2, A} where A<:AbstractMatrix{T}) where {T, NVAR}
linear_matrix_of_polynomial(poly::DensePolynomial{T,NVAR,2}) -> Matrix{T}

Return the (K × NVAR) matrix A such that the linear part equals A * x. Reads directly from the coefficient matrix — no intermediate arrays.

function mmap_polynomial(path::AbstractString, coeff_size::NTuple{M, Int64}, ::Type{T}, mset::MultiindexSet; write) where {M, T<:Number}
mmap_polynomial(path, coeff_size::NTuple, T::Type, mset; write=false)

Return a DensePolynomial whose coefficient array is backed by a memory-mapped file at path. coeff_size is the leading axes of the coefficient array (empty () for scalar, (K,) for K-vector, …).

The OS page-cache handles RAM pressure automatically: only accessed pages are loaded, so polynomials larger than available RAM work transparently.

Example

# 5-vector polynomial with 1 000 000 monomials, stored on disk
p = mmap_polynomial("coeffs.bin", (5,), Float64, mset; write=true)
function restrict_polynomial_to_degree(poly::DensePolynomial, max_degree::Int64)
restrict_polynomial_to_degree(poly::DensePolynomial, max_degree::Int) -> DensePolynomial

Returns a polynom that contains all the monomials of poly that are of degree lower or equal than max_degree.

function similar_poly(dict::Dict{StaticArraysCore.SVector{NVAR, Int64}, T}) where {NVAR, T<:Number} similar_poly(dict::Dict{StaticArraysCore.SVector{NVAR, Int64}, StaticArraysCore.SVector{K, T}}) where {NVAR, K, T<:Number} similar_poly(dict::Dict{StaticArraysCore.SVector{NVAR, Int64}, Vector{T}}) where {NVAR, T<:Number}
similar_poly(dict::Dict{SVector{NVAR,Int}, T}) where {NVAR, T<:Number}

Scalar polynomial from a dictionary mapping exponent vectors to scalar coefficients.

similar_poly(dict::Dict{SVector{NVAR,Int}, SVector{K,T}}) where {NVAR, K, T<:Number}

Fixed-size-vector polynomial from a dictionary mapping exponent vectors to SVector{K,T} coefficients. The output coefficient matrix has size K × L.

similar_poly(dict::Dict{SVector{NVAR,Int}, Vector{T}}) where {NVAR, T<:Number}

Vector-valued polynomial from a dictionary mapping exponents to Vector{T} coefficients. All vectors must have the same length K.

function zero(::Type{DensePolynomial{T, NVAR, N, A} where {NVAR, N, A<:AbstractArray{T, N}}}, mset::MultiindexSet) where T<:Number zero(::Type{DensePolynomial{T, NVAR, N, A} where {NVAR, N, A<:AbstractArray{T, N}}}, cshape::NTuple{M, Int64}, mset::MultiindexSet) where {T<:Number, M}
zero(DensePolynomial{T}, mset)              # scalar
zero(DensePolynomial{T}, coeff_shape, mset) # tensor-valued

Module MultilinearMaps — multilinear nonlinear term representations for NthOrderModel.

Nonlinear terms in the full-order ODE are encoded as AbstractMultilinearMap subtypes:

See MORFEFerrite.jl (StructuralSVK / FluidNavierStokes) and examples/02_clamped_beam_gridap/ for reference FEM backend implementations.

AbstractMultilinearMap{ORD}

Abstract supertype for all multilinear terms accepted by NthOrderModel.

Every concrete subtype must expose the fields multiindex, multiplicity_external, deg, and fully_asymmetric with the same semantics as MultilinearMap.

FEMMultilinearMap{ORD} <: AbstractMultilinearMap{ORD}

Abstract type for FEM-backed multilinear terms that expose element-level primitives.

Implementing the interface below enables the RHS batched accumulation path in MultilinearTerms.jl: the mesh is traversed exactly once per (monomial, term, split) rather than once per factorisation entry.

Required fields (same semantics as MultilinearMap):

  • multiindex, multiplicity_external, deg

  • fully_asymmetric::Union{Nothing, Bool} — nothing = not set (triggers @info at NthOrderModel construction if multiindex implies symmetry); false = acknowledged symmetric; true = override to FullyAsymmetric. FEM backends whose integrand is symmetric by construction should default to false.

Required methods (extend MORFE.*):

MultilinearMap{ORD, F}

Represents a single monomial term of order deg in the nonlinear function of an NthOrderModel.

A term is represented using a multiindex stored in the NTuple multiindex = (i0, ..., i{ORD-1}) where ik is the multiplicity of the derivative x^(k). So the ik specifies how many times the derivative x^(k) appears as an argument. In addition the function accepts multiplicity_external external variables r1, r2, ... which satisfy the first order dynamic system r' = dynamicsexternal(r), where dynamicsexternal is a DensePolynomial defined in NthOrderModel. The influence in f! is described by multiplicity_external

During evaluation the multilinear map is called as

f!(res,
   x^(0), ... repeated i_0 times,
   x^(1), ... repeated i_1 times,
   ...
   x^(ORD-1), ...repeated i_{ORD-1} times,
   r, ... repeated `multiplicity_external` times)

Important Notes

  • Each MultilinearMap must implement a multilinear map, i.e., it should be linear in each of its arguments independently.

  • The function f! accumulates (adds) into res and must be callable with the appropriate number of arguments.

  • If one i_k is larger than 1 we assume the input arguments are symmetric by permutation. For example:

multiindex = (0, 2,...)
f!(res, x^(1)_1, x^(1)_2, ...) = f!(res, x^(1)_2, x^(1)_1, ...)
  • Set fully_asymmetric = true to override the symmetry assumption: the term is treated as FullyAsymmetric regardless of multiindex, so every ordered argument permutation is evaluated independently with multiplier 1. Use this when f! is not symmetric in arguments that share a derivative order.

Default: `fully_asymmetric` not set (`nothing`)

When this keyword is omitted, the following assumptions hold (and an @info message is emitted when the term is added to an NthOrderModel). Explicitly passing fully_asymmetric = false applies the same behaviour silently, without triggering the message.

  1. f! is symmetric within each derivative-order group. For every k with multiindex[k] > 1, permuting any two of the multiindex[k] argument slots that belong to derivative order k leaves the result unchanged.

  2. Symmetry type is inferred automatically from multiindex:

    • All entries ≤ 1 → FullyAsymmetric: each factor slot uses a distinct derivative order; f! is called directly with multiplier 1.

    • Exactly one entry > 1 → FullySymmetric: all slots share one derivative order; each unique unordered selection of factor indices is evaluated once, scaled by the multinomial coefficient deg! / ∏ mᵢ!.

    • Multiple entries > 1 → GroupwiseSymmetric: slots span several derivative orders; each unique unordered selection is evaluated once, scaled by the product of per-group multinomial coefficients.

  3. Permutations are never evaluated separately. Only one representative per equivalence class of argument orderings is passed to f!; the combinatorial count is applied as a scalar multiplier on the output.

If f! does not satisfy assumption 1, pass fully_asymmetric = true.

Fields

  • f!::F — the in-place map itself, accumulating into its first argument. Stored as a type parameter so calls through it are statically dispatched.

  • multiindex::NTuple{ORD, Int} — multiindex[k] is how many times the derivative x^(k-1) appears as an argument.

  • multiplicity_external::Int — how many external variables r are passed, after the derivative arguments.

  • deg::Int — combined degree, sum(multiindex) + multiplicity_external. Cached because it is compared against the monomial degree on every factorisation.

  • fully_asymmetric::Union{Nothing, Bool} — overrides the symmetry inferred from multiindex. nothing means "not stated", which behaves as false but also emits the @info note described above; that three-valued form is what lets the model warn about an assumption the caller may not have realised it was making.

Construction

Four constructors are available. The keyword form is the recommended one — it names every argument and can infer multiindex from the system order, the total degree or a per-slot list of derivative orders:

MultilinearMap(f!; multiindex, derivatives, order, degree,
    multiplicity_external, fully_asymmetric)   # recommended
MultilinearMap(f!)                             # shape inferred from the arity of f!
MultilinearMap(f!, multiindex; fully_asymmetric)
MultilinearMap(f!, multiindex, multiplicity_external; fully_asymmetric)

In the keyword form an omitted order means ORD = 2, the second-order mechanical setting, and a one-factor f! is read as a pure external forcing term. Every assumed value is reported through an @info; the positional forms assume nothing and stay silent.

MultilinearMap(f!; multiindex = nothing, derivatives = nothing, order = nothing,
               degree = nothing, multiplicity_external = nothing,
               fully_asymmetric = nothing)

Create a multilinear term, naming every argument. This is the recommended constructor.

Keyword arguments

  • multiindex: tuple (or vector) of counts, multiindex[k] being how many argument slots use the derivative x^(k-1). Its length is the order ORD of the system.

  • derivatives: the alternative spelling — the 0-based derivative order of each argument slot, in call order. derivatives = (0, 0, 1) is the same term as multiindex = (2, 1), i.e. f!(res, x, x, ẋ). Must be non-decreasing, because it describes the order in which evaluate_term! passes the factors. Mutually exclusive with multiindex.

  • order: the system order ORD. Zero-pads a shorter multiindex up to it, so a quadratic term of a third-order model can be written multiindex = (2,), order = 3 instead of (2, 0, 0). It may pad but never truncate. Defaults to 2 — see below.

  • degree: the total degree, external factors included. Use it when the arity of f! cannot be introspected (a varargs closure), or as a cross-check against multiindex.

  • multiplicity_external: how many times the external state r is passed to f!, after the derivative arguments. Defaults to 0, or to 1 under the forcing rule below.

  • fully_asymmetric: overrides the symmetry inferred from multiindex; see the note in the MultilinearMap docstring. Defaults to nothing ("not stated").

Defaults for omitted arguments

ValueAssumed unless…Default
multiindexmultiindex or derivatives givenfrom degree, else from the arity of f!, with every non-external factor on x^(0)
orderorder given, or multiindex given2 — the second-order mechanical setting
multiplicity_externalgiven0, or 1 under the forcing rule

multiindex pins the order exactly, since its length is ORD; derivatives does not, because it lists argument slots rather than the system order.

Two rules apply only when neither multiindex nor derivatives was given:

  • Forcing rule. A total degree of 1 with multiplicity_external unstated is read as a pure external forcing term (multiplicity_external = 1, no derivative factors). Without it, MultilinearMap(f!) on f!(res, r) would resolve to a degree-1 term in the state, which is linear and cannot be represented here — linear contributions belong in the linear_terms matrices of NthOrderModel.

  • Mixed terms are never inferred. With multiplicity_external >= 1 and a non-zero internal degree, splitting the factors would mean guessing f!(res, x, r); that is an ArgumentError. Only the pure-forcing split is inferable. Stating multiindex lifts the restriction — mixed terms are perfectly legal, just not guessable.

Every assumed value is reported through an @info. A call that pins multiindex (or derivatives plus order) and multiplicity_external is silent, as are all the positional constructors. fully_asymmetric is not reported here: it has its own diagnostic at NthOrderModel construction, which fires exactly when the flag changes the result.

Examples

# f!(res, x, x, ẋ) in a second-order system
MultilinearMap(f!; multiindex = (2, 1))
MultilinearMap(f!; derivatives = (0, 0, 1))          # identical

# cubic term of a third-order system, without hand-written trailing zeros
MultilinearMap(f!; multiindex = (3,), order = 3)     # ⇒ (3, 0, 0)
MultilinearMap(f!; degree = 3, order = 3)            # ⇒ (3, 0, 0)

# pure external forcing, f!(res, r): shape, order and multiplicity all assumed
MultilinearMap(f!)                                   # ⇒ (0, 0), me 1, ORD 2

# the same term stated in full — silent
MultilinearMap(f!; multiindex = (0, 0), multiplicity_external = 1)

# a first-order term must say so, since the order defaults to 2
MultilinearMap(f!; order = 1)                        # ⇒ (2,), ORD 1

# f! is not symmetric in its two x^(0) slots
MultilinearMap(f!; multiindex = (2, 0), fully_asymmetric = true)

Errors

Throws ArgumentError if multiindex/derivatives entries are negative, if derivatives is not sorted, if order would truncate, if degree disagrees with multiindex, if a mixed internal/external split would have to be guessed, if the resulting degree is below 2 with no external factors, or if f! cannot be called with deg + 1 arguments.

MultilinearMap(f!, multiindex; fully_asymmetric = nothing)

Create a multilinear term for a system of order ORD without external dynamics.

Arguments

  • f!: in-place evaluation function, accumulating into its first argument

  • multiindex::NTuple{ORD, Int}: how many argument slots use each derivative; multiindex[k] counts the slots taking x^(k-1)

Keyword arguments

Equivalent to MultilinearMap(f!; multiindex = multiindex).

MultilinearMap(f!, multiindex, multiplicity_external; fully_asymmetric = nothing)

Create a multilinear term for a system of order ORD that also takes the external state.

f! is called with the derivative arguments selected by multiindex first, then the external state r repeated multiplicity_external times. A term that depends on the external state may have total degree 1 — a pure forcing term is written MultilinearMap(f!, (0, 0), 1), for which MultilinearMap(f!) is a shorthand.

The model this term goes into must have an external system; NthOrderModel rejects a multiplicity_external > 0 term otherwise.

Arguments

  • f!: in-place evaluation function, accumulating into its first argument

  • multiindex::NTuple{ORD, Int}: how many argument slots use each derivative

  • multiplicity_external::Int: how many times r is passed to f!

Keyword arguments

Equivalent to MultilinearMap(f!; multiindex = multiindex, multiplicity_external = multiplicity_external), and — like every positional form — it assumes nothing, so it never emits the @info the keyword constructor uses to report defaulted values.

A pure forcing term of a second-order system, MultilinearMap(f!, (0, 0), 1), is what the keyword constructor infers from a one-factor f! when nothing else is stated; see the keyword constructor's docstring for that shorthand and the assumptions it makes.

function _arity_description(fixed, va)
_arity_description(fixed, va) -> String

Human-readable summary of the argument counts a callable accepts, e.g. "3, ≥ 1".

function _build_multilinear_map(f!, multiindex::NTuple{ORD, Int64}, multiplicity_external::Int64, fully_asymmetric::Union{Nothing, Bool}) where ORD
_build_multilinear_map(f!, multiindex, multiplicity_external, fully_asymmetric)

Validate a fully resolved term and build it. The single place where the invariants of MultilinearMap are enforced; every public constructor ends up here.

Checks run in the order negatives → degree → arity so that sum(multiindex) is meaningful in every message.

function _call_signature(multiindex, multiplicity_external)
_call_signature(multiindex, multiplicity_external) -> String

Render the call f! receives, e.g. "f!(res, x^(0), x^(0), x^(1), r)" for multiindex = (2, 1) and multiplicity_external = 1.

function _check_arity(f!, deg, multiindex, multiplicity_external)
_check_arity(f!, deg, multiindex, multiplicity_external)

Throw an ArgumentError unless f! can be called with deg + 1 arguments.

hasmethod is the fast accept path, but it returns false for methods with concrete argument annotations, so a scan of the method table decides rejection. A varargs method that can absorb the arguments is accepted — that is what admits closures built by MORFESymbolicsExt and callable structs. A callable exposing no methods at all is trusted rather than rejected.

function _counts_from_derivatives(derivatives::NTuple{N, Int64}) where N
_counts_from_derivatives(derivatives) -> NTuple

Convert a per-slot list of 0-based derivative orders into a multiindex of counts: (0, 0, 1) → (2, 1). The list must be non-decreasing, since it is the argument order f! will be called with.

function _definition_site(f!)
_definition_site(f!) -> String

Return " @ file.jl:12" for the first method of f!, or "" when f! exposes no methods. Used by show and by FullOrderModel._term_label to point a diagnostic at the definition of a term. Deliberately uses first rather than only: f! is allowed to carry several methods, and a display helper must never throw.

function _infer_degree(f!)
_infer_degree(f!) -> Int

Number of factors f! takes, i.e. its argument count less res. Requires a single fixed arity; varargs or conflicting arities are ambiguous and raise an ArgumentError telling the caller to state the degree explicitly.

function _info_assumed(f!, multiindex, multiplicity_external, mi_source, assumed_order, assumed_me, forcing)
_info_assumed(f!, multiindex, multiplicity_external, mi_source, assumed_order,
              assumed_me, forcing)

Emit an @info naming every value the constructor had to default, or nothing at all when the caller pinned them. mi_source is :stated, :arity or :degree, and names where an assumed multiindex came from.

fully_asymmetric is deliberately not reported here: it already has a dedicated diagnostic, FullOrderModel._info_implicit_symmetry, which fires at NthOrderModel construction exactly when the flag changes the result.

function _method_arities(f!)
_method_arities(f!) -> (fixed, va)

Split the methods of f! by argument count, including res but excluding the callable itself. fixed lists the exact counts of the fixed-arity methods; va lists, for each varargs method, the minimum number of arguments it accepts.

function _msg_assumed_compact(f!, multiindex, multiplicity_external, assumed)
_msg_assumed_compact(f!, multiindex, multiplicity_external, assumed) -> String

One-line report of the values the constructor had to default. assumed is the list of field names that were not stated; only those are named, so the closing sentence ("state them explicitly to silence this message") is always true.

function _msg_assumed_forcing(f!, multiindex, multiplicity_external)
_msg_assumed_forcing(f!, multiindex, multiplicity_external) -> String

Full report for the one inference that can silently reinterpret a linear term as a forcing term. Every call it presents as silent really is silent under the resolution rules — the testset "suggested silent forms are silent" pins that.

function _pad_to_order(base::NTuple{N, Int64}, order::Int64) where N
_pad_to_order(base, order) -> NTuple

Zero-pad base to length order. Padding is symmetry-neutral: trailing zeros change neither all(<=(1), mi) nor count(>(0), mi), so symmetry_type classifies (2,) and (2, 0, 0) identically. Truncation is refused — it would silently drop a slot.

function _symmetry_label(multiindex, fully_asymmetric)
_symmetry_label(multiindex, fully_asymmetric) -> String

Name the symmetry class a term will be given by the parametrisation solver.

Mirrors symmetry_type in src/ParametrisationMethod/RightHandSide/MultilinearTerms/Symmetry.jl, which cannot be called from here: MultilinearMaps is loaded twelve includes earlier. The testset "show agrees with symmetry_type" holds the two in step.

function accumulate_qp!(Fe, ∇W_args, mult, element, q, dΩ, t::FEMMultilinearMap)
accumulate_qp!(Fe, ∇W_args, mult, element, q, dΩ, t::FEMMultilinearMap) -> nothing

Accumulate the integrand contribution at quadrature point q (weight dΩ) into the element residual vector Fe. ∇W_args is an NTuple of pre-scattered W columns; mult is the combinatorial multiplicity of the term.

function assemble_element!(accum, Fe, element, t::FEMMultilinearMap)
assemble_element!(accum, Fe, element, t::FEMMultilinearMap) -> nothing

Scatter the element residual Fe into the global accumulator accum using the DOF map of element.

function evaluate_term!(res, term::MultilinearMap{ORD}, xs, r) where ORD evaluate_term!(res, t::FEMMultilinearMap{ORD}, xs, r) where ORD
evaluate_term!(res, term, xs, r)

Evaluate a single MultilinearMap and accumulate (adds) the result into res.

Arguments

  • res: output vector (modified in-place)

  • term: multilinear term

  • xs: tuple (x, x^(1), …, x^(ORD-1)) of state derivatives

  • r: external state vector (or nothing if not used). If r is nothing but the term expects external arguments, an error is thrown.

`r` is the *physical* external state

See evaluate_nonlinear_terms!: when the external system was re-based, the caller must convert the reduced coordinates r′ with ExternalSystems.to_physical_external first. During the cohomological solve this argument is a basis direction rather than a state — a unit vector eⱼ, or the column Q[:, j] after a re-basing — supplied by ExternalSystems.external_argument_vectors, so f! may receive a complex-valued external argument and must not assume an integer one.

evaluate_term!(res, t::FEMMultilinearMap{ORD}, xs, r)

Direct (uncached) evaluation of a FEM-backed multilinear term at state xs and external state r, accumulating the result into res. To be used in InvarianceError.jl.

Internal argument slots (determined by t.multiindex) are scattered to quadrature-point field quantities via scatter_qp! and assembled element-wise.

For me = 0: ∇W_args is a homogeneous NTuple{N_INT, QP_TYPE} — type-stable.

For me > 0: the external arg slots in ∇W_args receive r directly (not scattered). r is a small NEXT-dimensional external-state vector, not a FOM displacement field. The concrete `accumulateqp!evaluates the full multilinear mapF(∇u₁,…,∇uₙ, r₁,…,rₘₑ)` at the actual inputs.

function fem_elements(t::FEMMultilinearMap)
fem_elements(t::FEMMultilinearMap) -> iterator

Return an iterator over the mesh elements (cells) for the FEM term t. Implement this for every concrete FEMMultilinearMap subtype.

function fem_getdetJdV(element, q, t::FEMMultilinearMap)
fem_getdetJdV(element, q, t::FEMMultilinearMap) -> Real

Return the integration weight det(J) · w_q at quadrature point index q of element.

function fem_n_qp(t::FEMMultilinearMap)
fem_n_qp(t::FEMMultilinearMap) -> Int

Return the number of quadrature points per element for the FEM term t.

function fem_ndofs_per_cell(t::FEMMultilinearMap)
fem_ndofs_per_cell(t::FEMMultilinearMap) -> Int

Return the number of degrees of freedom per element (cell) for the FEM term t.

function fem_qp_buffer(t::FEMMultilinearMap)
fem_qp_buffer(t::FEMMultilinearMap)

Return a pre-allocated scratch buffer sized for one quadrature point of the FEM term t. Reused across calls to avoid allocation in the inner element loop.

function fem_reinit!(element, t::FEMMultilinearMap)
fem_reinit!(element, t::FEMMultilinearMap) -> nothing

Reinitialise the FEM quadrature cache (e.g. CellValues) for element. Called once per element before any scatter_qp! or accumulate_qp! calls.

function scatter_qp!(∇W_col, W_global, element, t::FEMMultilinearMap)
scatter_qp!(∇W_col, W_global, element, t::FEMMultilinearMap) -> nothing

Fill ∇W_col in-place with the field values (e.g. gradients) at all quadrature points of element for one column of the parametrisation W_global.

Module ExternalSystems — representation of autonomous external dynamical systems that drive a full-order model.

An ExternalSystem encodes a finite-dimensional autonomous ODE

ṙ = f(r) = A r + higher-order terms

whose state r ∈ ℂᴺᴱˣᵀ appears as a forcing argument in the nonlinear terms of an NthOrderModel. The module stores the polynomial dynamics together with the linear matrix A and its eigenvalues, which enter the cohomological equations as external superharmonic frequencies.

Upper-triangularity, and how it is obtained

A must be upper triangular. The cohomological equations are solved monomial by monomial in GrLex order, which makes the solve causal: every coefficient a monomial needs is already available when it is reached. The |β| = 1 branch of the lower-order coupling needs W[α − eⱼ + eᵢ], a coefficient of the same total degree as α, and that coefficient precedes α in GrLex only when i < j. Accordingly LowerOrderCouplings._sum_degree_one_terms! reads only the strictly upper triangle of the reduced linear dynamics Λ.

Λ is block structured: its master block is diagonal, its lower-left block vanishes because the external system is autonomous, and its lower-right block is A. So Λ is upper triangular exactly when A is, and a strictly-lower entry of A would be discarded without trace.

A non-triangular A is therefore not rejected but re-based: the constructor finds a basis Q in which the linear part is upper triangular, re-expresses the whole polynomial in the new coordinates r′ where r = Q r′,

ṙ′ = U r′ + Q⁻¹ g(Q r′),        U = Q⁻¹ A Q  upper triangular

and stores Q in the basis field. Everything downstream — W's external columns, the reduced external coordinates, R's external rows — is then expressed in r′; external_basis recovers Q so results can be mapped back to the physical r. The solver feeds nonlinear terms the physical external argument automatically (see external_argument_vectors), so term definitions never need to change.

See _triangularising_basis for how Q is chosen, and why a real system's conjugate structure is preserved exactly.

ExternalSystem{N_EXT, T, EigenvalueType}

Represents a dynamical system of the form:

dr/dt = f(r) = A r + higher-order terms

where r ∈ ℝ^{N_EXT} (or ℂ^{N_EXT}), A is a constant upper-triangular matrix, and the dynamics are given by a polynomial expansion. The structure stores the full polynomial, the linear matrix, and its eigenvalues.

A must be upper triangular — see the module docstring for the GrLex-causality reason. A caller-supplied polynomial whose linear part is not triangular is re-based rather than rejected, and basis then records the change of coordinates.

Because it is triangular, A's eigenvalues are its diagonal entries, and they are stored by variable index: eigenvalues[e] == linear_matrix[e, e] is the eigenvalue that belongs to external variable e. This is not a sorted spectrum; the ordering carries meaning, since resonance detection contracts the eigenvalues against multiindex components position by position.

Fields

  • first_order_dynamics::DensePolynomial{T, N_EXT, 2} — the full polynomial dynamics, vector-valued: maps ℂ^{NEXT} → ℂ^{NEXT}. In the re-based coordinates r′ when basis !== nothing.

  • linear_matrix::SMatrix{N_EXT, N_EXT, T} — the linear part, i.e. the Jacobian at the origin. Static, since N_EXT is small and known at compile time. Always exactly upper triangular, and always re-derived from first_order_dynamics, so the two can never disagree.

  • eigenvalues::SVector{N_EXT, EigenvalueType} — eigenvalues of linear_matrix, i.e. its diagonal in variable order, cached because they set the external part of the superharmonic s. When T <: Real these are still complex, so EigenvalueType = Complex{T}; otherwise EigenvalueType = T.

  • basis::Union{Nothing, SMatrix{N_EXT, N_EXT, T}} — the change of external coordinates Q with r = Q r′, or nothing when the supplied dynamics were already triangular and nothing was touched.

Constructors

  1. ExternalSystem(first_order_dynamics) Build from a polynomial. Computes the linear matrix and associated eigenvalues automatically, re-basing first if the linear part is not upper triangular.

  2. ExternalSystem(eigenvalues) Construct a purely linear system dx/dt = diag(eigenvalues) * x, i.e., decoupled linear dynamics. Diagonal by construction, so it always satisfies the triangularity requirement.

function _evtype(::Type{T}) where T<:Real _evtype(::Type{T}) where T<:Complex
_evtype(::Type{T}) -> Type

Return the eigenvalue storage type for scalar type T: Complex{T} when T <: Real, or T itself when T <: Complex.

function _rebase(poly::DensePolynomial{T0, N_EXT, 2, A} where A<:AbstractMatrix{T0}, Q, Qinv) where {T0, N_EXT}
_rebase(poly, Q, Qinv) -> DensePolynomial

Re-express ṙ = f(r) in the coordinates r′ with r = Q r′, giving ṙ′ = Q⁻¹ f(Q r′).

The substitution itself is compose_linear (in Polynomials); the left multiplication by Q⁻¹ is a single matrix product on the coefficient array, since the polynomial is vector-valued with one row per component.

The promotion on the first line is load-bearing: compose_linear types its accumulators from the input polynomial, so a real polynomial composed with a complex Q cannot store the products back. promote_type rather than a blanket complex keeps a real A with an all-real spectrum in real arithmetic.

function _subdiagonal_offenders(linear_matrix::AbstractMatrix)
_subdiagonal_offenders(linear_matrix) -> Vector{Tuple{Int, Int}}

The (i, j) positions of the non-zero entries strictly below the diagonal.

Monomials are solved in GrLex order, so the |β| = 1 lower-order coupling can only read Λ[i, j] for i < j (LowerOrderCouplings._sum_degree_one_terms!); a strictly-lower entry of the external linear matrix would be silently discarded by the solver rather than producing a wrong-but-visible answer. Such entries are what triggers a re-basing, and this list is what the diagnostic reports. See the ExternalSystems module docstring.

function _triangularising_basis(A::StaticArraysCore.SMatrix{N, N, T}) where {N, T}
_triangularising_basis(A) -> Union{Nothing, NamedTuple}

Choose a basis Q in which Q⁻¹ A Q is upper triangular, or nothing when A already is.

Returns (; Q, U, Qinv, route) with route ∈ (:eigen, :schur).

Two requirements, and why the route matters

Triangularity is the GrLex-causality constraint (module docstring); both routes give it.

Conjugate structure is the second, and only one route gives it. Realification.realify applies a single conj_map across all NVAR variables, external ones included, and fill_conjugate_monomial! implements W_{P·γ} = conj(W_γ) for a real FOM. Preserving that needs an involution σ with Q[:, σ(k)] = conj(Q[:, k]) and λ_{σ(k)} = conj(λ_k); a basis that breaks it silently invalidates any conjugate_permutation touching external indices — a wrong answer, not an error.

A Schur basis breaks it in general: in A = Q U Qᴴ the first Schur vector Q[:, 1] is an eigenvector for λ₁, but Q[:, 2] is a Schur vector, not an eigenvector for λ₂, so Q[:, 2] ≠ conj(Q[:, 1]) unless A is normal.

The eigenvector basis of a real A gives both. LAPACK's dgeev returns complex eigenvalues in adjacent conjugate pairs, each pair's eigenvectors stored as the real and imaginary parts of one column pair, so V[:, k+1] == conj(V[:, k]) holds bit-exactly; real eigenvalues get real, self-conjugate eigenvectors. And U = Diagonal(λ) is diagonal, which is strictly better than triangular: it lets the solver take the uncoupled fast path (coupled_external = !isdiag(...) in CohomologicalDriver).

So: real and diagonalisable → eigen; anything else (complex A, or real but near-defective, where the eigenvector basis would be catastrophically ill-conditioned) → complex schur. Note schur on a real matrix returns the real Schur form, which is only quasi-triangular — it must be given a complex matrix.

function _unit(::Val{N}, j::Int64) where N
_unit(::Val{N}, j) -> SVector{N, Int}

The j-th unit multiindex / basis vector in N variables.

function _zero_subdiagonal_linear_block!(poly::DensePolynomial{T, N_EXT, 2, A} where A<:AbstractMatrix{T}; tol) where {T, N_EXT}
_zero_subdiagonal_linear_block!(poly; tol)

Zero the strictly-lower triangle of poly's linear block, after checking it is only round-off.

compose_linear produces sub-diagonal entries of size ~1e-17·‖A‖ where exact arithmetic would give zero, and istriu tests exact zeros — so without this the "triangularised" system is still not triangular by the package's own predicate, and linear_matrix would disagree with the polynomial it is supposed to be the Jacobian of.

linear_matrix_of_polynomial sets A[:, j] = coefficients[:, i_j] where i_j is the mset position of the unit multiindex eⱼ, so A[k, j] is coefficients[k, i_j] and the strict lower triangle is k > j within each unit-multiindex column.

A residual above tol means the basis is wrong; that must error rather than be truncated away, because truncating it would discard genuine coupling.

function external_argument_vectors(sys::Union{Nothing, ExternalSystem}, N_EXT::Int64)
external_argument_vectors(sys, N_EXT) -> Vector{<:SVector{N_EXT}}

The external argument the solver passes for each external variable j.

During the cohomological solve the external factor of a monomial is a basis vector, not a state: the multilinear term is evaluated once per external direction and the coefficient is read off. In the model's own coordinates that vector is the unit vector eⱼ; after a re-basing it is the physical direction Q eⱼ = Q[:, j]. Multilinearity makes the substitution exact — f(…, Qeⱼ, Qe_k) = Σ_{a,b} Q[a,j] Q[b,k] f(…, e_a, e_b).

With basis === nothing this returns the integer unit vectors, i.e. exactly what the solver used before re-basing existed.

function external_basis(::Nothing) external_basis(sys::ExternalSystem)
external_basis(sys) -> Union{Nothing, SMatrix}

The change of external coordinates Q with r = Q r′, or nothing when the system was left in the coordinates it was given in. nothing is the common case and is what every consumer branches on; it means "reduced external coordinates are the physical ones".

function to_physical_external(::Union{Nothing, ExternalSystem}, ::Nothing) to_physical_external(sys::Union{Nothing, ExternalSystem}, r)
to_physical_external(sys, r) -> r_physical

Map a reduced external coordinate vector r′ to the physical r = Q r′.

The state-valued counterpart of external_argument_vectors, used where an external argument is a genuine state rather than a basis direction — notably the invariance-error residual, which samples reduced coordinates and must feed the model physical ones. Identity when the system was never re-based.

Module FullOrderModel — representation of high-dimensional nonlinear ODEs.

The central type is NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T, MT}, which encodes an ORD-th order system

B_ORD ẋ^(ORD) + … + B₁ ẋ + B₀ x = F(x, ẋ, …, r)

where r satisfies an autonomous external system. Linear terms are stored as an NTuple{ORDP1, MT} of matrices; nonlinear terms as an NTuple{N_NL, AbstractMultilinearMap}.

Key functions: linear_first_order_matrices (produces the companion-form (A, B) pair used by eigensolvers), evaluate_nonlinear_terms!.

NthOrderModel{ORD, ORDP1, N_NL, T, MT} <: AbstractFullOrderModel

Representation of an ORD-th (ORDP1=ORD+1) order dynamical system of the form

B_ORD x^(ORD) + ... + B_1 x^(1) + B_0 x = F(x^(ORD-1), …, x^(1), x, r, …, r)

where:

  • x^(n) is the n-th derivative of x (x^(n) = d_t^n x)

  • x^(0) = x is the state vector

  • B_i are the coefficient matrices

  • F is a multilinear polynomial function of the derivatives and the external state vector r

  • The external state r satisfies its own first‑order dynamics r' = g(r)

Generic type parameters

  • ORD defines the order of the ODE.

  • ORDP1 is the number of linear terms (from 0 through ORD). It must satisfy ORDP1 == ORD+1.

  • N_NL is the number of nonlinear terms in the tuple nonlinear_terms.

  • N_EXT is the size of the external system.

  • T is the numeric type.

  • MT is the matrix type that forms the ORDP1-tuple of linear_terms.

Fields

  • n_fom::Int — dimension of the full‑order state vector x.

  • linear_terms::NTuple{ORDP1, MT} — the linear coefficient matrices (B_0, …, B_ORD), all of identical size.

  • nonlinear_terms::NTuple{N_NL, AbstractMultilinearMap{ORD}} — the nonlinear contributions, each a MultilinearMap or FEMMultilinearMap.

  • external_system::Union{Nothing, ExternalSystem{N_EXT}} — the external dynamics, or nothing for an unforced model.

  • max_nl_degree::Int — the largest combined degree over nonlinear_terms, cached at construction. It bounds the polynomial order at which anything nonlinear can still contribute, and drives the progress indicator's work estimate.

Representation

  • Linear terms are stored as a tuple (B_0, …, B_ORD)

  • Nonlinear terms are represented as a collection of MultilinearMaps

Each MultilinearMap defines:

  • which derivatives are involved (via multiindex)

  • how many times the external state appears as an argument (via multiplicity_external)

  • the combined degree deg = sum(multiindex) + multiplicity_external

Notes

  • All matrices must have identical size.

  • The nonlinear structure is stored in sparse form (only active terms).

function _check_external_terms(nonlinear_terms)
_check_external_terms(nonlinear_terms)

Reject terms that read the external state in a model built without one.

Without this the mismatch survives construction and only surfaces mid-solve, as "Term expects external arguments but no external state provided" from evaluate_term!.

function evaluate_nonlinear_terms!(res, model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T} where {N_EXT, T}, order, state_vectors) where {ORD, ORDP1, N_NL} evaluate_nonlinear_terms!(res, model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T} where {N_EXT, T}, order, state_vectors, r) where {ORD, ORDP1, N_NL}
evaluate_nonlinear_terms!(res, model, order, state_vectors, r = nothing)

Evaluate all nonlinear terms of a given polynomial degree for an NthOrderModel.

Arguments

  • res: output vector (modified in-place)

  • model: the NthOrderModel

  • order: degree of the nonlinear terms to evaluate

  • state_vectors: tuple (x, x^(1), …, x^(ORD-1)) of state derivatives

  • r: external state vector (default nothing). Must be provided if any term uses external variables.

`r` is the *physical* external state

Terms are defined in the external coordinates the model was written in. When the external system was re-based (its linear matrix was not upper triangular, so ExternalSystem chose a new basis Q), the solver's reduced external coordinates r′ are related to these by r = Q r′, and it is the caller's job to convert: ExternalSystems.to_physical_external(model.external_system, r′) does it, and is the identity for every system that was not re-based. Passing r′ where r is expected silently evaluates the terms at the wrong point.

function linear_first_order_matrices(model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T, MT}) where {ORD, ORDP1, N_NL, N_EXT, T, MT<:(SparseMatrixCSC{T})} linear_first_order_matrices(model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T, MT}) where {ORD, ORDP1, N_NL, N_EXT, T, MT<:AbstractMatrix{T}}
linear_first_order_matrices(model::NthOrderModel)

Construct the matrices A and B of the equivalent linear first-order system:

B Ẋ = A X

obtained from the ORD-th order model

B_ORD x^(ORD) + ... + B_1 x^(1) + B_0 x = F(...)

by introducing the augmented state vector

X = [x, x^(1), ..., x^(ORD-1)].

and the (ORDn_fom x ORDn_fom)-block matrices

B = [ I   0   0   ⋯   0
	  0   I   0   ⋯   0
	  ⋮       ⋱
	  0   0   0   ⋯  B_ORD ]

and

A = [ 0   I   0   ⋯   0
	  0   0   I   ⋯   0
	  ⋮       ⋱
	 -B₀ -B₁ -B₂ ⋯ -B_{ORD-1} ]

where I is the n_fom × n_fom identity matrix.

function show(io::IO, ::MIME{Symbol("text/plain")}, m::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T, MT}) where {ORD, ORDP1, N_NL, N_EXT, T, MT} show(io::IO, m::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T, MT}) where {ORD, ORDP1, N_NL, N_EXT, T, MT}
Base.show(io::IO, ::MIME"text/plain", m::NthOrderModel)

Print a summary of the model.

Without this, showing a model dumps every field — which for a FEM backend means the entire DofHandler behind each nonlinear term. Everything printed here is already on the type; nothing is computed.

The one-line method exists for the same reason: a model nested inside another container would otherwise expand its whole field tree there too.

Module SpectralDecomposition — solve the generalised eigenproblem for an NthOrderModel and package the result for a parametrisation.

The whole spectral layer, in one module. It replaces the former split between Eigensolvers (a stub that held no solvers) and Eigenproblems (which held them) — a division that named neither half for what it contained.

Files

FileContents
Eigensolvers.jlAbstractEigensolver and its subtypes, the eigensolve / eigensolve_left interface, generalised_eigenpairs, the Spectrum container, sorting/normalisation, and left-block reconstruction
SpectralData.jlModeBundle and SpectralData — the selected, model-reconciled bundle a parametrisation consumes
ConjugatePermutation.jlthe conjugate involution derived from a spectrum and an external system

Two layers, deliberately

Spectrum is raw solver output: every mode computed, no selection state. SpectralData is what a parametrisation actually consumes: the selected master modes, reconciled against a specific model's order, plus the outer eigenvalues resonance detection reads. Keeping them apart means a spectrum can be solved once and several reductions taken from it, and that selection is a pure operation rather than a mutation.

Interface contract — full order-blocks

eigensolve and eigensolve_left must return eigenvectors as FOM × ORD × n arrays containing ALL companion order-blocks, not just the physical slice:

  • right: (λB − A) ψ = 0, blocks ψ = [ψ_1; …; ψ_ORD] with ψ_{k+1} = λ ψ_k;

  • left (sesquilinear): φᴴ (λB − A) = 0, reported eigenvalue λ (the pencil eigenvalue of the adjoint problem is conj(λ)).

The eigensolver is the single owner of eigenvalue knowledge: it uses λ to define the eigenvector blocks, and downstream code reads the blocks without ever folding eigenvalues. Solvers producing only the physical left slice can reconstruct the rest with left_eigenmode_orders_from_slice.

Naming: spectral data, not eigen-data

SpectralData and ModeBundle store right_blocks / left_blocks, not "eigenvectors", and the names are chosen to keep room. A master set need not consist of eigenpairs: for a defective eigenvalue the invariant subspace is spanned by a Jordan chain of generalised eigenvectors, and such a chain would sit in exactly these fields alongside its eigenvalue. Nothing here implements Jordan chains today, but nothing here assumes their absence either — the block layout and the accessors carry over unchanged.

AbstractEigensolver

Abstract supertype for all eigensolvers accepted by eigensolve.

Concrete subtypes must implement eigensolve and eigensolve_left for NthOrderModel. Two built-in subtypes are provided: DefaultEigensolver and ArpackEigensolver.

Interface contract — full order-blocks

Both eigensolve and eigensolve_left must return eigenvectors as FOM × ORD × n arrays containing ALL companion order-blocks, not just the physical slice:

  • right: (λB − A) ψ = 0, blocks ψ = [ψ_1; …; ψ_ORD] with ψ_{k+1} = λ ψ_k;

  • left (sesquilinear): φᴴ (λB − A) = 0, reported eigenvalue λ (the pencil eigenvalue of the adjoint problem is conj(λ)).

The eigensolver is the single owner of eigenvalue knowledge: it uses λ to define the eigenvector blocks, and downstream code (orthogonality and invariance operators) reads the blocks without ever folding eigenvalues. Solvers that naturally produce only the physical left slice can reconstruct the blocks with left_eigenmode_orders_from_slice.

ArpackEigensolver <: AbstractEigensolver

Sparse eigensolver backed by Arpack.eigs. Computes nev smallest-magnitude eigenpairs. If nev is not specified, Arpack's default count is used.

Fields

  • nev::Union{Nothing, Int64} — number of eigenpairs to compute. nothing means "not chosen yet"; the zero-argument constructor warns, because leaving the count to Arpack on a large model is rarely what the caller wants.

  • eigenvalues::Union{Nothing, Vector{ComplexF64}} — the eigenvalues from the most recent eigensolve, cached so the left problem can reuse them. Left undefined by the constructors, not set to nothing: guard with isdefined before reading it.

DefaultEigensolver <: AbstractEigensolver

Dense eigensolver backed by LinearAlgebra.eigen. Computes all eigenpairs. Suitable for small to medium full-order models (dense matrices).

type ModeBundle
ModeBundle{ORD, EV}

One family of modes — eigenvalues plus, optionally, their right and left eigenvector order-blocks.

EV is the eigenvalue container, which is what lets a single type serve both families: the master bundle uses SVector{ROM, ComplexF64} because the solve requires a statically sized vector, while the outer bundle uses Vector{ComplexF64} because its length varies run to run and must not drive recompilation.

Fields

  • eigenvalues::EV

  • right_blocks, left_blocks — FOM × ORD × n, or nothing when modes were not kept (outer bundles default to eigenvalues only).

  • right_physical, left_physical — FOM × n materialised copies of the physical slices.

Why the physical slices are cached copies, not views

Three reasons, all load-bearing:

  1. A Bool-mask selection is not strided, so a view of it would drop downstream BLAS/sparse products onto the slow generic path — the reason the previous code took copies explicitly.

  2. solve_parametrisation types its right modes as a concrete Matrix{ComplexF64}.

  3. An accessor that sliced would allocate on every call rather than once.

The cost is 2·FOM·n complex numbers, negligible beside the FOM × ORD × L parametrisation.

MorfeEigensolver <: AbstractEigensolver

Sparse eigensolver for the shifted eigenproblem, backed by Arpack.eigs through generalised_eigenpairs. Shifting targets the eigenvalues nearest a chosen point in the complex plane, which is how master modes around a given frequency are picked out of a large spectrum.

Fields

  • nev::Union{Nothing, Int64} — number of eigenpairs to compute. nothing is resolved to the full problem size on the first eigensolve.

  • shift::Union{Nothing, ComplexF64} — the shift point. nothing falls back to the unshifted eigs.

  • eigenvalues::Union{Nothing, Vector{ComplexF64}} — eigenvalues from the most recent eigensolve, cached so the left problem can reuse them. Left undefined by the constructors, not set to nothing: guard with isdefined before reading it.

SpectralData{ORD, ROM}

The complete spectral input to a parametrisation: a master ModeBundle (always carrying modes), an outer bundle (eigenvalues always, modes optional), and the conjugate structure of the spectrum they were selected from.

Element type is pinned to ComplexF64 rather than left as a parameter, because the solve is ComplexF64 throughout; a generic element type would only create a silent conversion boundary.

Fields

  • master::ModeBundle{ORD, SVector{ROM, ComplexF64}}

  • outer::ModeBundle{ORD, Vector{ComplexF64}} — the eigenvalues left off the manifold, which outer resonance detection reads.

  • conjugate_permutation::Union{Nothing, Vector{Int}} — the involution σ over the whole spectrum, 1:n_eigs, with λ[σ(i)] = conj(λ[i]); nothing when the spectrum has no conjugate structure or none was requested.

  • master_permutation, outer_permutation — σ restricted to each bundle and re-indexed, computed once in the constructor. Read them through master_conjugate_permutation / outer_conjugate_permutation.

  • mode_numbers::Vector{Int} — physical mode number of each spectrum entry, derived from σ's orbits (see below).

One involution, detected once — and each restriction computed once

Conjugacy is a property of the spectrum, not of a bundle: master and outer modes are subsets of one index set. Detecting per bundle would establish the same fact twice, so σ is detected once over all eigenvalues.

The restrictions are then computed once, in the constructor, and stored — not derived per call. The master restriction is what every solve reads, so recomputing it would put an avoidable allocation on that path. Whether σ was detected or the master pairing was supplied explicitly, the result is settled at construction and thereafter only read.

A restriction is well defined only if the index set is closed under σ — both members of a pair selected, or neither. Splitting a pair across the master/outer boundary is rejected at construction, where the offending entry can be named, rather than surfacing later inside the solve.

Mode rescaling for conditioning (e.g. multiplying both sides by 1e-2) is a caller-side operation on the raw arrays before construction; there is deliberately no scale field.

SpectralData(; eigenvalues, right_modes, left_modes,
			 right_derivatives = nothing, left_blocks = nothing,
			 outer_eigenvalues = ComplexF64[], conjugate_permutation = nothing)

Build SpectralData directly from raw arrays — for eigensolvers that never construct a Spectrum (a shift-invert Hopf solve, say).

Two shapes are accepted:

  • Whole blocks. right_modes and left_modes are FOM × ORD × ROM arrays (or FOM × ROM matrices when ORD == 1), with right_derivatives and left_blocks left nothing. Remember the mirrored convention: the left array's last slice is the physical one.

  • Physical slices plus their companions. right_modes and left_modes are the FOM × ROM physical slices, right_derivatives is FOM × (ORD-1) × ROM holding W^(k)[eᵣ], and left_blocks is FOM × (ORD-1) × ROM holding the lower-order left blocks φ_{r,j}. This is the shape callers of the old positional solve already had, and taking it here means the mirrored convention is applied in one place instead of at every call site — where a swap is type-correct and compiles silently.

conjugate_permutation is taken as given here (no :detect): a caller assembling raw arrays is in the best position to know the pairing, and eigenvalue-based detection is not sufficient on its own.

It is accepted at either of two lengths:

  • ROM — the master block. Outer entries are left self-paired, the honest statement that raw arrays say nothing about their conjugate structure.

  • ROM + length(outer_eigenvalues) — the involution over the whole synthetic spectrum, used verbatim. Pass this when the outer modes' pairing IS known, so that physical_mode numbers physical modes rather than eigenvalues and a conjugate pair among the outer targets reports once rather than twice. A real (non-oscillatory) outer mode is its own conjugate and maps to itself.

SpectralData(model, eigenproblem; master,
			 conjugate_permutation = nothing, keep_outer_modes = false)

Select the master modes out of a solved eigenproblem and reconcile their blocks against model's order.

master is either a Vector{Int} of indices or a Vector{Bool} mask.

Three rules that carry correctness weight

Order is never changed. master's index order is the reduced-coordinate order — it determines the conjugate permutation, the monomial-set variable roles and the resonance target numbering. Nothing here sorts; sorting is a property of the eigenproblem, applied before selection.

Blocks are sliced when the orders match, extended only when they don't. If the eigenproblem was solved on an operator of the same order as model, the stored blocks are used as-is — not recomputed, because they share whatever scaling the biorthogonal normalisation applied and a recomputation can drift from that. When model has the higher order (an augmented (K, C, M, 0) fed by a second-order structural eigenproblem), the missing right blocks are generated by multiplying the last available block by λ — not by forming a fresh λ^{k-1} ψ — and the left blocks are rebuilt from the physical slice against model.linear_terms.

conjugate_permutation defaults to nothing. Pass :detect to derive it from the master eigenvalues; unlike bare detect_conjugate_permutation, that path also verifies the eigenvectors (Ψ[:, σ(r)] ≈ conj(Ψ[:, r]) on every order-block, both sides) and returns nothing with an @info if they disagree — eigenvalue pairing alone is necessary but not sufficient, and a wrong permutation silently corrupts W and R. The default is nothing so that enabling conjugate symmetry is always a deliberate act.

An explicit vector is accepted at either of two lengths:

  • n_eigs — the involution over the whole spectrum, stored verbatim. Prefer this whenever the solver's pairing is known for every entry. A structural solver returning adjacent conjugate pairs, for instance, has σ = reduce(vcat, [[2p, 2p-1] for p in 1:n_pairs]) exactly. Outer entries then carry their true partner, so physical_mode numbers physical modes rather than individual eigenvalues, and per-mode diagnostics (the outer-resonance warning) name the pair instead of warning once per conjugate.

  • ROM — the master block only. Outer entries are left self-paired, the honest reading of "the caller stated the master pairing, not the spectrum's"; an outer conjugate pair then reports as two separate modes.

Both derive the same master restriction, so moving a call site from the second form to the first leaves the solve bit-identical.

type Spectrum
Spectrum{T}

Stores sorted and biorthogonally normalised left/right eigenpairs of the generalised eigenproblem A x = λ B x, together with a master-mode selector.

Left eigenmodes are kept in two forms: the full order-block array and the physical-space (highest-order) slice of it. The full blocks feed the orthogonality row operators directly, so no eigenvalue folding is needed to reconstruct them; the slice is what physical-space post-processing and export want.

Immutable, and it holds no master selection. A spectrum is what the eigensolver computed; which of its modes span the manifold is a separate decision, and it belongs to the SpectralData built from it — SpectralData(model, spectrum; master = …). The type used to be mutable with a master_modes field only so the old select_master_modes_* mutators had somewhere to write, which meant one object could mean different things at different points in a script.

Fields

  • solver::AbstractEigensolver — the solver that computed the eigenpairs, retained so downstream code can query how they were obtained.

  • eigenvalues::Array{Complex{T}} — sorted eigenvalues λ.

  • eigenmodes::Array{Complex{T}} — right eigenvectors as FOM × ORD × n_eigs, sorted to match eigenvalues.

  • left_eigenmodes::Union{Nothing, Matrix{Complex{T}}} — physical-space left eigenvectors, FOM × n_eigs; nothing until set.

  • left_eigenmodes_orders::Union{Nothing, Array{Complex{T}, 3}} — full left eigenvector order-blocks, FOM × ORD × n_eigs; nothing when the solver supplied only the physical slice.

StructureModalDampingEigensolver <: AbstractEigensolver

solves eigenproblem of the mechanical 2nd order problem:

$M \ddot{U} + C \dot{U} + K U + n(U) = 0$

with $C = \alpha*M + \beta*K$ under the assumption that M and K are spd matrices.

The steps are:

  1. calculate eigenpair $\omega_k, \varphi_k$ of: $(K-\omega_k^2*M)\varphi_k=0$

  2. calculate: $\xi_k=0.5(\frac{\alpha}{\omega_k} + \beta * \omega_k)$

  3. calculate eigenvalues: $\lambda_k = -\xi_k*\omega_k \sqrt{1-\xi_k^2}$

Calculates only the first nev eigenvectors, sorted by increasing $\omega_k$: mode k occupies the adjacent conjugate entries 2k-1, 2k.

Fields

  • eigenvalues::Union{Nothing, Vector} — eigenvalues from the most recent eigensolve, nothing before the first one. Cached so the left problem can reuse them instead of re-running Arpack.

  • nev::Int64 — number of modes to compute, counted in the second-order problem (so nev frequencies ωₖ, not 2·nev first-order eigenvalues).

  • α::Float64 — mass-proportional damping coefficient in C = αM + βK.

  • β::Float64 — stiffness-proportional damping coefficient in C = αM + βK.

function _mode_numbers(σ::Union{Nothing, AbstractVector{Int64}}, n_eigs::Int64)
_mode_numbers(σ, n_eigs) -> Vector{Int}

Number the physical modes of a spectrum: entry i gets out[i], the index of the conjugate pair it belongs to.

The pairs are the orbits of σ, numbered by first appearance — walk the spectrum in order and, on reaching an unvisited entry, assign the next number to it and to σ(i). A self-paired entry (σ(i) = i, a real eigenvalue) is its own mode.

This deliberately does not compute ⌈i/2⌉. That formula assumes the eigensolver emits conjugate partners adjacently, which is a convention of some solvers rather than a fact about spectra — a shift-invert or filtered solve can return {1, 5} as a pair. Deriving the numbering from σ agrees with the adjacent case and stays correct otherwise.

With no conjugate structure (σ === nothing), every entry is its own mode.

function _stack_blocks(physical::AbstractArray, companions::Union{Nothing, AbstractArray}, ROM::Int64, side::Symbol)
_stack_blocks(physical, companions, ROM, side) -> Array{ComplexF64, 3}

Assemble one side's order-blocks, applying the mirrored convention here and nowhere else: the right physical slice is block 1 with its derivatives in 2:ORD, the left physical slice is block ORD with its orthogonality blocks in 1:(ORD-1).

With companions === nothing the caller already holds whole blocks and they are used as given.

function _structural_left_eigenmode_orders(λ::AbstractVector, Y::AbstractArray{<:Complex, 3}, mass::AbstractMatrix, damping::AbstractMatrix)
_structural_left_eigenmode_orders(λ, Y, mass, damping) -> Array{ComplexF64, 3}

Analytic companion left-eigenvector order-blocks for a proportionally damped second-order structure M ẍ + C ẋ + K x = 0 with real symmetric M, K and C = αM + βK. For the sesquilinear left eigenvector φ (solving φᴴ (λB − A) = 0 on the companion pencil) with real position mode ϕ = Y[:, 1, k]:

φ_2 = ϕ                    (physical slice)
φ_1 = (conj(λ) M + C) ϕ

These are exactly the blocks eigensolve_left would return — algebraically equal to -(1/conj(λ)) Kᵀϕ via the quadratic eigenrelation, but built from the moderate-norm M, C instead of K (which amplifies eigensolver noise by (ω_max/ω₁)²). No adjoint eigensolve is needed because the proportional damping makes the blocks analytic in ϕ.

function check_biorthogonality(sd::SpectralData{ORD, ROM}, model::NthOrderModel) where {ORD, ROM}
check_biorthogonality(sd::SpectralData, model) -> Matrix{ComplexF64}

Return the master-block biorthogonality matrix G[r, s] = φᵣᴴ B ψₛ, which should be the identity for biorthogonally normalised eigenvectors.

This is the numerical guard on the mirrored right/left index convention: swapping the physical slice for a derivative block, or the two sides for each other, destroys G ≈ I loudly, whatever accessor was misused.

Diagnostic only — deliberately not called from parametrise or the solve, so it adds no cost to a normal run. Call it in tests, or when a result looks wrong.

function detect_conjugate_permutation(lambda::AbstractVector; atol)
detect_conjugate_permutation(lambda; atol = 1e-8) -> Union{Vector{Int}, Nothing}

Attempt to construct a conjugate-permutation vector from the eigenvalue vector lambda (length NVAR). Returns a Vector{Int} perm such that

lambda[perm[i]] ≈ conj(lambda[i])   for all i

and perm[perm[i]] == i (involution), or nothing if no such perfect pairing exists (e.g. an eigenvalue has no conjugate partner within atol).

Warning — necessary but not sufficient. Two eigenvalues forming a conjugate pair does not guarantee that the corresponding eigenvectors satisfy

master_modes[:, perm[r]] ≈ conj(master_modes[:, r]).

This condition can fail when:

  • the eigenvalue is degenerate (eigenspace has dimension > 1),

  • the solver returned a non-conjugate basis for a repeated eigenvalue,

  • eigenvectors were post-processed with different phases or normalisation.

Always verify eigenvector conjugacy (e.g. check norm(master_modes[:, perm[r]] - conj(master_modes[:, r]))) before passing the returned vector to solve_parametrisation as conjugate_permutation. Passing an incorrect permutation silently corrupts W and R.

Arguments

  • lambda — eigenvalue vector of length NVAR (master + external eigenvalues).

  • atol — absolute tolerance for the conjugate-match test

		  `|lambda[j] - conj(lambda[i])| < atol`.

Returns

Vector{Int} (involution, 1-based) if a perfect pairing is found; nothing otherwise.

function eigensolve(model::NthOrderModel, solver::DefaultEigensolver) eigensolve(model::NthOrderModel, solver::MorfeEigensolver) eigensolve(model::NthOrderModel, solver::StructureModalDampingEigensolver) eigensolve(model::NthOrderModel, solver::AbstractEigensolver, args...) eigensolve(::AbstractMatrix, ::AbstractMatrix, ::StructureModalDampingEigensolver)
eigensolve(model::NthOrderModel, solver::DefaultEigensolver)

Solves right eigenproblem using eigen from LinearAlgebra. Let A and B be the first order matrices of model. Then it returns the eigenpairs

$ (A-\lambda_k B)y_k = 0$
eigensolve(model::NthOrderModel, solver::MorfeEigensolver)

Solves right eigenproblem using generalised_eigenpairs from MORFE.Eigensolvers. Let A and B be the first order matrices of model. Then it returns the eigenpairs

$ (A-\lambda_k B)y_k = 0$
eigensolve(mass::AbstractMatrix, stiffness::AbstractMatrix, solver::StructureModalDampingEigensolver)

Calculates right eigenvectors in the secod order form. Uses the relations:

$ (K-\omega_k^2*M)\y_{k,U}=0 $ $ y_{k,V}=\lambda_k y_{k,U} $

and assumes $C = \alpha*M + \beta*K$.

eigensolve(model::NthOrderModel, solver::StructureModalDampingEigensolver)

Thin wrapper: extracts M = model.linear_terms[end] and K = model.linear_terms[1] and delegates to eigensolve(M, K, solver).

function eigensolve_left(model::NthOrderModel, solver::DefaultEigensolver) eigensolve_left(model::NthOrderModel, solver::MorfeEigensolver) eigensolve_left(model::NthOrderModel, solver::AbstractEigensolver, args...)
eigensolve_left(model::NthOrderModel, solver::DefaultEigensolver)

Solves left eigenproblem using eigen from LinearAlgebra. let A and B be the first order matrices of model. Then it returns the eigenpairs

$ x_k^H(A-\lambda_k B) = 0$
eigensolve_left(model::NthOrderModel, solver::MorfeEigensolver)

Solves left eigenproblem using σ-shift to recover the correct eigenvalues.

function external_conjugate_permutation(::Nothing; atol) external_conjugate_permutation(sys::ExternalSystem; atol)
external_conjugate_permutation(sys; atol = 1e-8) -> Union{Vector{Int}, Nothing}

The conjugate involution σ on the N_EXT external variables, or nothing when the external system has no conjugate structure to offer.

σ pairs external variable k with the one carrying conj(λ_k), and — when the system was re-based — additionally guarantees Q[:, σ(k)] == conj(Q[:, k]). Both conditions are needed for the reduction's conjugate symmetry: Realification.realify applies one conj_map across all NVAR variables, external ones included, and fill_conjugate_monomial! implements W_{P·γ} = conj(W_γ). With a real forcing F the external columns satisfy Φ[:, k] = L(λ_k)⁻¹ F Q[:, k], so conj(Φ[:, k]) = Φ[:, σ(k)] follows exactly from the two conditions together.

Eigenvalue pairing alone is not sufficient — see detect_conjugate_permutation's own warning — which is why the basis columns are verified rather than assumed. A system re-based onto a Schur basis generally fails that check and correctly returns nothing: its external variables simply are not conjugate pairs.

Use full_conjugate_permutation to assemble the full NVAR permutation.

function full_conjugate_permutation(master_perm::AbstractVector{Int64}, sys::Union{Nothing, ExternalSystem})
full_conjugate_permutation(master_perm, sys) -> Vector{Int}

Assemble the full NVAR-length conjugate_permutation from the master block and the external system, appending ROM .+ σ where σ is the external involution.

Callers otherwise hand-write the whole vector ([2, 1, 3], [2, 1, 4, 3], …), which bakes in a pairing that a change of external coordinates can invalidate, and which has to special -case an odd number of external variables. Deriving the external block instead keeps it correct in both situations.

Throws when the external system has no conjugate structure — see external_conjugate_permutation.

function generalised_eigenpairs(args...; kwargs...)
generalised_eigenpairs(A, B; nev, shift=nothing, which=:LM, tol=0.0,
					   maxiter=3000, ncv=nothing, v0=nothing,
					   ritzvec=true, sort_largest_real=false)

Solve the generalised eigenproblem A x = lambda B x using Arpack.

Requires Arpack.jl and LinearMaps.jl. Load them to activate the MORFE extension.

function indices(b::ModeBundle)
indices(b::ModeBundle) -> Vector{Int}

The positions this bundle's modes occupy in the source spectrum.

Selecting master modes discards the original numbering, so it is recorded here. Two things need it: restricting the spectrum-wide conjugate involution to this bundle, and reporting a mode by the entry a user would index in their own spectrum. Conjugate partners need not be adjacent, so these are not recoverable by arithmetic.

function left_eigenmode_orders_from_slice(linear_terms::NTuple{ORDP1, AbstractMatrix}, left_slice::AbstractMatrix, eigenvalues::AbstractVector; apply) where ORDP1
left_eigenmode_orders_from_slice(linear_terms, left_slice, eigenvalues)
-> Array{ComplexF64, 3}

Reconstruct the full left eigenvector order-blocks from the physical-space slice, for callers that compute only the latter. For the sesquilinear left eigenvector φ = [φ_1; …; φ_ORD] of reported eigenvalue λ (physical slice ℓ = φ_ORD, satisfying L(λ)ᴴ ℓ = 0 with L(s) = Σ_k B_k s^k), the companion block equations give

φ_{ORD-1} = conj(λ) · (B_ORDᴴ ℓ) + B_{ORD-1}ᴴ ℓ
φ_j       = conj(λ) · φ_{j+1}   + B_jᴴ ℓ          j = ORD-2, …, 1

The eigenvalue is used only to define the eigenvector from its slice — the per-monomial cohomological solve reads the blocks and never touches it. Prefer eigensolvers that return the full blocks directly (eigensolve_left does); use this only when a slice is all you have.

The slice may carry an arbitrary per-mode scale: the blocks scale with it, and the orthogonality equations are invariant under per-mode row scaling.

apply — which operator each block is hit with

apply(B_j) is what multiplies ℓ; it defaults to adjoint, giving the B_jᴴ form derived above, which is correct for a general pencil.

Pass apply = identity when the pencil is self-adjoint — B_j real symmetric for every j, so L(s)ᵀ = L(s). Two things then hold simultaneously: B_jᴴ = B_j, and the left eigenvector is the conjugate of the right one, so a right position mode may legitimately be handed in as left_slice. That is exactly the structural case, and _structural_left_eigenmode_orders is the thin wrapper for it.

The keyword exists rather than hard-coding adjoint because exact symmetry is expected of assembled M, C but not guaranteed. When B is exactly symmetric the two routes agree bitwise (measured, dense and sparse alike: symmetry makes the scatter and gather traversals visit the same indices in the same order). But a single ulp of asymmetry — Ke[i, j] and Ke[j, i] are separate floating-point expressions in an element routine — makes B * x and B' * x differ at round-off. apply = identity therefore keeps the structural path bit-for-bit unconditionally, without resting on an assumption about the assembler, and states the self-adjointness claim at the call site.

Sharing the recurrence is the point: the φ_ORD = ℓ fill-downward index convention is the easy thing to get backwards, and it is now written once.

function left_mode_blocks(b::ModeBundle) left_mode_blocks(sd::SpectralData)
left_mode_blocks(b::ModeBundle) -> view or nothing

Blocks 1:(ORD-1) of the left eigenvectors — the ones feeding the orthogonality row operators. nothing when ORD == 1.

function left_modes(b::ModeBundle) left_modes(sd::SpectralData)
left_modes(b::ModeBundle) -> Matrix{ComplexF64}

The physical-space left eigenvectors, FOM × n — the highest-order block, mirroring right_modes, which is the lowest. Cached at construction.

function master_by_sorting(nev::Integer)
master_by_sorting(nev::Integer) -> Vector{Int}

The first nev spectrum entries, as master indices — the eigenpairs were already sorted by spectrum.

Pure: it returns indices for SpectralData(model, sp; master = …) rather than marking them on the spectrum. The mutating select_master_modes_by_sorting it replaces wrote a mask into the Spectrum, which made the same object mean different things at different points in a script and left the selection invisible at the call site that used it.

function master_by_target_frequency(sp::Spectrum, target_frequencies::AbstractVector, tol::Float64)
master_by_target_frequency(sp::Spectrum, target_frequencies, tol) -> Vector{Int}

The spectrum entries within tol of any of target_frequencies, as master indices.

Distance used: dist(a, b) = abs(real(a - b)) + abs(imag(a - b)). A target that matches nothing warns and contributes no index — it is a mis-specified target, not an empty selection, and silently returning fewer masters than asked for is the failure mode worth naming.

function master_conjugate_permutation(sd::SpectralData)
master_conjugate_permutation(sd) -> Union{Nothing, Vector{Int}}

σ restricted to the master modes and re-indexed to 1:ROM — the form the cohomological solve consumes, before it is extended over the external variables.

Computed once in the constructor; this is a field read. Every solve consults it, so it is not something to re-derive per call.

function normalise_biorthogonal!(model::NthOrderModel, eigenmodes::Array{T}, left_eigenmodes::Array{T}) where T
normalise_biorthogonal!(
	model::NthOrderModel,
	eigenmodes::Matrix{T},
	left_eigenmodes::Matrix{T})

Normalise eigenmodes to fulfill the equation xi^H * B * yj = δ_{ij}

Both sides are scaled symmetrically: with s = sqrt(x_i^H B y_i), the right eigenmode is divided by s and the left eigenmode by conj(s), so the sesquilinear pairing becomes exactly 1.

function outer_conjugate_permutation(sd::SpectralData)
outer_conjugate_permutation(sd) -> Union{Nothing, Vector{Int}}

σ restricted to the outer modes and re-indexed to 1:n_outer. Used to group conjugates when reporting off-manifold near-resonances.

Computed once in the constructor; this is a field read.

function physical_mode(sd::SpectralData, i::Integer)
physical_mode(sd::SpectralData, i::Integer) -> Int

The physical mode number of spectrum entry i — conjugate partners share a number.

function right_mode_derivatives(b::ModeBundle) right_mode_derivatives(sd::SpectralData)
right_mode_derivatives(b::ModeBundle) -> view or nothing

Blocks 2:ORD of the right eigenvectors — the time derivatives ψ_{k+1} = λ ψ_k. nothing when ORD == 1.

function right_modes(b::ModeBundle) right_modes(sd::SpectralData)
right_modes(b::ModeBundle) -> Matrix{ComplexF64}

The physical-space right eigenvectors, FOM × n. Cached at construction; no indexing.

function sort_by_magnitude!(eigenvalues, eigenmodes)
sort_by_magnitude!(eigenvalues, eigenmodes)

Sorts eigenpairs to resemble the order: |λ[1]| ≤ |λ[2]| ≤ ... where λ=eigenvalues.

function sort_left_eigenmodes(eigenvalues, left_eigenvalues, left_eigenmodes)
sort_left_eigenmodes(eigenvalues, left_eigenvalues, left_eigenmodes)

Sorts the left eigenpairs to match right eigenpairs, by using the distance function dist(a, b) = abs(real(a - b)) + abs(imag(a - b))

function spectrum(model::NthOrderModel, solver::StructureModalDampingEigensolver; sorter!) spectrum(stiffness::AbstractMatrix, mass::AbstractMatrix, solver::StructureModalDampingEigensolver; sorter!) spectrum(model::NthOrderModel; solver, sorter!, normaliser!)
spectrum(model::NthOrderModel, solver::StructureModalDampingEigensolver; sorter!)

Specialised path for StructureModalDampingEigensolver: mass-normalisation is built into eigensolve, and the left eigenvector order-blocks are analytic in the position mode (see _structural_left_eigenmode_orders). No adjoint solve or biorthogonal normalisation is needed.

The optional sorter! kwarg has the same semantics as in the general eigensolve: pass (args...) -> nothing to preserve the solver's natural ordering.

spectrum(stiffness, mass, solver::StructureModalDampingEigensolver; sorter!)

Convenience overload: pass K and M directly without constructing an NthOrderModel. The Rayleigh damping matrix C = αM + βK is rebuilt from the solver parameters for the left eigenvector order-blocks.

spectrum(
	model::NthOrderModel;
	solver::AbstractEigensolver = DefaultEigensolver(),
	sorter!::Function = sort_by_magnitude!,
	normaliser!::Function = normalise_biorthogonal!)

Computes left and right eigenpairs of the problem described in model by using the defined solver. Additionally sorter! sorts the eigenpairs and normaliser! is used to normalise the eigenmodes.

function spectrum_entries(sd::SpectralData, p::Integer)
spectrum_entries(sd::SpectralData, p::Integer) -> Vector{Int}

The spectrum entries making up physical mode p — one for a real mode, two for a conjugate pair. Not necessarily consecutive, which is why they are looked up rather than computed.

Module Realification — convert complex-valued parametrisations and reduced dynamics to real-valued form.

The cohomological equations are solved in ComplexF64 to handle both damped and undamped systems uniformly. For systems with real matrices and complex-conjugate master-mode pairs, the resulting W and R satisfy conjugate-symmetry relations that allow an exact transformation to real arithmetic. This module implements that transformation so that subsequent time integration and post-processing can operate entirely in real arithmetic.

function _realify_term(exp_vec::StaticArraysCore.SVector{N, Int64}, coeff::C, n::Int64) where {C, N}
_realify_term(exp_vec::SVector{N,Int}, coeff::C, n::Int)
	-> Dict{SVector{N,Int}, C} where {C,N}

Transform a single term (exponent vector exp_vec and coefficient coeff) of a polynomial in the canonical form (z, z̄, w) into a sum of real monomials. Returns a dictionary mapping new exponent vectors (in the real variables) to their coefficients.

Here N = 2n + m, with n conjugate pairs and m real variables.

function _reorder_canonical(poly::DensePolynomial{C, N, N1, A} where {N1, A<:AbstractArray{C, N1}}, conj_map::Vector{Int64}) where {C, N}
_reorder_canonical(poly::DensePolynomial{C,N}, conj_map::Vector{Int})
	-> (DensePolynomial{C,N}, n, m)

Reorder variables according to a conjugation map conj_map of length N (where N = number of variables).

  • conj_map[i] = j means variable i is conjugate to variable j.

  • If variable i is real, then conj_map[i] = i.

The reordering groups variables as (z₁, …, zₙ, conj(z₁), …, conj(zₙ), w₁, …, wₘ) where n is the number of conjugate pairs and m the number of real variables. Terms with the same exponent after reordering are merged.

Returns the canonical polynomial (same concrete type as poly), n, and m.

function realify(poly::DensePolynomial, conj_map::Vector{Int64})
realify(poly::DensePolynomial, conj_map::Vector{Int}) -> DensePolynomial

Transform a complex‑valued polynomial (with variables that may be conjugate pairs) into a polynomial in real variables.

Arguments

  • poly: a polynomial in variables z₁, …, z_N.

  • conj_map: a vector of length N where conj_map[i] = j means variable i is the conjugate of variable j; if i is real, then conj_map[i] = i.

Returns

A new polynomial in real variables x₁, …, x_n, y₁, …, y_n, w₁, …, w_m with n conjugate pairs and m real variables. The transformation uses the formulas z = x + i y, z̄ = x - i y. The returned polynomial has the same concrete type as the input poly (including the same number of variables).

function realify_via_linear(poly::DensePolynomial, conj_map::Vector{Int64})
realify_via_linear(poly::DensePolynomial, conj_map::Vector{Int}) -> DensePolynomial

Transform a complex‑valued polynomial into a polynomial in real variables by composing with the linear map that expresses complex variables in terms of real and imaginary parts. This is an alternative implementation to realify that uses the compose_linear function. The returned polynomial has the same concrete type as the input poly (real coefficients).

See also: realify, compose_linear

Module Resonance — resonance detection for the parametrisation method.

Eigenvalue roles

Three eigenvalue groups are distinguished:

  • master_eigenvalues (required): the ROM eigenvalues. They enter the superharmonic s = ⟨λ, α⟩ and define the inner resonance targets (rows 1:ROM of inner_resonances).

  • external_eigenvalues (optional): eigenvalues of the external forcing system. They enter s through the multiindex coefficients but do not produce a target row. Pass them so that s is computed over the full NVAR = ROM + N_EXT index.

  • outer_eigenvalues (optional): additional resonance targets (eigenvalues not included in master_eigenvalues, tested for near-resonance). They define the rows of outer_resonances but do not enter s.

Choosing a resonance style

Four constructors are provided:

ConditionNumberEstimateCondition <: OuterResonanceCondition

Flags a monomial as resonant using the criterion:

|λⱼ - s| * max_cond < spectral_radius * κ(λⱼ)

Scaling the test by the spectral radius and by each eigenvalue's own conditioning makes the criterion dimensionless, so it transfers across models without retuning a raw distance tolerance.

Fields

  • eigenvalues::Vector{ComplexF64} — the target eigenvalues, in local indexing.

  • spectral_radius::Float64 — spectral radius of the full-order system, setting the scale against which |λⱼ - s| is judged.

  • condition_numbers::Vector{Float64} — per-target eigenvalue condition numbers κ(λⱼ).

  • max_cond::Float64 — the largest condition number tolerated for the cohomological operator before the monomial counts as resonant.

  • target_indices::Vector{Int} — which local targets this condition applies to.

  • conjugacy_map::Union{Nothing, Vector{Int}} — optional local conjugacy map, used as in RealEigenvalueCondition; nothing when pairing is not wanted.

EigenvalueCondition <: OuterResonanceCondition

Flags a monomial as resonant when |λⱼ - s| < tol.

Fields

  • eigenvalues::Vector{ComplexF64} — the target eigenvalues, in local indexing.

  • tol::Union{Float64, Vector{Vector{Float64}}} — a scalar tolerance, or a per-monomial, per-target table when the threshold has to vary.

  • target_indices::Vector{Int} — which local targets this condition applies to, typically 1:n. Kept explicit so several conditions can cover disjoint targets.

GraphInternal <: InternalResonance

Every monomial of total degree ≥ 2 is marked resonant with all inner master modes. Linear monomials eᵣ are resonant only with their own mode r.

InternalResonance

Abstract supertype for strategies that decide which monomials are resonant with the inner (ROM) master modes.

NormalFormInternal <: InternalResonance

No monomial is automatically marked resonant with inner modes; resonance is determined entirely by the eigenvalue-proximity condition.

OuterResonanceCondition

Abstract supertype for conditions that test whether a monomial is resonant with a mode at superharmonic frequency s.

All concrete subtypes must implement is_resonant(cond, target::Int, s::Number, k::Int) -> Bool.

RealEigenvalueCondition <: OuterResonanceCondition

Flags a monomial as resonant when |λⱼ - s| < tol or |λ_{conj(j)} - s| < tol, so that conjugate eigenvalue pairs share the resonance flag.

Fields

  • eigenvalues::Vector{ComplexF64} — the target eigenvalues, in local indexing.

  • conjugacy_map::Vector{Int} — conjugacy_map[i] is the local index of the conjugate of eigenvalue i. Pairing them keeps the flags symmetric, which a real-valued full-order model requires.

  • tol::Union{Float64, Vector{Vector{Float64}}} — a scalar tolerance, or a per-monomial, per-target table.

  • target_indices::Vector{Int} — which local targets this condition applies to.

ResonanceConfig(; style, tol, tol_relative, conjugacy_map, outer_targets, warn_outer)

Every knob that controls resonance detection, in one place.

Previously these were loose keyword arguments spread across parametrise (resonance, resonance_tol, conjugacy_map) and re-implemented again in MORFEFerrite's structural backend (resonance_tol, resonance_tol_rel, plus a separate off-manifold warning pass). Gathering them means there is one thing to read, and combinations that cannot work are rejected where they are written rather than deep inside the solve.

Fields

  • style::Symbol = :graph — :graph, :complex_normal_form, or :real_normal_form.

  • tol::Union{Nothing, Real, AbstractVector} = nothing — absolute detuning threshold. nothing means "not specified" and resolves to the style's default.

  • tol_relative::Union{Nothing, Real} = nothing — when set, replaces tol by the per-target threshold tol_relative * |λⱼ|, judging each target on its own frequency scale. This is the physically meaningful criterion when the master modes span decades.

  • conjugacy_map::Union{Nothing, Vector{Int}} = nothing — required by :real_normal_form, and an error with any other style rather than silently ignored.

  • outer_targets::Bool = false — also flag resonances against the non-master eigenvalues, populating the outer_resonances block. Diagnostic: the solve reads only the inner block.

  • warn_outer::Bool = true — warn when a monomial is near-resonant with an off-manifold mode, whose direction is then solved through a near-singular operator.

  • eigenvalue_projection::Symbol = :full — which part of the master eigenvalues detection compares. :full uses them as they are; :imaginary_part_only replaces λ by i·Im(λ), so near-resonance is judged on frequency alone and the growth rate is ignored. See below.

eigenvalue_projection

For an oscillatory reduction about a marginally stable state — a Hopf normal form, say — what makes a monomial resonant is that its frequency combination ⟨Im λ, α⟩ lands on a target frequency. The growth rate Re λ is small, and it varies with the continuation parameter, so letting it enter the detuning makes the flag pattern depend on where in a parameter sweep you happen to be. :imaginary_part_only removes that dependence.

It applies to the master eigenvalues only — the external and outer eigenvalues are compared as they are. Note it also changes what tol_relative means, and helpfully so: tol_relative * |λ| becomes tol_relative * |Im λ|, a tolerance measured on the same frequency scale the detection now uses.

The default is :full, so nothing changes unless it is asked for.

Why tol defaults to nothing rather than 1e-2

The guards below fire on explicitly set values. With a numeric default there would be no way to tell "the user asked for this tolerance" from "nobody said anything", so a plain ResonanceConfig() would emit a spurious "tolerance unused" notice on every run — and guards that cry wolf get ignored. nothing is the only honest "unspecified".

ResonanceSet{ROM, N_EXT, M}

Boolean look-up table recording which monomials are resonant with which master-mode or outer-mode targets.

Type parameters: ROM = number of master modes, N_EXT = external system size, M = matrix type (typically BitMatrix).

Resonance decides, per monomial, whether the cohomological system gets a border on a given master row — so this table is consulted once per monomial per master mode, and is precomputed as bits rather than re-tested against tolerances during the solve.

Use one of the resonance_set_from_* constructors rather than building this directly.

Fields

  • multiindices::MultiindexSet — the set over which resonances are defined; its NVAR must equal ROM + N_EXT, which the constructor enforces.

  • inner_resonances::M — ROM × NMON; entry (r, k) is true when monomial k is resonant with master mode r.

  • outer_resonances::Union{Nothing, M} — n_out × NMON for outer (non-master) targets, or nothing when there are none. Outer resonance is not something the border can absorb; it signals that the master set is too small.

function _build_inner_matrix(strategy::MORFE.Resonance.InternalResonance, inner_cond::Union{Nothing, MORFE.Resonance.OuterResonanceCondition}, super_eigenvalues, multiindices::MultiindexSet, n_int::Int64)

Build the n_int × NMON inner resonance matrix.

strategy applies graph/normal-form unconditional flags; inner_cond (optional) applies an eigenvalue-proximity check on the master eigenvalues.

function _build_outer_matrix(outer_cond::MORFE.Resonance.OuterResonanceCondition, super_eigenvalues, multiindices::MultiindexSet, n_out::Int64)

Build the n_out × NMON outer resonance matrix using outer_cond.

function _first_close_pair(eigenvalues::AbstractVector, tol::Float64, n::Int64)
_first_close_pair(eigenvalues, tol, n) -> (i, j, gap)

The first pair of eigenvalues separated by no more than tol, or (0, 0, 0.0) if there is none. Allocation-free, and stops at the first hit.

Split out from the caller so the @info that reports it sits outside the loop: string interpolation inside a loop body captures the loop variables and boxes them on every iteration, even when the branch is not taken and nothing is logged.

function _outer_warn_tolerance(config::ResonanceConfig, outer_eigenvalues::AbstractVector)
_outer_warn_tolerance(config, outer_eigenvalues)
	-> Union{Nothing, Float64, Vector{Float64}}

The threshold the off-manifold scan compares |λ_outer[j] - s| against, resolved without going through resolve_tolerances.

That function is wrong for this caller three times over: it re-emits every @info guard a second time, tol_relative makes it build n_monomials identical tolerance rows where the scan wants one, and for an explicitly per-target tol it returns nothing for the outer family — a vector sized for the inner targets cannot be indexed by an outer target number. That last case used to reach _resolve_outer_tol and throw, so a per-target tolerance combined with the default warn_outer = true aborted the solve. It now returns nothing, and the scan is skipped with a notice rather than taking the run down with it.

function _resolve_outer_tol(tol, outer_tol, n_out::Int64, n_int::Int64)
_resolve_outer_tol(tol, outer_tol, n_out, n_int)

Pick the tolerance for the outer targets, and reject the one combination that used to be a silent bounds error.

A per-target tolerance is read tol[k][local_idx] with local_idx local to its own condition — 1:n_int for the inner block, 1:n_out for the outer one. The two blocks are built from separate condition objects, so they can perfectly well carry separate tolerances; only the public constructors' single tol argument tied them together, and a per-target vector sized for n_int then overran whenever n_out > n_int.

outer_tol === nothing means "reuse tol", which is exactly right for a scalar (it applies to any number of targets) and impossible for a per-target vector — hence the error, which tells the caller what to pass instead of failing deep inside is_resonant.

function _warn_outer_resonances(mset::MultiindexSet, master_eigs, outer_eigs, external_eigs, config::ResonanceConfig, spectral)
_warn_outer_resonances(mset, master_eigs, outer_eigs, external_eigs, config, spectral)

Warn when a monomial is near-resonant with a physical mode that is not on the manifold.

That direction is then solved through a near-singular operator, so the ROM loses accuracy there regardless of how the load is shaped: solve_single_monomial! builds its operator from s = ⟨λ, α⟩ alone, which makes the conditioning independent of the right-hand side. Rounding injects a component along the near-null direction and 1/(λ_s - s) amplifies it.

Runs for autonomous and forced models alike — a monomial built purely from master coordinates that lands on an off-manifold eigenvalue is exactly as near-singular as a forced one, and that its cause is the chosen master set makes it more worth surfacing.

Why this does not build a ResonanceSet

It used to, and that made an on-by-default diagnostic cost O(NMON × n_outer) with a large constant: a whole second set including an n_int × NMON inner block that was discarded, _superharmonics allocating a temporary per monomial twice, and _local_index's findfirst inside is_resonant turning the outer build into O(NMON × n_outer²).

The criterion needs none of that — it is one distance test per (monomial, outer target) — so it is written out directly. A run that flags nothing now costs a constant 64 bytes (measured, unchanged from |mset| = 20 to 54) against the 246 400 the probe cost at |mset| = 35 with 58 outer modes. The test itself is unchanged: |λ_outer[j] - s| < tol, the same EigenvalueCondition comparison the probe applied, for every style. Reusing the already-built outer block instead was rejected deliberately: under :real_normal_form that block ORs each target with its conjugate, which would silently change what gets reported.

function apply_internal_resonances!(::AbstractMatrix{Bool}, ::MORFE.Resonance.NormalFormInternal, ::AbstractVector{Int64}, ::Int64, ::Int64) apply_internal_resonances!(mat::AbstractMatrix{Bool}, ::MORFE.Resonance.GraphInternal, mi::AbstractVector{Int64}, n_int::Int64, k::Int64)
apply_internal_resonances!(mat, strategy, mi, n_int, k)

Set inner-resonance flags in column k of the inner matrix mat for the monomial with exponent vector mi.

function build_resonance_set(model::NthOrderModel, mset::MultiindexSet, spectral, config::ResonanceConfig)
build_resonance_set(model, mset, spectral::SpectralData, config::ResonanceConfig)
	-> ResonanceSet

Build the resonance set from a SpectralData bundle and a ResonanceConfig — the single entry point for resonance construction.

Reads the master and outer eigenvalues off spectral (replacing the eigenproblem plus master-mask plumbing), resolves the config's tolerances into correctly-sized inner and outer objects via resolve_tolerances, and warns about off-manifold near-resonances when config.warn_outer is set.

function empty_resonance_set(multiindices::MultiindexSet{NVAR}, n_int::Int64) where NVAR empty_resonance_set(multiindices::MultiindexSet{NVAR}, n_int::Int64, n_out::Int64) where NVAR
empty_resonance_set(multiindices, n_internal, n_outer=0) -> ResonanceSet

Construct a ResonanceSet with all resonance flags set to false. n_internal = number of master modes (ROM); n_outer = number of outer targets (0 = none).

function is_resonant(rs::ResonanceSet{ROM}, idx::Int64, target::Int64) where ROM is_resonant(rs::ResonanceSet{ROM}, mi::Vector{Int64}, target::Int64) where ROM is_resonant(cond::MORFE.Resonance.EigenvalueCondition, target::Int64, s::Number, k::Int64) is_resonant(cond::MORFE.Resonance.RealEigenvalueCondition, target::Int64, s::Number, k::Int64) is_resonant(cond::MORFE.Resonance.ConditionNumberEstimateCondition, target::Int64, s::Number, ::Int64)
is_resonant(rs, idx, target) -> Bool
is_resonant(rs, mi, target)  -> Bool

Return true when the monomial at position idx (or exponent vector mi) is resonant with target target. Targets 1:ROM query inner_resonances; targets > ROM query outer_resonances (returns false when outer is nothing).

function n_internal(::ResonanceSet{ROM}) where ROM
n_internal(rs::ResonanceSet{ROM}) -> ROM

Return the number of internal master modes (compile-time constant from type parameter).

function project_eigenvalues(λ::AbstractVector, projection::Symbol)
project_eigenvalues(λ, projection::Symbol) -> Vector

Apply a ResonanceConfig eigenvalue projection: :full is the identity, returning Vector{ComplexF64}; :imaginary_part_only returns the imaginary parts Im(λ) as Vector{Float64}.

A projected eigenvalue i·Im(λ) is carried by the real number Im(λ) — the two are isomorphic for this purpose, because for real a, b

|i·a − i·b| = |a − b|

and the whole of detection is abs(eig - s). So carrying the frequencies as Float64 reproduces the pure-imaginary complex computation exactly, with no i·Im reconstruction and one code path serving both modes.

Project every family the same way — master, external and outer. Mixing a projected family with an unprojected one promotes the superharmonic s = ⟨λ, α⟩ back to complex and reinstates the growth rates through the back door, which is precisely what the projection was asked to remove.

Always returns a plain Vector. The master eigenvalues arrive as a static vector, and a comprehension over one stays static — a SizedVector that the resonance_set_from_* signatures reject. The loops below are the same reason the caller used Vector{ComplexF64}(…) rather than collect.

function resolve_tolerances(config::ResonanceConfig, master_eigenvalues::AbstractVector, outer_eigenvalues::AbstractVector, n_monomials::Int64)
resolve_tolerances(config, master_eigenvalues, outer_eigenvalues, n_monomials)
	-> (inner_tol, outer_tol)

Turn a ResonanceConfig into the two correctly-sized tolerance objects the resonance-set constructors want, and emit @info for settings that will have no effect.

With tol_relative, each family is sized for its own target count:

inner[k][r] = tol_relative * |λ_master[r]|      length n_int
outer[k][j] = tol_relative * |λ_outer[j]|       length n_out

which is why the two can now be combined at all.

function resonance_set_from_complex_normal_form_style(multiindices::MultiindexSet{NVAR}, master_eigenvalues::AbstractVector{<:Number}, tol::Union{Float64, Vector{Vector{Float64}}}; external_eigenvalues, outer_eigenvalues, outer_tol) where NVAR
resonance_set_from_complex_normal_form_style(
	multiindices, master_eigenvalues, tol;
	external_eigenvalues, outer_eigenvalues)

Build a ResonanceSet using the complex normal form style: inner resonances flagged by |λᵣ - s| < tol for each master mode r; outer resonances (if any) flagged by proximity to outer_eigenvalues.

Suitable for autonomous Invariant Manifolds (e.g. NNMs) with complex conjugate reduced variables; add external_eigenvalues for non-autonomous systems where the multiindex includes forcing directions.

function resonance_set_from_condition_number_estimate(multiindices::MultiindexSet{NVAR}, master_eigenvalues::AbstractVector{<:Number}, spectral_radius::Float64, target_condition_numbers::Vector{Float64}, max_cond::Float64; external_eigenvalues, outer_eigenvalues, inner_target_indices, outer_target_indices, conjugacy_map) where NVAR
resonance_set_from_condition_number_estimate(
	multiindices, master_eigenvalues, spectral_radius,
	target_condition_numbers, max_cond;
	external_eigenvalues, outer_eigenvalues,
	inner_target_indices, outer_target_indices, conjugacy_map)

Build a ResonanceSet using a condition-number criterion:

|λⱼ - s| * max_cond < spectral_radius * κ(λⱼ)

target_condition_numbers has n_int + n_out entries: the first n_int are for the master modes, the remainder for the outer modes.

inner_target_indices / outer_target_indices restrict which rows in each sub-matrix are populated (default: all).

function resonance_set_from_graph_style(multiindices::MultiindexSet{NVAR}, master_eigenvalues::AbstractVector{<:Number}, external_eigenvalues::AbstractVector{<:Number}, outer_eigenvalues::AbstractVector{<:Number}, tol::Union{Float64, Vector{Vector{Float64}}}) where NVAR resonance_set_from_graph_style(multiindices::MultiindexSet{NVAR}, master_eigenvalues::AbstractVector{<:Number}, external_eigenvalues::AbstractVector{<:Number}, outer_condition::MORFE.Resonance.OuterResonanceCondition) where NVAR
resonance_set_from_graph_style(
	multiindices, master_eigenvalues, external_eigenvalues,
	outer_eigenvalues, tol)

Build a ResonanceSet using the graph style: every monomial of total degree ≥ 2 is marked resonant with all ROM master modes. Outer targets are flagged by eigenvalue proximity |λⱼ - s| < tol.

  • master_eigenvalues: ROM eigenvalues; enter s and are inner targets.

  • external_eigenvalues: enter s only (e.g. forcing frequencies in the multiindex, since external_eigenvalues cant be targets).

  • outer_eigenvalues: outer targets (eigenvalues not included in master_eigenvalues, tested for near-resonance). Pass ComplexF64[] when there are no outer targets.

resonance_set_from_graph_style(multiindices, master_eigenvalues, external_eigenvalues, outer_condition)

Advanced overload that accepts a pre-built OuterResonanceCondition for the outer rows. outer_condition.target_indices must use local indices 1:n_out. n_out is inferred as maximum(outer_condition.target_indices) (or 0 if empty).

function resonance_set_from_real_normal_form_style(multiindices::MultiindexSet{NVAR}, master_eigenvalues::AbstractVector{<:Number}, conjugacy_map::Vector{Int64}, tol::Union{Float64, Vector{Vector{Float64}}}; external_eigenvalues, outer_eigenvalues, outer_tol) where NVAR
resonance_set_from_real_normal_form_style(
	multiindices, master_eigenvalues, conjugacy_map, tol;
	external_eigenvalues, outer_eigenvalues)

Build a ResonanceSet using the real normal form style: like CNF but conjugate pairs share the resonance flag — monomial k is resonant with target j when |λⱼ - s| < tol OR |λ_{conj(j)} - s| < tol.

conjugacy_map has length(master_eigenvalues) + length(outer_eigenvalues) entries; the first n_int cover inner targets, the remainder cover outer targets (re-indexed locally). conjugacy_map[i] is the local index of the conjugate of target i.

function resonant_multiindices(rs::ResonanceSet{ROM}, target::Int64) where ROM
resonant_multiindices(rs, target) -> Vector{Int}

Return the positions of all monomials resonant with target. Targets 1:ROM query inner_resonances; targets > ROM query outer_resonances.

function resonant_targets(rs::ResonanceSet, idx::Int64) resonant_targets(rs::ResonanceSet, mi::Vector{Int64})
resonant_targets(rs, idx) -> AbstractVector{Bool}
resonant_targets(rs, mi)  -> Union{AbstractVector{Bool}, Nothing}

Return a boolean vector indicating which targets are resonant with the monomial at position idx (or exponent vector mi). Concatenates inner and outer rows. Returns nothing when mi is not in the multiindex set.

function set_resonance!(rs::ResonanceSet{ROM}, target::Int64, idx::Int64, value::Bool) where ROM set_resonance!(rs::ResonanceSet{ROM}, target::Int64, mi::Vector{Int64}, value::Bool) where ROM
set_resonance!(rs, target, idx, value) -> rs
set_resonance!(rs, target, mi, value)  -> rs

Set the resonance flag for target target and monomial idx (or multiindex vector mi) to value. Targets 1:ROM address inner_resonances; targets > ROM address outer_resonances. Returns rs for chaining. Warns if mi is not found.

Assemble the part of the cohomological equations that corresponds to the FullOrderModel, thereby imposing invariance of the manifold. MasterModeOrthogonality handles the orthogonality conditions induced by the parametrisation styles.


Nomenclature

SymbolMeaning
FOMFull-order model dimension
ROMNumber of master modes (reduced coordinates)
N_EXTNumber of external forcing modes
NVARROM + N_EXT (total reduced variables)
RSet of resonant master modes

Non-resonant master modes have trivial (zero) reduced dynamics and are excluded from the cohomological equations; their columns in C(s) are omitted.


Per-multiindex cohomological equation

For each multi-index k with superharmonic s = Σᵢ kᵢ λᵢ (λᵢ eigenvalues of the master modes), the cohomological equation has the block structure

[ L(s)  C(s) ] * [ W_k; f_res ] = RHS_k

where:

  • L(s) (FOM × FOM) is the parametrisation operator (characteristic-matrix polynomial of the full-order model),

  • C(s) (FOM × |R|) acts on the unknown reduced-dynamics coefficients f_res of the resonant master modes,

  • RHS_k contains all known lower-order contributions and external forcing.

External forcing modes are not unknowns; their contributions are handled separately and appear on the right-hand side.


Construction of L(s) and C(s) via Horner

The operator L(s) is defined as

L(s) = Σ_{k=1}^{ORD+1} B[k] s^{k-1}

where B[k] are the coefficient matrices of the linear part of the full-order model (size FOM × FOM). L(s) is evaluated efficiently using Horner's method.

The operator acting on the reduced dynamics is

C(s) = Σ_{j=1}^{ORD} D[j] s^{j-1}

with pre-computed coefficient matrices D[j] (size FOM × NVAR) given by

D[j] = Σ_{k=j+1}^{ORD+1} B[k] · generalised_right_eigenmodes · reduced_dynamics_linear^{k-(j+1)}

Here:

  • generalised_right_eigenmodes: NVAR × FOM matrix collecting the generalised eigenvectors of the master modes and the external forcing modes.

  • reduced_dynamics_linear: Jordan matrix of the linear part of the reduced dynamics.

The D[j] matrices are pre-computed once per order using a downward recurrence (similar to the Horner scheme in MasterModeOrthogonality).


Precomputation and assembly

The coefficients D[j] are pre-computed for all NVAR = ROM + N_EXT variables. However, when assembling the linear system for a given multi-index (with superharmonic s), only a subset of the columns of C(s) is used:

  • For the left-hand-side matrix [L(s) C(s)], only the columns corresponding to the resonant master modes (size |R|) are extracted from C(s). Non-resonant master modes are omitted because their reduced dynamics is identically zero.

  • The external forcing modes (N_EXT columns) are handled separately and do not appear as unknowns; their contributions are moved to the right-hand side via the operator -E(s) (see below).


Right-hand-side assembly

RHS_k is the sum of two independent contributions: lower-order terms from the cohomological equation, and external forcing terms. Both are evaluated using fused Horner passes that reuse intermediate matrices to minimise computational cost.

Lower-order RHS (cohomological coupling)

During the Horner evaluation of L(s), the intermediate matrices

L[j](s) = Σ_{k=j+1}^{ORD+1} B[k] · s^{k-(j+1)},   j = 1,…,ORD

are naturally available. Multiplying each L[j](s) by a pre-computed coupling vector ξ[j] (obtained from lower-order solution coefficients) gives the contribution of lower-order terms to the RHS:

RHS_lower = -Σ_{j=1}^{ORD} L[j](s) · ξ[j]

The negative sign arises because these terms originate from the left-hand side of the cohomological equation and are moved to the right-hand side. This accumulation is performed in the same Horner loop that computes L(s), avoiding recomputation of the L[j](s) intermediates.

External forcing RHS

For external forcing modes e = 1,…,N_EXT, the polynomial coefficients E_e[L] (FOM × 1 column vectors) are pre-computed such that

E_e(s) = Σ_{L=1}^{ORD} E_e[L] · s^{L-1}

is the contribution of forcing mode e to the cohomological equation when multiplied by its known amplitude external_dynamics[e]. The total external contribution is

RHS_ext = Σ_{e=1}^{N_EXT} E_e(s) · external_dynamics[e]

To evaluate this efficiently, the coefficients of all active (non-zero) external modes are first combined into a single vector polynomial:

g_L = Σ_{e active} external_dynamics[e] · E_e[L],   L = 1,…,ORD

Then g(s) = Σ_{L=1}^{ORD} g_L · s^{L-1} is evaluated in a single Horner pass. The result is added to the RHS accumulator. This fused approach avoids evaluating each E_e(s) independently and scales only with the number of active external modes.

The complete right-hand side is therefore

RHS_k = RHS_lower + RHS_ext

where both parts are computed using dedicated fused Horner passes that share the polynomial evaluation structure of the main operator L(s).


Module contents

FunctionDescription
precompute_column_polynomialsPre-compute D_{L,j} coefficient arrays for both the system-matrix columns and the external-forcing RHS
evaluate_system_matrix_and_lower_order_rhs!Fused Horner pass for L(s) + lower-order RHS
evaluate_column!Evaluate one C_r(s) column
evaluate_external_rhs!Accumulate external-forcing RHS
assemble_cohomological_matrix_and_rhs!Full block-matrix and RHS assembly (in-place)
function assemble_cohomological_matrix_and_rhs!(M::AbstractMatrix, rhs::AbstractVector, s::Number, linear_terms::NTuple{ORDP1, var"#s413"} where var"#s413"<:(AbstractMatrix), C_coeffs::Vector{<:AbstractMatrix}, E_coeffs::Vector{<:AbstractMatrix}, resonance::StaticArraysCore.SVector{ROM, Bool}, lower_order_couplings::AbstractVector{<:AbstractVector}, external_dynamics::AbstractVector, g_buffer::AbstractVector) where {ROM, ORDP1}
assemble_cohomological_matrix_and_rhs!(M, rhs, s, linear_terms, C_coeffs, E_coeffs,
										resonance, lower_order_couplings,
										external_dynamics, g_buffer) → nothing

In-place variant: writes the invariance-equation system matrix and RHS directly into the caller-supplied M and rhs buffers. No heap allocation occurs.

M must have size FOM × (FOM + ROM) — the border is constant width and the resonance mask selects its content rather than its size: column FOM + r holds C_r(s) when mode r is resonant and is zeroed otherwise. The zeroed columns are harmless because the matching orthogonality row pins R_{r,α} = 0 (see assemble_orthogonality_matrix_and_rhs!).

rhs must have length FOM. Both are overwritten on entry. g_buffer is the pre-allocated FOM-length scratch buffer for the external RHS.

function build_sparse_L_and_rhs!(rhs::AbstractVector, L_template::SparseArrays.SparseMatrixCSC, mappings::Vector{Vector{Int64}}, linear_terms::NTuple{ORDP1, var"#s413"} where var"#s413"<:SparseArrays.SparseMatrixCSC, s, lower_order_couplings::AbstractVector{<:AbstractVector}) where ORDP1
build_sparse_L_and_rhs!(rhs, L_template, mappings, linear_terms, s, lower_order_couplings)
-> L_template

In-place Horner evaluation of the parametrisation operator using a pre-allocated union-pattern template and index mappings from precompute_sparse_L_template.

Avoids ALL per-monomial sparse arithmetic allocations regardless of whether the linear_terms share a common sparsity pattern. The template's nzval is overwritten on each call; its colptr/rowval are never modified.

function evaluate_column!(c::AbstractVector{T}, s::T, r::Int64, C_coeffs::Vector{<:AbstractMatrix{T}}) where T
evaluate_column!(c, s, r, C_coeffs) -> c

Evaluate the r-th reduced-dynamics operator column

C_r(s) = Σ_{L=1}^{ORD} C_coeffs[r][:, L] · s^{L-1}

in-place via Horner's method, overwriting the pre-allocated FOM-vector c.

c may be a plain Vector{T} or a column view view(M, :, col).

Horner recurrence

L runs from ORD-1 down to 1:

c  ←  C_coeffs[r][:, ORD]              (highest-degree coefficient)
for L = ORD-1, …, 1:
	c ← c · s + C_coeffs[r][:, L]

Column access C_coeffs[r][:, L] reads contiguous memory (Julia is column-major), so the loop touches sequential cache lines.

Arguments

  • c :: AbstractVector{T} – output buffer (length FOM), overwritten.

  • s :: T – evaluation frequency.

  • r :: Int – 1-based master-mode index (1 ≤ r ≤ ROM).

  • C_coeffs :: Vector{<:AbstractMatrix{T}} – pre-computed coefficients from precompute_column_polynomials; C_coeffs[r] is FOM × ORD.

Complexity

O(ORD · FOM)

function evaluate_external_rhs!(rhs::AbstractVector{T}, s::T, external_dynamics::AbstractVector{T}, E_coeffs::Vector{<:AbstractMatrix{T}}, g::AbstractVector{T}) where T
evaluate_external_rhs!(rhs, s, external_dynamics, E_coeffs, g) -> rhs

Accumulate the external-forcing contribution to the cohomological right-hand side:

rhs += Σ_{e=1}^{N_EXT} external_dynamics[e] · E_e(s)

where E_e(s) = Σ_{L=1}^{ORD} E_coeffs[e][:, L] · s^{L-1} is the e-th external column polynomial (pre-computed by precompute_column_polynomials).

The sign flip was already absorbed into the pre‑computed coefficients E_coeffs, since they originate from the left‑hand side of the invariance equation and are moved to the right‑hand side, so the contribution is added to the RHS rather than subtracted.

Sparse exploitation

Only the non-zero entries of external_dynamics are processed. For periodic forcing of a few harmonics this is typically a small subset of N_EXT.

Combined Horner pass

Rather than evaluating each E_e(s) independently, the non-zero contributions are combined into a single degree-(ORD-1) vector polynomial

g(s) = Σ_{e active} external_dynamics[e] · E_e(s)

and evaluated in one Horner pass (ORD-1 scalar-vector multiplies plus ORD-1 saxpy operations over FOM), instead of N_EXT_active separate Horner passes.

Arguments

  • rhs :: AbstractVector{T} – accumulator (length FOM), updated in-place.

  • s :: T – evaluation frequency.

  • external_dynamics :: AbstractVector{T} – known amplitudes of the N_EXT external forcing modes; typically sparse.

  • E_coeffs :: Vector{<:AbstractMatrix{T}} – pre-computed external coefficients from precompute_column_polynomials; E_coeffs[e] is FOM × ORD.

  • g :: AbstractVector{T} – pre-allocated FOM-length scratch buffer; zeroed on entry.

Complexity

  • O(N_EXT_active · FOM · ORD) for combining coefficients.

  • O(FOM · ORD) for the single Horner evaluation.

function evaluate_system_matrix_and_lower_order_rhs!(parametrisation_operator::AbstractMatrix, lower_order_rhs::AbstractVector, s::Number, lower_order_couplings::AbstractVector{<:AbstractVector}, linear_terms::NTuple{ORDP1, var"#s412"} where var"#s412"<:(AbstractMatrix)) where ORDP1
evaluate_system_matrix_and_lower_order_rhs!(parametrisation_operator, lower_order_rhs, s, lower_order_couplings, linear_terms)
-> parametrisation_operator

Evaluate the parametrisation operator L(s) and accumulate the lower-order right-hand-side contributions in a single Horner pass, reusing the transient intermediate matrices that are available only during the polynomial evaluation.

Mathematical context

At step j of the Horner recurrence (before the scalar multiply by s), the intermediate matrix

L[j](s) = Σ_{k=j+1}^{ORD+1} B[k] · s^{k-(j+1)}

is available. Multiplying by the pre-computed coupling vector ξ[j] = lower_order_couplings[j] gives the contribution of lower-order solution terms at derivative order j to the right-hand side:

contribution[j] = -L[j](s) · ξ[j]

The negative sign arises because these terms originate from the left-hand side of the cohomological equation and are transposed to the right-hand side.

Summed over j = 1, …, ORD, the full lower-order RHS is

lower_order_rhs = -Σ_{j=1}^{ORD} L[j](s) · ξ[j] = -Σ_{j=1}^{ORD} ( Σ_{k=j+1}^{ORD+1} B[k] · s^{k-(j+1)} ) · ξ[j]

This computation must share the Horner loop with L(s): the L[j] intermediates are transient, and recomputing them would double the O(ORD · FOM²) work.

The coupling vectors are obtained from MORFE.LowerOrderCouplings.compute_lower_order_couplings applied to the lower-order multi-indices associated with each Horner step.

Arguments

  • parametrisation_operator :: AbstractMatrix{T} – output buffer (FOM × FOM), overwritten with L(s) = Σ_{k=1}^{ORD+1} B[k] · s^{k-1}.

  • lower_order_rhs :: AbstractVector{T} – accumulator (length FOM), updated in-place. Must be initialised to zero (or the desired starting value) by the caller.

  • s :: T – evaluation superharmonic.

  • lower_order_couplings :: SVector{ORD, <:AbstractVector{T}} – coupling vectors ξ[j] for j = 1,…,ORD; each element is an AbstractVector{T} of length FOM.

  • linear_terms :: NTuple{ORD+1, <:AbstractMatrix{T}} – linear_terms[k] = B[k].

Complexity

O(ORD · FOM²), shared with the L(s) evaluation.

function precompute_column_polynomials(fom_matrices::NTuple{ORDP1, var"#s413"} where var"#s413"<:AbstractMatrix{T}, generalised_right_eigenmodes::AbstractMatrix{T}, reduced_dynamics_linear::AbstractMatrix{T}, ROM::Int64) where {ORDP1, T<:Number}
precompute_column_polynomials(fom_matrices, generalised_right_eigenmodes,
							  reduced_dynamics_linear, ROM)
-> (C_coeffs, E_coeffs)

Pre-compute the polynomial coefficient arrays for the cohomological operator columns (master modes) and the external-forcing right-hand-side columns.

These arrays are computed once per polynomial order and reused for every multi-index at that order.

Return values

  • C_coeffs :: Vector{Matrix{T}} of length ROM, where C_coeffs[r] is FOM × ORD. Column j of C_coeffs[r] is the degree-(j-1) coefficient of the r-th reduced-dynamics operator column:

    C_r(s) = Σ_{j=1}^{ORD} C_coeffs[r][:, j] · s^{j-1}
  • E_coeffs :: Vector{Matrix{T}} of length N_EXT = NVAR - ROM, where E_coeffs[e] is FOM × ORD. Column j of E_coeffs[e] is the degree-(j-1) coefficient of the e-th external-forcing operator column:

    E_e(s) = Σ_{j=1}^{ORD} E_coeffs[e][:, j] · s^{j-1}

Arguments

  • fom_matrices :: NTuple{ORD+1, <:AbstractMatrix{T}} – linear matrices of the full-order model; fom_matrices[k+1] corresponds to B[k] (0-indexed in the ODE).

  • generalised_right_eigenmodes :: AbstractMatrix{T} of size FOM × NVAR – generalised eigenvectors; columns 1:ROM are the master modes, columns ROM+1:NVAR are the external forcing modes.

  • reduced_dynamics_linear :: AbstractMatrix{T} of size NVAR × NVAR – Jordan-form matrix of the linear part of the reduced dynamics on the Invariant Manifold.

  • ROM :: Int – number of master modes (dimension of the reduced-order model).

Recurrence

The output matrices are filled by a single downward Horner recurrence (j runs from ORD down to 1) using one FOM × NVAR working buffer D:

D ← B[ORD+1] * generalised_right_eigenmodes                               (j = ORD)
C_coeffs[r][:, j] ← D[:, r]       for r = 1…ROM
E_coeffs[e][:, j] ← D[:, ROM+e]   for e = 1…N_EXT

D ← D * reduced_dynamics_linear + B[j+1] * generalised_right_eigenmodes  (j = ORD-1, …, 1)
C_coeffs[r][:, j] ← D[:, r]       for r = 1…ROM
E_coeffs[e][:, j] ← D[:, ROM+e]   for e = 1…N_EXT

After step j, column j of every per-target matrix holds the exact degree-(j-1) coefficient

D[:, ·] = Σ_{k=j+1}^{ORD+1} B[k] * generalised_right_eigenmodes * reduced_dynamics_linear^{k-(j+1)}

Complexity

  • Time: O(ORD · FOM · NVAR) (dominated by ORD matrix–matrix products)

  • Storage: O(ORD · FOM · NVAR)

function precompute_external_column_polynomials(fom_matrices::NTuple{ORDP1, var"#s413"} where var"#s413"<:(AbstractMatrix), external_directions::AbstractMatrix, reduced_dynamics_linear::AbstractMatrix, D_master_steps::Vector{<:AbstractMatrix}) where ORDP1
precompute_external_column_polynomials(fom_matrices, external_directions,
										reduced_dynamics_linear, D_master_steps)
-> E_coeffs

Φext-dependent half of [`precomputecolumnpolynomials](@ref). Computes the external-mode column polynomialsEe(s)reusing the pre-saved master Horner intermediatesDmastersteps(from [precomputemastercolumn_polynomials`](@ref)) instead of recomputing the master-column work.

Pass external_directions = zeros(FOM, N_EXT) to obtain the partial (Φext = 0) Ecoeffs needed for the initial external-forcing solve.

Arguments

function precompute_master_column_polynomials(fom_matrices::NTuple{ORDP1, var"#s413"} where var"#s413"<:(AbstractMatrix), master_modes::AbstractMatrix, Λ_master::AbstractMatrix) where ORDP1
precompute_master_column_polynomials(fom_matrices, master_modes, Λ_master)
-> (C_coeffs, D_master_steps)

Φext-independent half of [`precomputecolumnpolynomials](@ref). Computes only the master-mode column polynomialsCr(s)and saves the intermediate FOM×ROM Horner buffer at every step for later reuse by [precomputeexternalcolumn_polynomials`](@ref).

Because Λ is upper-triangular, the master columns of the Horner buffer D form a closed subsystem under the recurrence D ← D·Λ + B[j+1]·Y: for column r ≤ ROM, (D·Λ)[:,r] = Σ_{k≤r} D[:,k]·Λ[k,r] depends only on master columns. Therefore C_coeffs is independent of the external directions Φ_ext.

Returns

function precompute_sparse_L_template(linear_terms::NTuple{ORDP1, var"#s413"} where var"#s413"<:SparseArrays.SparseMatrixCSC) where ORDP1
precompute_sparse_L_template(linear_terms) -> (L_template, mappings)

Pre-allocate a SparseMatrixCSC{ComplexF64} with the union sparsity pattern of all linear_terms, and compute index mappings so that each entry of linear_terms[k] can be accumulated into the correct position of the template's nzval array.

Returns (L_template, mappings) where mappings[k][i] is the index into L_template.nzval that corresponds to position i of linear_terms[k].nzval.

Used once at context construction; the template is reused across all monomials by the in-place build_sparse_L_and_rhs! overload, eliminating per-monomial sparse arithmetic allocations even when the input matrices do not share a common pattern (e.g. when the damping matrix is C = α*M + β*K and Julia's sparse addition drops entries that cancel to exactly zero).

function precompute_sparse_bordered_template(L_template::SparseArrays.SparseMatrixCSC{Tv, Ti}, ROM::Int64) where {Tv, Ti}
precompute_sparse_bordered_template(L_template, ROM) -> (M, border_row_base)

Allocate the constant-size (FOM+ROM) × (FOM+ROM) bordered cohomological matrix

	┌                              ┐
	│  L(s)      C(s) P            │   FOM rows  (invariance)
	│  P Ĵ(s)    P Ĉ(s) P + τ Q    │   ROM rows  (orthogonality / R_α = 0)
	└                              ┘

whose sparsity pattern depends only on the union pattern of L_template and on ROM — never on the resonance mask P = diag(ρ) of the monomial being solved. Non-resonant border entries are carried as numeric zeros in structural positions, which is exactly what allows one symbolic factorisation to be reused for every monomial. The system being represented is documented in the CohomologicalEquations module docstring; what follows is this function's own contract — where each block lands in the CSC arrays, which the assembly in _solve_monomial! writes to directly.

Block layout, in CSC order:

blockrowscolspattern
L1:FOM1:FOMunion pattern of L_template
C P1:FOMFOM+1:enddense FOM × ROM
P ĴFOM+1:end1:FOMdense ROM × FOM
P Ĉ P + τ QFOM+1:endFOM+1:enddense ROM × ROM

so nnz(M) = nnz(L_template) + 2·FOM·ROM + ROM². Appending the border rows to each of the first FOM columns preserves sorted rowval because every union row index is ≤ FOM.

Returns

  • M :: SparseMatrixCSC — the template; only nzval is ever written afterwards.

  • border_row_base :: Vector{Int} — length FOM; entry M[FOM+r, c] for c ≤ FOM lives at M.nzval[border_row_base[c] + r - 1].

No L → M index table is returned or needed. Because the L entries of column c are laid down as a contiguous prefix of that column, the map is affine within each column,

	L_template.nzval[p]  ↦  M.nzval[M.colptr[c] + (p - L_template.colptr[c])]

so scatter_L_into_bordered! is a per-column block copy rather than an indirect gather — which also saves an nnz(L)-length index vector.

Border column positions need no table either: column FOM+q starts at bq = M.colptr[FOM+q], so C_q(s) occupies the contiguous run M.nzval[bq : bq+FOM-1] and the corner entry M[FOM+m, FOM+q] sits at bq + FOM + m - 1.

function scatter_L_into_bordered!(M::SparseArrays.SparseMatrixCSC, L_template::SparseArrays.SparseMatrixCSC)
scatter_L_into_bordered!(M, L_template) -> M

Copy the freshly evaluated L(s) from the standalone Horner workspace into the (1,1) block of the bordered template.

L's entries for column c sit contiguously in both matrices — at L_template.colptr[c] … and at M.colptr[c] … respectively (see precompute_sparse_bordered_template) — so each column is a single copyto! block move rather than an element-by-element gather through an index table. Keeping this a separate step is what leaves build_sparse_L_and_rhs! untouched: it needs its own square workspace for the transient Horner intermediates L[j](s) that accumulate the lower-order RHS.

Assemble the orthogonality conditions that arise in the parametrisation method, a reduced-order modelling technique for high-dimensional dynamical systems.


Nomenclature

SymbolMeaning
FOMFull-order model dimension
ROMNumber of master modes (reduced coordinates)
N_EXTNumber of external forcing modes
NVARROM + N_EXT (total reduced variables)
RSet of resonant master modes (`

Non-resonant master modes have trivial (zero) reduced dynamics. They are not dropped from the block: each contributes the trivial row f_r = 0, so the block keeps a constant ROM × (FOM+ROM) shape whatever the resonance pattern.


Mathematical origin: sesquilinear B-orthogonality

For each multi-index γ with superharmonic s = Σᵢ γᵢ λᵢ, the condition for master mode r is the sesquilinear B-orthogonality of the companion left eigenvector φ_r = [φ_{r,1}; …; φ_{r,ORD}] (solving φ_rᴴ (λ_r B − A) = 0) against the first-order state 𝒲 of the monomial:

φ_rᴴ B 𝒲 = 0,     𝒲 = [W_1; …; W_ORD],   W_1 = W,   W_{j+1} = s W_j + Y_j f + ξ_j

Here Y_j are the right eigenmode order-blocks, f the reduced-dynamics coefficient at γ (master part unknown, external part known forcing) and ξ_j the known lower-order couplings. Solving the recurrence and collecting terms reduces the condition to a single row equation

Ĵ_r(s) W + C_r(s) f_res = − E_r(s) f_ext − Σ_{k=1}^{ORD-1} G_{r,k}(s) ξ_k

with

Ĵ_r(s)   = Σ_{j=1}^{ORD} J_r[j] · s^{j-1}          (1 × FOM row on W)
G_{r,k}(s) = Σ_{j=k+1}^{ORD} J_r[j] · s^{j-1-k}     (Horner tails of Ĵ_r)
C_r(s)   = Σ_{k=1}^{ORD-1} G_{r,k}(s) · Y_k^m       (coupling to unknown f_res)
E_r(s)   = Σ_{k=1}^{ORD-1} G_{r,k}(s) · Y_k^e       (known forcing → RHS)

Only the resonant master columns of C_r carry a value; the rest are written as zeros, since the matching trivial rows pin those reduced-dynamics coefficients to zero anyway.


Coefficients from eigenvector order-blocks (no eigenvalue folding)

The row coefficients are read directly off the (conjugated) left eigenvector order-blocks — no eigenvalue appears:

J_r[j]   = conj(φ_{r,j})              j = 1, …, ORD-1
J_r[ORD] = conj(B_ORDᴴ φ_{r,ORD})

The conjugation is the sesquilinear ᴴ of the condition; it is baked into the stored coefficients so that every assembled contraction (row · W, row · ξ, row · Y) is bilinear.

C_r/E_r contract the same tails G_{r,k} against the right eigenmode order-blocks: master blocks come from the eigensolver, external blocks from the recurrence Y_{k+1}^e = Y_k^e Λ_e + Y_k^m Λ_me (the master↔external coupling of the reduced linear dynamics — the one confined place an eigenvalue-derived matrix remains).


Precomputation and assembly

J_r, C_r and E_r coefficients are pre-computed once per order. At assembly time, only a subset is used:

  • LHS matrix [Ĵ_r Ĉ_r]: the columns of C_r(s) for non-resonant master modes are zeroed rather than omitted, which keeps the block shape independent of the resonance pattern. Nothing is lost — those coefficients are pinned to zero by their own trivial rows.

  • RHS scalar: only the N_EXT_active non-zero external forcing entries of E_r(s) contribute; their values are multiplied by external_dynamics and accumulated into RHS_r.


Right-hand-side assembly

RHS_r is the sum of two scalar contributions:

Lower-order RHS

During the Horner evaluation of Ĵ_r(s), the intermediate row vectors

Ĵ_r[j](s) = Σ_{k=j+1}^{ORD} J_r[k] · s^{k-(j+1)},   j = 1, …, ORD-1

are naturally available. Dotting each with the pre-computed coupling vector ξ[j] gives a scalar contribution:

RHS_lower_r = -Σ_{j=1}^{ORD-1} Ĵ_r[j](s) · ξ[j]

This accumulation is performed in the same Horner loop that computes Ĵ_r(s), avoiding recomputation of the Ĵ_r[j] intermediates.

External forcing RHS

For external forcing modes e = 1, …, N_EXT, the scalar-valued polynomial

E_r_e(s) = Σ_{j=1}^{ORD-1} E_coeffs[r][j, e] · s^{j-1}

gives the contribution of mode e to RHS_r when multiplied by external_dynamics[e]. The total external contribution is

RHS_ext_r = -Σ_{e active} external_dynamics[e] · E_r_e(s)

Only active (non-zero) external modes are processed. Their contributions are combined into a single scalar Horner pass, avoiding per-mode evaluations.

The complete right-hand side is therefore

RHS_r = RHS_lower_r + RHS_ext_r

Full system assembly

Stacking one row per master mode — resonant or not — yields the global linear system

[ P Ĵ   P Ĉ P + τ Q ] · [ W; f ] = P · RHS_R,      P = diag(resonance), Q = I − P

where:

  • Ĵ is ROM × FOM (rows Ĵ_r), kept only on resonant rows,

  • Ĉ is ROM × ROM (columns of C_r from the joint operator), masked on both axes,

  • f is the full ROM-vector of reduced-dynamics coefficients,

  • RHS_R is the assembled ROM-vector of scalar right-hand sides,

  • τ = 1 on the non-resonant diagonal, turning row r into f_r = 0.

The block size is therefore independent of how many modes are resonant, which is what allows the sparse cohomological solver to hold one sparsity pattern — and one cached symbolic factorisation — for the whole solve. Masking loses nothing: the dropped C entries multiply coefficients that the trivial rows pin to zero.


Module contents

FunctionDescription
precompute_orthogonality_operator_coefficientsPre-compute J_r coefficient arrays for the orthogonality row operators Ĵ_r(s)
precompute_orthogonality_column_polynomialsPre-compute Q_r coefficient arrays split into C_coeffs and E_coeffs
evaluate_orthogonality_row_and_lower_order_rhs!Fused Horner pass for Ĵ_r(s) (row) + scalar lower-order RHS
evaluate_orthogonality_column_row!Evaluate C_r(s) into one row of the C block, masked by the resonance vector
evaluate_orthogonality_external_rhsCompute the scalar external-forcing RHS for mode r
assemble_orthogonality_matrix_and_rhs!Constant-size ROM × (FOM+ROM) block and RHS assembly (in-place)
function assemble_orthogonality_matrix_and_rhs!(M::AbstractMatrix, rhs::AbstractVector, s::T, J_coeffs::AbstractVector{<:AbstractMatrix{T}}, C_coeffs::Vector{<:AbstractMatrix{T}}, E_coeffs::Vector{<:AbstractMatrix{T}}, resonance::StaticArraysCore.SVector{ROM, Bool}, lower_order_couplings::AbstractVector{<:AbstractVector{T}}, external_dynamics::AbstractVector{T}) where {T, ROM}
assemble_orthogonality_matrix_and_rhs!(M, rhs, s, J_coeffs, C_coeffs, E_coeffs,
										resonance, lower_order_couplings,
										external_dynamics) → nothing

In-place variant: writes the orthogonality block and its RHS directly into the caller-supplied M (ROM × (FOM+ROM)) and rhs (length ROM) buffers. No heap allocation occurs.

The block has constant size — one row per master mode, resonant or not — and the resonance vector selects each row's content rather than the block's dimensions:

  • resonant r: row r is the orthogonality condition Ĵ_r(s) W_α + Σ_m Ĉ_{rm}(s) R_{m,α} = g_{r,α}, with the corner entries masked to the resonant modes by evaluate_orthogonality_column_row!;

  • non-resonant r: row r becomes the trivial equation τ R_{r,α} = 0 — everything zeroed except M[r, FOM+r] = τ = 1 — which encodes the style choice that non-resonant reduced-dynamics coefficients vanish.

Keeping the width constant is what lets the sparse path reuse one symbolic factorisation across every monomial. Note that τ is structural only: the solver never reads the trivial rows back out of the solution vector, it writes the hard zeros directly during unpacking.

function evaluate_orthogonality_column_row!(c::AbstractVector{T}, s::T, r::Int64, C_coeffs::Vector{<:AbstractMatrix{T}}, resonance::StaticArraysCore.SVector{ROM, Bool}) where {T, ROM}
evaluate_orthogonality_column_row!(c, s, r, C_coeffs, resonance) -> c

Evaluate the joint operator row C_r(s) in-place via Horner's method, overwriting the pre-allocated length-ROM vector c.

C_r(s) = Σ_{j=1}^{ORD-1} C_coeffs[r][j, :] · s^{j-1} is a 1 × ROM row polynomial. Entry c[j] holds C_r(s)[j] when master mode j is resonant and zero(T) otherwise — i.e. the layout is expanded and masked, indexed by the mode j itself rather than compacted into resonant rank order.

Masking rather than compacting is what keeps the bordered cohomological system at the constant size FOM + ROM for every monomial (one sparsity pattern, one cached symbolic factorisation). Dropping the non-resonant entries is lossless: the matching orthogonality row pins R_{j,α} = 0, so those coefficients multiply zero.

c may be a plain Vector{T} or a row view view(M, row, col_range).

Horner recurrence (column-wise, no allocation)

For each resonant column index j independently:

val  ←  C_coeffs[r][ORD-1, j]
for L = ORD-2, …, 1:
	val ← val · s + C_coeffs[r][L, j]
c[j] ← val

Column j of C_coeffs[r] is contiguous in memory (Julia is column-major), so each per-column Horner pass is cache-friendly.

Arguments

  • c :: AbstractVector{T} – output buffer (length ROM), fully overwritten: resonant entries with C_r(s), the rest with zero.

  • s :: T – evaluation frequency.

  • r :: Int – 1-based master-mode index for the row equation (1 ≤ r ≤ ROM).

  • C_coeffs :: Vector{<:AbstractMatrix{T}} – pre-computed coefficients from precompute_orthogonality_column_polynomials; C_coeffs[r] is (ORD-1) × ROM.

  • resonance :: SVector{ROM, Bool} – resonance[j] is true iff master mode j is resonant at the current multi-index.

Complexity

O((ORD-1) · |R|), with no heap allocation.

function evaluate_orthogonality_external_rhs(s::T, r::Int64, external_dynamics::AbstractVector{T}, E_coeffs::Vector{<:AbstractMatrix{T}}) where T
evaluate_orthogonality_external_rhs(s, r, external_dynamics, E_coeffs) -> T

Compute the scalar external-forcing contribution to the right-hand side of the orthogonality equation for master mode r:

RHS_ext_r = -Σ_{e active} external_dynamics[e] · E_r_e(s)

where E_r_e(s) = Σ_{j=1}^{ORD-1} E_coeffs[r][j, e] · s^{j-1} is the scalar polynomial for forcing mode e in the row equation for mode r (pre-computed by precompute_orthogonality_column_polynomials).

The negative sign reflects that these terms are moved from the left-hand side of the cohomological equation to the right-hand side.

Sparse exploitation

Only the non-zero entries of external_dynamics are processed. For periodic forcing of a few harmonics this is typically a small subset of N_EXT.

Combined Horner pass

The non-zero contributions are combined into a single scalar polynomial

g(s) = Σ_{e active} external_dynamics[e] · E_r_e(s)

and evaluated in one Horner pass (ORD-2 scalar multiplies and ORD-2 · N_EXT_active scalar additions), instead of N_EXT_active separate Horner passes.

Arguments

  • s :: T – evaluation frequency.

  • r :: Int – 1-based master-mode index (1 ≤ r ≤ ROM).

  • external_dynamics :: AbstractVector{T} – known amplitudes of the N_EXT external forcing modes; typically sparse.

  • E_coeffs :: Vector{<:AbstractMatrix{T}} – pre-computed coefficients from precompute_orthogonality_column_polynomials; E_coeffs[r] is (ORD-1) × N_EXT.

Returns

The scalar RHS_ext_r = -g(s).

Complexity

O(N_EXT_active · (ORD-1)) for combining coefficients plus O(ORD-1) for the single Horner evaluation.

function evaluate_orthogonality_row_and_lower_order_rhs!(row::AbstractVector{T}, s::T, lower_order_couplings::AbstractVector{<:AbstractVector{T}}, J_coeffs_r::AbstractMatrix{T}) where T
evaluate_orthogonality_row_and_lower_order_rhs!(row, s,
												lower_order_couplings,
												J_coeffs_r)
-> scalar_rhs :: T

Evaluate the orthogonality row operator Ĵ_r(s) and compute the scalar lower-order right-hand-side contribution for mode r in a single Horner pass, reusing the transient intermediate row vectors.

Mathematical context

At step j of the Horner recurrence (before the scalar multiply by s), the intermediate row vector

Ĵ_r[j](s) = Σ_{k=j+1}^{ORD} J_r[k, :] · s^{k-(j+1)}

is available. Contracting bilinearly with the pre-computed coupling vector ξ[j] gives the scalar contribution of lower-order solution terms at step j:

contribution[j] = -Ĵ_r[j](s) · ξ[j] = -Σᵢ Ĵ_r[j](s)ᵢ · ξ[j]ᵢ

The contraction must not conjugate: the sesquilinear conjugation of the orthogonality condition is already baked into the row coefficients J_r (see precompute_orthogonality_operator_coefficients), so row holds the Horner tail G_{r,j}(s) with the ᴴ applied. The negative sign arises because these terms originate from the left-hand side of the cohomological equation. Summed over j = 1, …, ORD-1:

RHS_lower_r = -Σ_{j=1}^{ORD-1} Ĵ_r[j](s) · ξ[j]

The sum runs to ORD-1 (one fewer than in InvarianceEquation) because the joint operator Q_r has one fewer degree. Sharing the loop with the Ĵ_r(s) evaluation avoids recomputing the Ĵ_r[j] intermediates.

Arguments

  • row :: AbstractVector{T} – output buffer (length FOM), overwritten with Ĵ_r(s) = Σ_{j=1}^{ORD} J_r[j, :] · s^{j-1}.

  • s :: T – evaluation superharmonic.

  • lower_order_couplings :: SVector{ORD_M1, <:AbstractVector{T}} – coupling vectors ξ[j] for j = 1, …, ORD-1; each is a length-FOM vector.

  • J_coeffs_r :: AbstractMatrix{T} – ORD × FOM matrix; row j is J_r[j, :], the degree-(j-1) coefficient of Ĵ_r. Obtained from precompute_orthogonality_operator_coefficients.

Returns

The scalar lower-order RHS accumulation RHS_lower_r = -Σ_{j=1}^{ORD-1} Ĵ_r[j](s) · ξ[j].

Complexity

O(ORD · FOM), shared with the Ĵ_r(s) evaluation.

function precompute_orthogonality_column_polynomials(J_coeffs::AbstractVector{<:AbstractMatrix}, right_master_blocks::AbstractArray{<:Number, 3}, external_directions::AbstractMatrix, reduced_dynamics_linear::AbstractMatrix)
precompute_orthogonality_column_polynomials(J_coeffs, right_master_blocks,
											external_directions,
											reduced_dynamics_linear)
-> (C_coeffs, E_coeffs)

Pre-compute the coefficient arrays of the operators C_r(s) (coupling to the unknown reduced dynamics) and E_r(s) (known external forcing) that appear in the orthogonality equation for master mode r:

J_r(s) W + C_r(s) f_m = − E_r(s) f_e − Σ_k G_{r,k}(s) ξ_k

Mathematical origin

With G_{r,k}(s) = Σ_{j=k+1}^{ORD} J_r[j, :] s^(j-1-k) the Horner tails of the row operator, the couplings are bilinear contractions against the right eigenmode order-blocks Y_k (Y_1 = physical mode, Y_{k+1} = next derivative block):

C_r(s) = Σ_{k=1}^{ORD-1} G_{r,k}(s) · Y_k^m        (master blocks, from the eigensolver)
E_r(s) = Σ_{k=1}^{ORD-1} G_{r,k}(s) · Y_k^e        (external blocks)

The master blocks are supplied directly (right_master_blocks). The external blocks are generalised — the reduced linear dynamics couples them back to the master modes — and are generated by the block recurrence

Y_1^e = Φ_ext,       Y_{k+1}^e = Y_k^e Λ_e + Y_k^m Λ_me

where Λ_me = Λ[1:ROM, ROM+1:NVAR] and Λ_e = Λ[ROM+1:NVAR, ROM+1:NVAR] are the master↔external and external blocks of reduced_dynamics_linear. This is the one place an eigenvalue-derived matrix remains, confined to the per-order precompute.

All contractions are bilinear (Σᵢ J_r[·,i] Y[i,·]): the sesquilinear conjugation of the orthogonality condition is already baked into J_coeffs.

Arguments

  • J_coeffs :: Vector{<:AbstractMatrix{T}} – output of precompute_orthogonality_operator_coefficients; J_coeffs[r] is ORD × FOM.

  • right_master_blocks :: AbstractArray{T,3} – right master-mode order-blocks, size FOM × ORD × ROM; right_master_blocks[:, k, m] = Y_k^m[:, m] (equal to the linear master monomials of the parametrisation W). Only blocks k ≤ ORD-1 are used.

  • external_directions :: AbstractMatrix{T} – physical external directions Φ_ext, size FOM × N_EXT.

  • reduced_dynamics_linear :: AbstractMatrix{T} – NVAR × NVAR linear reduced dynamics; only the Λ_me and Λ_e blocks are read.

Return values

  • C_coeffs :: Vector{Matrix{T}} of length ROM; C_coeffs[r] is (ORD-1) × ROM, row p = degree-(p-1) coefficient of C_r(s).

  • E_coeffs :: Vector{Matrix{T}} of length ROM; E_coeffs[r] is (ORD-1) × N_EXT, row p = degree-(p-1) coefficient of E_r(s).

When ORD == 1 both matrices have zero rows and the operators are identically zero (the corresponding blocks are absent from the assembled system).

Complexity

  • Time: O(ROM² · ORD² · FOM) for the contractions plus O(ORD · FOM · NVAR · N_EXT) for the external block recurrence.

  • Storage: O(ROM · ORD · NVAR)

function precompute_orthogonality_operator_coefficients(fom_matrices::NTuple{ORDP1, var"#s413"} where var"#s413"<:(AbstractMatrix), left_eigenmodes::AbstractMatrix) where ORDP1 precompute_orthogonality_operator_coefficients(fom_matrices::NTuple{ORDP1, var"#s412"} where var"#s412"<:(AbstractMatrix), left_eigenmodes::AbstractMatrix, left_modes_derivatives::Union{Nothing, AbstractArray{<:Number, 3}}) where ORDP1
precompute_orthogonality_operator_coefficients(fom_matrices, left_eigenmodes,
											   left_modes_derivatives = nothing)
-> Vector{Matrix{T}}

Pre-compute the polynomial coefficient arrays for the orthogonality row operators J_r(s) directly from the left-eigenvector order-blocks. No eigenvalue is used.

Mathematical origin

The orthogonality condition for master mode r at a monomial with superharmonic s is the sesquilinear B-orthogonality of the companion left eigenvector φ_r = [φ_{r,1}; …; φ_{r,ORD}] (defined by φ_rᴴ (λ_r B − A) = 0) against the first-order state 𝒲:

φ_rᴴ B 𝒲 = 0,     B = blockdiag(I, …, I, B_ORD)

Reducing block-by-block, the row acting on the physical unknown W is

J_r(s) = Σ_{j=1}^{ORD} J_r[j, :] · s^(j-1)

with coefficients read straight off the (conjugated) eigenvector blocks:

J_r[j, :]   = conj(φ_{r,j})          j = 1, …, ORD-1
J_r[ORD, :] = conj(B_ORDᴴ φ_{r,ORD}) = conj(B_ORDᴴ ℓ_r)

The conjugation is the sesquilinear ᴴ of the condition, stored so that the assembled matrix row acts bilinearly on W (row · W = Σᵢ rowᵢ Wᵢ).

Arguments

  • fom_matrices :: NTuple{ORD+1, <:AbstractMatrix{T}} – linear matrices of the full-order model; fom_matrices[k+1] corresponds to B_k (0-indexed).

  • left_eigenmodes :: AbstractMatrix{T} – physical-space (highest-order) left eigenvector slice; left_eigenmodes[:, r] is ℓ_r = φ_{r,ORD} (length FOM).

  • left_modes_derivatives :: Union{Nothing, AbstractArray{T,3}} – lower-order left eigenvector blocks, size FOM × (ORD-1) × ROM; left_modes_derivatives[:, j, r] = φ_{r,j}. Required when ORD > 1 (the eigensolver returns them; see solve_left). May be nothing for ORD == 1.

Return value

A Vector{Matrix{T}} of length ROM; entry r is the ORD × FOM matrix J_coeffs[r] whose row j stores the degree-(j-1) coefficient of J_r(s).

Complexity

  • Time: one B_ORDᴴ · ℓ_r product per mode — O(ROM · FOM²) dense (O(ROM · nnz) sparse); the remaining blocks are copies.

  • Storage: O(ROM · ORD · FOM)

Module ParametrisationObjects — the coefficient containers of the DPIM parametrisation, and the contract their multiindex set must satisfy.

Defines the two objects that together represent the invariant manifold and the reduced dynamics:

Also provides create_parametrisation_method_objects (allocates both objects for a given MultiindexSet), compute_higher_derivative_coefficients! (fills the derivative-order slices of W from the solved first-order slice and the reduced dynamics), and validate_multiindex_set (the five-clause mset contract).

Why this is a module of its own

CohomologicalEquations needs exactly these definitions, while the user-facing ParametrisationMethod — which owns parametrise — needs CohomologicalEquations. Splitting the containers out breaks what would otherwise be a circular module dependency, and lets ParametrisationMethod load last and simply call the solver. Everything here is re-exported by ParametrisationMethod, so MORFE.ParametrisationMethod.ReducedDynamics and friends keep resolving.

Parametrisation{ORD, NVAR, T}

A dense polynomial with a contiguous (FOM, ORD, L) coefficient array. Represents a parametrisation mapping from reduced coordinates and forcing variables to the full state.

  • ORD: native order of the full ODE (1 for first‑order, 2 for second‑order).

  • NVAR: total number of variables = reduced coordinates + forcing variables.

  • T: numeric element type (e.g., ComplexF64).

Layout: coefficients[:, ord, l] is the full‑state vector (length FOM) for the ord-th time derivative of the l-th monomial coefficient.

Fields

  • poly::DensePolynomial{T, NVAR, 3, Array{T, 3}} — the coefficient array in the layout above, together with the multiindex set it is aligned to.

  • external_system_size::Int — how many of the NVAR variables are external forcing amplitudes rather than reduced coordinates. The reduced dimension is the remainder, so this is what separates the master block from the forcing block when slicing the coefficients.

ReducedDynamics{ROM, NVAR, T}

A dense polynomial whose coefficients are SVector{ROM, T}. Represents the reduced dynamics on a manifold of dimension ROM.

  • ROM: dimension of the reduced state (first‑order system).

  • NVAR: total number of variables = ROM + externalsystemsize.

  • T: numeric type.

The dynamics are: ż = R(z, r), where r are the forcing variables.

Fields

  • poly::DensePolynomial{T, NVAR, 2, Matrix{T}} — coefficients as a NVAR × L matrix aligned to the multiindex set. Rows 1:ROM are solved for; the trailing external_system_size rows hold the known forcing amplitudes.

  • external_system_size::Int — number of forcing variables, fixing where the master rows end and ROM = NVAR - external_system_size.

function compute_higher_derivative_coefficients!(param_coeff::AbstractArray{T, 3}, red_coeff::AbstractMatrix{T}, external_dynamics::AbstractVector{T}, superharmonic::T, global_index::Int64, generalised_eigenmodes::AbstractMatrix{T}, lower_order_couplings::AbstractVector{<:AbstractVector{T}}) where T
compute_higher_derivative_coefficients!(
	param_coeff, red_coeff, external_dynamics, superharmonic, global_index,
	generalised_eigenmodes, lower_order_couplings
) -> nothing

Compute the higher time‑derivative coefficients W^(j+1)[α] for j = 1 … ORD-1 using the superharmonic recurrence

W^(j+1)[α] = s · W^(j)[α]  +  Φ_master · R[α]  +  Φ_ext · e_dyn  +  ξ[j]

where:

  • s = superharmonic is the frequency ⟨λ, α⟩,

  • Φ = generalised_eigenmodes (FOM × NVAR) collects the right eigenmodes,

  • R[α] = red_coeff[:, global_index] (ROM‑vector) contains the master‑mode reduced‑dynamics coefficients at the current monomial (already solved),

  • e_dyn = external_dynamics (N_EXT‑vector) contains the known external dynamics at the current monomial,

  • ξ[j] = lower_order_couplings[j] (FOM‑vector) contains the coupling from lower‑order monomials at derivative order j.

Modifies param_coeff in‑place. Does nothing when ORD = 1 (no higher derivatives exist for a first‑order ODE).

Arguments

  • param_coeff :: AbstractArray{T, 3} — shape FOM × ORD × L; the coefficient tensor of the parametrisation polynomial.

  • red_coeff :: AbstractMatrix{T} — shape ROM × L; master‑mode reduced‑dynamics coefficients.

  • external_dynamics :: AbstractVector{T} — length N_EXT; known external dynamics at the current monomial.

  • superharmonic :: T — scalar s = ⟨λ, α⟩.

  • global_index :: Int — monomial index into the last axis of param_coeff and the last axis of red_coeff.

  • generalised_eigenmodes :: AbstractMatrix{T} — shape FOM × NVAR; right generalised eigenvectors (master modes in columns 1:ROM, external modes in ROM+1:NVAR).

  • lower_order_couplings :: AbstractVector{<:AbstractVector{T}} — length ORD; element j is a length‑FOM vector ξ[j] produced by LowerOrderCouplings.compute_lower_order_couplings.

function create_parametrisation_method_objects(mset::MultiindexSet{NVAR}, ORD::Int64, FOM::Int64, ROM::Int64, external_system_size::Int64) where NVAR create_parametrisation_method_objects(mset::MultiindexSet{NVAR}, ORD::Int64, FOM::Int64, ROM::Int64, external_system_size::Int64, ::Type{T}) where {T<:Number, NVAR} create_parametrisation_method_objects(mset::MultiindexSet{NVAR}, ORD::Int64, FOM::Int64) where NVAR create_parametrisation_method_objects(mset::MultiindexSet{NVAR}, ORD::Int64, FOM::Int64, ::Type{T}) where {T<:Number, NVAR}
create_parametrisation_method_objects(mset::MultiindexSet{NVAR}, ORD::Int, FOM::Int, ROM::Int, external_system_size::Int, ::Type{T}=Complex)

Create a consistent pair of polynomials:

Both polynomials share the same multiindex set mset and element type T. The total number of variables NVAR must satisfy NVAR == ROM + external_system_size. FOM is the full‑order dimension (size of the state vector). It is not stored but used to initialise the coefficient vectors correctly.

Arguments

  • mset: multiindex set for NVAR variables.

  • ORD: native order of the full ODE (1 or 2).

  • FOM: dimension of the full‑order state in its native order.

  • ROM: dimension of the reduced state.

  • external_system_size: number of forcing variables (default 0).

  • T: element type.

function restrict_Parametrisation_to_degree(W::Parametrisation, max_degree::Int64)
function restrict_Parametrisation_to_degree(W::Parametrisation, max_degree::Int) -> Parametrisation

Returns a new Parametrisation that contains all the monomials of poly that are of degree lower or equal than max_degree.

function restrict_ReducedDynamics_to_degree(R::ReducedDynamics, max_degree::Int64)
function restrict_ReducedDynamics_to_degree(R::ReducedDynamics, max_degree::Int) -> ReducedDynamics

Returns a new ReducedDynamics that contains all the monomials of poly that are of degree lower or equal than max_degree.

function validate_multiindex_set(mset::MultiindexSet{N}, nvar::Int64, rom::Int64; conjugate_permutation) where N
validate_multiindex_set(mset, nvar, rom; conjugate_permutation = nothing)

Check a custom multiindex set against everything the cohomological solve assumes, and throw an ArgumentError naming the offending exponent on the first violation.

The clauses, and why each one matters:

  1. nvar variables, matching ROM + N_EXT.

  2. Minimum total degree ≥ 1 — the expansion is centred on the fixed point, so the constant monomial has no coefficient to solve for.

  3. Every unit multiindex eᵢ — the linear part of the parametrisation is initialised from the eigenvectors, one column per unit multiindex.

  4. Downward closed — the graded solve reads W[α - β + eᵢ] while working on α and factorises α = β₁ + … + β_d over members of mset. A missing divisor is not an error at run time: it is read as zero, silently corrupting the right-hand side.

  5. With a conjugate_permutation: that it is an involutive permutation of 1:nvar mapping 1:rom into itself, and that mset is closed under it. A member whose partner is absent is solved directly rather than filled by conjugation, so the result loses the conjugate structure the permutation asserts.

Returns nothing. Cost is O(nvar · |mset| · log|mset|) — negligible beside a solve, but parametrise and solve_parametrisation both take a validate_mset = false escape hatch for callers that have already checked.

See is_downward_closed and is_conjugate_closed for the two closure predicates on their own.

Module MultilinearTerms — efficient evaluation of the nonlinear right-hand side of the cohomological equations.

For each monomial α the nonlinear contribution is

Σₜ  Σ_{β₁+…+βₖ=α}  multiplier · t.f!(W[β₁], …, W[βₖ], r₁, …, rₘ)

where the outer sum runs over all nonlinear terms t of the model and the inner sum enumerates factorisations of α into k sub-exponents from already-computed W columns. This module provides two evaluation paths:

For FEM-backed terms (FEMMultilinearMap) an additional O4 combined element loop merges all me=0 FEM contributions into a single mesh traversal per monomial, avoiding redundant fem_reinit! and scatter_qp! calls.

Three symmetry strategies (FullyAsymmetric, FullySymmetric, GroupwiseSymmetric) are dispatched at compile time from the MultilinearMap.multiindex field.

CachedSplit

Precomputed bookkeeping for one (monomial l, term t, external-split) triple.

Fields

  • ext_count::Int — multiplicity of this external-variable split (from bounded_index_tuples); always 1 when me = 0.

  • args_ext_indices::Vector{Int} — indices into the cache's external_arguments that reconstruct the external forcing arguments; empty when me = 0.

  • is_asymmetric::Bool — true iff the term is FullyAsymmetric, in which case replaying it needs no scratch buffer.

  • orders::Vector{Int} — derivative index for each factor slot; length is the internal degree of the term.

  • entries::Vector{FactorisationEntry} — one entry per factorisation of the remainder exponent, each carrying its own symmetry multiplier.

FEMCachedSplit{DEG, ME}

Precomputed bookkeeping for one (monomial, FEM-term, external-split) triple.

DEG is the internal degree (t.deg - me) and ME the external multiplicity, so accumulate_qp! is called with DEG + ME == t.deg arguments. ME is a type parameter so the external arguments are appended with a compile-time-known count in _replay_fem_split!.

Fields

  • ext_count::Int — external multiplicity; 1 when me = 0.

  • args_ext_indices::Vector{Int} — indices into the cache's external_arguments that reconstruct the external arguments; empty when me = 0.

  • unique_cols::Vector{Tuple{Int, Int}} — deduplicated (derivative_order, W_col_idx) pairs across every entry in this split. Deduplication is what makes the batching pay: each pair is scattered to quadrature-point gradients once per element, however many factorisations reference it.

  • fem_entries::Vector{FEMFactorisationEntry{DEG}} — one entry per factorisation, indexing into unique_cols rather than into W directly. Keyed on the internal degree alone, since it only indexes unique_cols.

FEMFactorisationEntry{DEG}

One factorisation entry for the FEM-batched path.

Fields

  • multiplier::Int — symmetry count, as in FactorisationEntry.multiplier.

  • local_factor_indices::NTuple{DEG, Int} — for each factor slot, the index into the enclosing FEMCachedSplit.unique_cols. An NTuple rather than a Vector so ntuple(k -> …, Val(DEG)) in the hot loop unrolls at compile time.

FEMGlobalEntry{DEG}

One factorisation entry in the combined element loop for a given monomial.

Fields

  • term_idx::Int — index into model.nonlinear_terms. Present here but not in FEMFactorisationEntry because the combined loop mixes terms.

  • multiplier::Int — symmetry count, as in FEMFactorisationEntry.

  • local_factor_indices::NTuple{DEG, Int} — indices into the enclosing FEMGlobalSplit.global_unique_cols.

FEMGlobalSplit{ENTRIES_TUPLE}

All combined-loop bookkeeping for one monomial, covering all me=0 FEM terms.

Fields

  • global_unique_cols::Vector{Tuple{Int, Int}} — deduplicated (derivative_order, W_col_idx) pairs across all me = 0 FEM terms and their splits, scattered once per element. Deduplicating across terms, not just within one, is what the combined loop buys over FEMCachedSplit.

  • entries_by_deg::ENTRIES_TUPLE — one Vector{FEMGlobalEntry{D}} per degree D present, held in a typed tuple so the inner loop dispatches type-stably on DEG.

  • driver_term_idx::Int — the FEM term whose element iterator drives the loop; 0 when this monomial has no me = 0 FEM terms.

  • participating_term_indices::Vector{Int} — sorted distinct term_idx values appearing in entries_by_deg, so assembly touches only the terms involved instead of scanning all of them.

FullyAsymmetric <: SymmetryType

Tag for terms whose factor slots all use distinct derivatives (multiindex has no repeated entry > 1). No scratch buffer is needed; t.f! writes directly into the accumulator.

FullySymmetric <: SymmetryType

Tag for terms where all factor slots share a single derivative order (exactly one positive entry in multiindex). Each factorisation carries a symmetry count.

GroupwiseSymmetric <: SymmetryType

Tag for terms whose factor slots span multiple derivative orders (multiple positive entries in multiindex). Uses factorisations_groupwise_symmetric with a combined per-group symmetry count.

MultilinearTermsCache{T, QP, EV}

All precomputed factorisation bookkeeping for a given (model, parametrisation) pair. splits[l][t_idx] is the list of CachedSplit values for monomial l and term t_idx; an empty list means the term degree exceeds the monomial degree and it contributes nothing.

Type parameters:

  • T — element type of FOM-length vectors (e.g. ComplexF64)

  • QP — element type of the combined qp gradient buffer global_∇W_qp

	  (e.g. `Tensor{2,3,ComplexF64}` for Ferrite SVK; `Nothing` when no FEM terms)
  • EV — element type of external_arguments, i.e. SVector{N_EXT, Int} normally and

	  `SVector{N_EXT, eltype(Q)}` when the external system was re-based.  A type
	  parameter so the hot loop's argument tuple is inferred rather than `Any`.

Build once before the solve loop with build_multilinear_terms_cache. Use by passing to the (model, exp_index, parametrisation, cache) overload of compute_multilinear_terms inside the loop. The cache is valid as long as the multiindex set and the model structure are unchanged (i.e. across all solve steps).

Fields

Three parallel split representations coexist because a model may mix closure-based and FEM-backed nonlinear terms, and the FEM ones are far cheaper to evaluate batched over elements than term by term:

  • splits::Vector{Vector{Vector{CachedSplit}}} — the generic path. splits[l][t_idx] lists the CachedSplit values for monomial l and term t_idx; empty when the term degree exceeds the monomial degree.

  • fem_splits::Vector{Vector{Vector{Any}}} — the same indexing for FEM terms, holding FEMCachedSplit{DEG, ME} values, empty for closure terms. Typed Any because DEG varies per term; reached through a function barrier so the hot loop stays type-stable.

  • global_fem_splits::Vector{Any} — one FEMGlobalSplit per monomial, fusing every FEM term into a single element loop. Any for the same reason.

Buffers, all reused across monomials to keep the solve loop allocation-free:

  • result_buffer::Vector{T} — length FOM, accumulates the nonlinear contribution returned to the caller.

  • scratch_buffer::Vector{T} — length FOM, working space for symmetric terms; unused when a term is FullyAsymmetric.

  • temp_buffer::Vector{T} — length FOM, holds one intermediate contraction.

  • external_arguments::Vector{EV} — the external argument passed for each external variable, used to rebuild the external forcing arguments; empty when N_EXT == 0. These are the unit vectors eⱼ in the model's own coordinates, or the columns Q[:, j] of the change of basis when the external system was re-based — see ExternalSystems.external_argument_vectors. Applying Q here, once per solve, is why no term ever has to know about the change of coordinates.

  • fem_Fe::Vector{T} — element-local residual for the external-multiplicity fallback path, sized to the largest ndofs_per_cell in the model.

  • global_∇W_qp::Matrix{QP} — shared quadrature-point gradient buffer, max_global_unique × max_n_qp. QP is a type parameter precisely so element access inside the hot loop is statically typed.

  • global_Fe_buffers::Vector{Vector{T}} — per-term element residual buffers, sized to each FEM term's fem_ndofs_per_cell; empty for closure terms.

SymmetryType

Abstract tag type used to dispatch the inner factorisation accumulation strategy at compile time. The three concrete subtypes correspond to the three cases that arise from MultilinearMap.multiindex:

  • FullyAsymmetric — all entries ≤ 1; each factor slot uses a different derivative.

  • FullySymmetric — exactly one entry > 1; all slots share one derivative.

  • GroupwiseSymmetric — multiple entries > 0; slots span several derivatives.

function _accumulate_global_entries!(::Any, ::Any, ::Tuple{}, ::Any, ::Any, ::Any, ::Any) _accumulate_global_entries!(Fe_bufs, ∇W_qp, entries_by_deg::Tuple{Array{MORFE.MultilinearTerms.FEMGlobalEntry{DEG}, 1}, Vararg}, model, element, q, dΩ) where DEG
_accumulate_global_entries!(Fe_bufs, ∇W_qp, entries_by_deg, model, element, q, dΩ)

Recursive type-stable dispatch over the degree-grouped entry tuple. The base case (empty tuple) is a no-op. Each recursive step processes the head degree group — Julia specialises a method per DEG so Val(DEG) in ntuple and the accumulate_qp! dispatch are resolved at compile time — then recurses on the tail.

function _accumulate_split!(accum, _scratch, t, ::MORFE.MultilinearTerms.FullyAsymmetric, W, set, rem, deg, candidate_indices, args_ext) _accumulate_split!(accum, scratch, t, ::MORFE.MultilinearTerms.FullySymmetric, W, set, rem, deg, candidate_indices, args_ext) _accumulate_split!(accum, scratch, t, ::MORFE.MultilinearTerms.GroupwiseSymmetric, W, set, rem, deg, candidate_indices, args_ext)
_accumulate_split!(accum, scratch, t, sym, W, set, rem, deg, candidate_indices, args_ext)

Accumulate contributions from one (monomial, external-split) pair into accum, dispatching on the SymmetryType tag sym.

function _build_fem_cached_split(::Val{DEG}, ::Val{ME}, cs::MORFE.MultilinearTerms.CachedSplit) where {DEG, ME}
_build_fem_cached_split(::Val{DEG}, ::Val{ME}, cs) -> FEMCachedSplit{DEG, ME}

Convert a CachedSplit to a FEMCachedSplit{DEG, ME} for the FEM-batched replay path. Deduplicates (derivative_order, W_col_idx) pairs across all entries and remaps each entry's factor slots to local indices into the unique_cols table. DEG is the internal degree and ME the external multiplicity, carried as a type parameter so _replay_fem_split! can append the external arguments with a compile-time-known count. Called once at cache-build time; produces zero allocations in the hot path.

function _build_global_fem_split(model, fem_splits_l, fem_term_indices)
_build_global_fem_split(model, fem_splits_l, fem_term_indices) -> FEMGlobalSplit

Build the O4 combined-element-loop bookkeeping for one monomial, merging all me=0 FEM terms into a single FEMGlobalSplit.

  • Deduplicates (order, col) pairs across all me=0 FEM splits into a global table.

  • Remaps each FEMFactorisationEntry's local indices to global table indices.

  • Groups entries by degree into a typed Tuple for type-stable compile-time dispatch.

  • Returns an empty FEMGlobalSplit{Tuple{}} when no me=0 FEM terms are present.

function _collect_entries(::MORFE.MultilinearTerms.FullyAsymmetric, t, mset, rem, deg, cands) _collect_entries(::MORFE.MultilinearTerms.FullySymmetric, t, mset, rem, deg, cands) _collect_entries(::MORFE.MultilinearTerms.GroupwiseSymmetric, t, mset, rem, deg, cands)
_collect_entries(sym, t, mset, rem, deg, cands) -> Vector{FactorisationEntry}

Call the appropriate factorisation function for cache construction, routing FullyAsymmetric to factorisations_asymmetric, FullySymmetric to factorisations_fully_symmetric, and GroupwiseSymmetric to factorisations_groupwise_symmetric.

function _derivative_orders(t::AbstractMultilinearMap)
_derivative_orders(t) → NTuple

Map each factor slot to its 1-based derivative index. Example: multiindex = (2, 1) → (1, 1, 2).

function _orders_for_cache(::MORFE.MultilinearTerms.FullySymmetric, t, deg) _orders_for_cache(::MORFE.MultilinearTerms.SymmetryType, t, deg)
_orders_for_cache(sym, t, deg) -> Vector{Int}

Return the per-slot derivative order vector for storage in a CachedSplit.

function _replay_all_fem_splits!(result, model, W, global_split::MORFE.MultilinearTerms.FEMGlobalSplit, global_∇W_qp, global_Fe_buffers)
_replay_all_fem_splits!(result, model, W, global_split, global_∇W_qp, global_Fe_buffers)

Execute the O4 combined element loop: traverse the mesh once, scatter all globally-unique W columns, then accumulate contributions from ALL me=0 FEM terms at each quadrature point.

fem_reinit! and scatter_qp! are called at most once per unique (element, W-column) pair; accumulate_qp! dispatches per degree group via _accumulate_global_entries!.

function _replay_fem_split!(result, t::FEMMultilinearMap, W, fem_split::MORFE.MultilinearTerms.FEMCachedSplit{DEG, ME}, Fe, temp, external_arguments) where {DEG, ME}
_replay_fem_split!(result, t, W, fem_split, Fe, temp, external_arguments)

Replay one FEMCachedSplit{DEG, ME} using the FEM-batched element loop.

For each element: calls fem_reinit! once, scatters each unique (order, col) W column to qp-level field values via scatter_qp!, accumulates all qp contributions via accumulate_qp!, and assembles the element residual into result via assemble_element!. The qp gradient buffer ∇W_qp is obtained from the term via fem_qp_buffer(t) and is owned by the term, not allocated here.

accumulate_qp! receives DEG internal arguments followed by ME external ones, so the tuple has length t.deg — matching what evaluate_term!'s direct FEM overload (_eval_fem_term_direct!) passes. Before this, the external slots were omitted here and ext_count was applied as a bare scalar multiplier, so an me > 0 FEM term could not tell one external direction from another and disagreed with the direct path. The external arguments are hoisted out of the element loop, exactly as in _replay_split!.

function _replay_global_fem!(result, model, W, cache::MORFE.MultilinearTerms.MultilinearTermsCache{T, QP}, exp_index) where {T, QP}
_replay_global_fem!(result, model, W, cache, exp_index)

Function barrier: retrieves cache.global_fem_splits[exp_index] (stored as Any) and delegates to _replay_all_fem_splits!. The barrier restores type stability because cache.global_∇W_qp::Matrix{QP} is concretely typed at the call site even though the global_split triggers runtime dispatch on ENTRIES_TUPLE.

function _replay_split!(result, scratch, temp, t, W, split, deg, external_arguments)
_replay_split!(result, scratch, temp, t, W, split, deg, external_arguments)

Replay one CachedSplit into result using precomputed factorisation bookkeeping.

  • me = 0 (args_ext_indices empty): accumulates directly into result.

  • me > 0: accumulates into temp, then axpy!(ext_count, temp, result).

Dispatches asymmetric/symmetric accumulation via split.is_asymmetric.

function _replay_term!(result, t::MultilinearMap, W, exp_index, t_idx, cache::MORFE.MultilinearTerms.MultilinearTermsCache) _replay_term!(result, t::FEMMultilinearMap, W, exp_index, t_idx, cache::MORFE.MultilinearTerms.MultilinearTermsCache)
_replay_term!(result, t, W, exp_index, t_idx, cache)

Replay all cached splits for one nonlinear term t and monomial exp_index into result. Dispatches on the concrete term type:

function accumulate_multilinear_term!(result, scratch, temp, t::MultilinearMap{ORD}, parametrisation::Parametrisation{ORD, NVAR}, exp::StaticArraysCore.SVector{NVAR}, candidate_indices, external_exp, external_arguments) where {ORD, NVAR}
accumulate_multilinear_term!(result, scratch, temp, t, parametrisation,
							  exp, candidate_indices, external_exp, external_arguments)

Add the contribution of nonlinear term t for exponent exp to result.

When t has no external (forcing) slots (me = 0) there is exactly one split, so we skip bounded_index_tuples and write directly into result, saving one fill! and one axpy! per term. For me > 0 each split is accumulated into temp first, then scaled into result via axpy!.

function build_multilinear_terms_cache(model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T} where {ORDP1, N_NL, N_EXT, T}, parametrisation::Parametrisation{ORD, NVAR}) where {ORD, NVAR} build_multilinear_terms_cache(model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T} where {ORDP1, N_NL, N_EXT, T}, parametrisation::Parametrisation{ORD, NVAR}, skip_bits::BitVector) where {ORD, NVAR}
build_multilinear_terms_cache(model, parametrisation[, skip_bits]) → MultilinearTermsCache

Precompute all factorisation data for every monomial and term. Valid as long as the multiindex set is unchanged.

When skip_bits[l] is true the cache entry for monomial l is left empty. This is safe for monomials that will never be replayed (linear monomials, conjugate secondaries): those entries are guarded by the same skip_bits check in the solve loop.

function compute_multilinear_terms(model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T} where {ORDP1, N_NL, N_EXT, T}, exp::StaticArraysCore.SVector{NVAR}, parametrisation::Parametrisation{ORD, NVAR}) where {ORD, NVAR} compute_multilinear_terms(model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T} where {ORDP1, N_NL, N_EXT, T}, exp_index::Int64, parametrisation::Parametrisation{ORD, NVAR}, cache::MORFE.MultilinearTerms.MultilinearTermsCache) where {ORD, NVAR}
compute_multilinear_terms(model, exp, parametrisation) → Vector

Return the sum of all nonlinear-term contributions for exponent exp.

Scratch buffers and shared data (external_arguments, candidate_indices, external_exp) are allocated once and reused across all terms.

compute_multilinear_terms(model, exp_index, parametrisation, cache) → Vector

Cached variant of compute_multilinear_terms: replays precomputed factorisation data instead of calling the factorisation routines.

exp_index is the 1-based index into parametrisation's multiindex set.

function compute_multilinear_terms!(result::AbstractVector, model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T} where {ORDP1, N_NL, N_EXT, T}, exp_index::Int64, parametrisation::Parametrisation{ORD, NVAR}, cache::MORFE.MultilinearTerms.MultilinearTermsCache) where {ORD, NVAR}
compute_multilinear_terms!(result, model, exp_index, parametrisation, cache) → nothing

In-place variant: zeros result then accumulates all nonlinear contributions into it. Uses cache.scratch_buffer, cache.temp_buffer, and cache.external_arguments so that no heap allocation occurs during the inner solve loop.

function symmetry_type(t::MultilinearMap) symmetry_type(t::AbstractMultilinearMap)
symmetry_type(t) -> SymmetryType

Classify a AbstractMultilinearMap as FullyAsymmetric, FullySymmetric, or GroupwiseSymmetric based on its multiindex field.

Setting fully_asymmetric = true on any term overrides the multiindex-based classification and always returns FullyAsymmetric.

Module LowerOrderCouplings — computation of lower-order coupling vectors ξ[α].

For each monomial α the cohomological right-hand side includes a coupling term

ξ[α] = Σ_{β+γ=α, β after e_i in GrLex} R_β · (|γ₁| W_{γ+e₁} + … + |γₙ| W_{γ+eₙ})

that links the current monomial to all lower-degree coefficients of W and R already computed. compute_lower_order_couplings evaluates this sum efficiently using a precomputed dictionary and candidate index list.

function _is_zero_coeff(coeff::AbstractMatrix)
_is_zero_coeff(coeff) -> Bool

Return true when the FOM × ORD coefficient slice coeff is entirely zero. Used to skip trivial contributions without entering the accumulation loop.

function _sum_degree_one_terms!(accumulator::Array{Vector{T}, 1}, upper_bound::StaticArraysCore.SVector{NVAR, Int64}, multiindex_dict::Dict{StaticArraysCore.SVector{NVAR, Int64}, Int64}, red_coefficients::AbstractMatrix{T}, param_coefficients::AbstractArray{T, 3}, unit_vectors::AbstractArray{StaticArraysCore.SVector{NVAR, Int64}, 1}) where {NVAR, T}
_sum_degree_one_terms!(accumulator, upper_bound, multiindex_dict,
					   red_coefficients, param_coefficients, unit_vectors)

Accumulate the |β| = 1 part of the lower-order coupling sum into accumulator.

For each unit-vector eⱼ with eⱼ ≤ upper_bound, and for each i < j, adds R[i, eⱼ] * (upper_bound - eⱼ + eᵢ)[i] * W[:, :, upper_bound - eⱼ + eᵢ] to accumulator. Only reads precomputed multiindex_dict — no allocations.

The i < j restriction is causality, not an optimisation. upper_bound - eⱼ + eᵢ has the same total degree as upper_bound, and GrLex breaks ties by descending lexicographic order, so that exponent precedes upper_bound — and is therefore already solved — exactly when i < j. Consequently this branch reads only the strictly upper triangle of the reduced linear dynamics Λ, and a strictly-lower entry of Λ would be dropped without trace. That is why the external system's linear matrix is required to be upper triangular; see the ExternalSystems module docstring.

function _sum_higher_degree_terms!(accumulator::Array{Vector{T}, 1}, upper_bound::StaticArraysCore.SVector{NVAR, Int64}, mset::MultiindexSet, multiindex_dict::Dict{StaticArraysCore.SVector{NVAR, Int64}, Int64}, red_coefficients::AbstractMatrix{T}, param_coefficients::AbstractArray{T, 3}, candidate_idxs::AbstractVector{Int64}, unit_vectors::AbstractArray{StaticArraysCore.SVector{NVAR, Int64}, 1}) where {NVAR, T}
_sum_higher_degree_terms!(accumulator, upper_bound, mset, multiindex_dict,
						  red_coefficients, param_coefficients,
						  candidate_idxs, unit_vectors)

Accumulate the |β| ≥ 2 part of the lower-order coupling sum into accumulator.

Iterates over candidate_idxs (precomputed as exponents componentwise ≤ upper_bound with total degree in [2, sum(upper_bound)-1]) and adds the same weighted R[i, β] * W[:, :, upper_bound - β + eᵢ] contributions as _sum_degree_one_terms!.

function compute_lower_order_couplings(upper_bound::StaticArraysCore.SVector{NVAR, Int64}, parametrisation::Parametrisation{ORD, NVAR, T}, reduced_dynamics::ReducedDynamics{ROM, NVAR, T}, multiindex_dict::Dict{StaticArraysCore.SVector{NVAR, Int64}, Int64}, accumulator::Array{Vector{T}, 1}, candidate_idxs::AbstractVector{Int64}, unit_vectors::AbstractArray{StaticArraysCore.SVector{NVAR, Int64}, 1}) where {ORD, NVAR, ROM, T} compute_lower_order_couplings(upper_bound::AbstractVector{Int64}, parametrisation::Parametrisation{ORD, NVAR, T}, reduced_dynamics::ReducedDynamics{ROM, NVAR, T}) where {ORD, NVAR, ROM, T}
compute_lower_order_couplings(upper_bound, parametrisation, reduced_dynamics,
							  multiindex_dict, accumulator, candidate_idxs,
							  unit_vectors) -> accumulator

Performance path. Evaluate the lower-order coupling vector ξ[α] for monomial α = upper_bound and accumulate the result into the pre-zeroed accumulator.

All auxiliary data (multiindex_dict, accumulator, candidate_idxs, unit_vectors) must be pre-allocated by the caller (typically via LowerOrderResources) to avoid heap allocation in the inner solve loop. Returns accumulator for convenience.

compute_lower_order_couplings(upper_bound, parametrisation, reduced_dynamics) -> Vector{Vector{T}}

Allocating convenience wrapper. Builds the multiindex dictionary, accumulator, and candidate index list internally, then delegates to the performance path.

Use the 7-argument form inside performance-critical loops; this overload is intended for interactive use or testing.

Numerical linear algebra for MORFE's constant-size bordered systems.

This internal module owns sparse backend selection, constant-pattern solver state, symbolic-factorisation caching, numeric refactorisation, exact factor reuse, residual verification, iterative refinement, and Pardiso extension hooks. It deliberately does not assemble cohomological equations, schedule monomials, manage symmetry, or persist checkpoints.

The supported sparse backends are KLU, UMFPACK, and extension-provided Pardiso. A cached factorisation is retained only after a successful factorisation; failed cached KLU and UMFPACK updates receive one fresh symbolic analysis before failure is reported. Failed KLU native storage is released before that recovery analysis begins.

Source organisation

FilePurpose
SolverState.jlBackend markers, constant-pattern sparse storage, and cache lifetime
FailureDiagnostics.jlContextual failure categories and user-facing diagnostics
Factorisations.jlTyped KLU and UMFPACK symbolic/numeric factorisation reuse
AccuracyControl.jlSparse backward errors and iterative refinement
BorderedSolve.jlBackend dispatch, exact factor reuse, and in-place solves
AbstractSparseBackend

Internal dispatch root for sparse bordered solvers. KLUBackend and UMFPACKBackend are always available; PardisoBackend is constructed only when the extension is active.

type KLUBackend
KLUBackend{VERIFY}

Marker for the built-in KLU path. VERIFY selects backward-error verification at compile time so the default unchecked loop does not pay for residual bookkeeping.

PardisoBackend{P, VERIFY}

Sparse backend state for an extension-provided Pardiso solver. VERIFY selects backward-error verification at compile time.

SparseLinearSolverState{T, B, RT}

Sparse-path resources for the constant-size bordered cohomological system (see the CohomologicalEquations module docstring for the system itself).

bordered is the (FOM+ROM) × (FOM+ROM) matrix actually handed to the factoriser. Its colptr/rowval are fixed for the entire solve — only nzval is rewritten per monomial — which is what keeps the symbolic factorisation cached in fact valid throughout, and is the reason the border is masked rather than compacted.

L_template is the separate square FOM × FOM workspace on which build_sparse_L_and_rhs! runs its fused Horner pass (it needs the transient intermediates L[j](s) to accumulate the lower-order RHS); the resulting L(s) is then block-copied into the (1,1) block of bordered, column by column.

Backend selection follows the caller's parametrisation options: :klu forces KLU, :umfpack forces SuiteSparse UMFPACK, :pardiso requires the extension, and :auto prefers an available Pardiso implementation before falling back to KLU. KLU and UMFPACK reuse cached symbolic analysis while redoing numeric factorisation whenever the bordered matrix changes. KLU uses klu_factor!, not the exported klu!: the latter freezes the pivot sequence.

Fields

  • bordered::SparseMatrixCSC{T} — the (FOM+ROM)² matrix handed to the factoriser. Its colptr/rowval never change; only nzval is rewritten per monomial.

  • L_template::SparseMatrixCSC{T} — FOM × FOM workspace carrying the union sparsity pattern of all linear_terms, on which L(s) is built.

  • L_mappings::Vector{Vector{Int}} — for each linear_terms[k], the position in L_template.nzval of each of its stored entries, so accumulating s^k B_k is an indexed scatter with no pattern search.

  • border_row_base::Vector{Int} — length FOM; bordered[FOM+r, c] lives at nzval[border_row_base[c] + r - 1]. The border rows are contiguous within each column, which is what makes writing them a strided copy.

  • solve_scratch::Vector{T} — length FOM+ROM RHS copy for Pardiso, whose solve needs distinct input and output vectors. Empty on the KLU and UMFPACK paths, where ldiv! is genuinely in-place.

  • pardiso_matrix::Any — the matrix handed to Pardiso's analysis phase; nothing until _pardiso_prepare! has run.

  • fact::Any — cached KLU or UMFPACK factorisation; nothing until the first successful one.

  • backend::B — selected KLUBackend, UMFPACKBackend, or PardisoBackend.

  • residual_tolerance::Union{Nothing, RT} — active backward-error threshold.

  • max_refinement_steps::Int — maximum KLU or UMFPACK refinement corrections.

  • residual_work::Vector{T} — persistent KLU or UMFPACK residual/norm workspace.

  • refinement_work::Vector{T} — lazily allocated correction workspace.

  • max_relative_residual::RT — largest accepted backward error observed so far.

  • refinement_count::Int — total KLU or UMFPACK refinement corrections performed.

mutable is load-bearing, not incidental: the Pardiso branch attaches a finaliser to release C-side memory, and Julia refuses to finalise an immutable object.

SparseLinearSolverState{T}(L_template, L_mappings, FOM, ROM;
    options = nothing) -> SparseLinearSolverState

Initialise the sparse solver state and constant-pattern bordered template. Backend selection, residual verification, and refinement storage follow options.

UMFPACKBackend{VERIFY}

Marker for the opt-in SuiteSparse UMFPACK path. VERIFY selects backward-error verification at compile time so unchecked solves retain the same specialised path as KLU.

_BorderedSolveFailure

Internal contextual failure raised by a bordered cohomological solve.

Fields

  • category::Symbol — :outer_resonance, :factorisation, :solve, or :accuracy.

  • backend::Symbol — selected linear solver (:dense, :klu, :umfpack, or :pardiso).

  • index::Int — position of the monomial in the active multiindex set.

  • multiindex — exponent vector of the failing monomial.

  • superharmonic — canonical superharmonic used to assemble the matrix.

  • inner_resonance_mask — master-mode resonance mask.

  • outer_resonance_targets::Vector{Int} — configured outer targets flagged at this monomial.

  • recovery_attempted::Bool — whether a failed cached factorisation was discarded and retried from a fresh symbolic analysis.

  • detail::String — backend status or failed accuracy criterion.

  • cause — underlying backend exception, or nothing when failure was status-based.

function _backend_name(::MORFE.BorderedLinearSolvers.KLUBackend) _backend_name(::MORFE.BorderedLinearSolvers.UMFPACKBackend) _backend_name(::MORFE.BorderedLinearSolvers.PardisoBackend)
_backend_name(backend) -> Symbol

Return the stable diagnostic name of a sparse backend (:klu, :umfpack or :pardiso).

function _backward_error(ss::SparseLinearSolverState, x, norm_r, norm_b, norm_A)
_backward_error(state, x, norm_r, norm_b, norm_A) -> Real

Return the normwise backward error norm_r / (norm_A * norm(x, Inf) + norm_b), guarded against a zero denominator in the state's real scalar type.

function _bordered_solve!(ss::SparseLinearSolverState{T, <:MORFE.BorderedLinearSolvers.KLUBackend{VERIFY}}, solution::AbstractVector, superharmonic, index::Int64, multiindex, resonance, resonance_set; reuse_factor) where {T, VERIFY} _bordered_solve!(ss::SparseLinearSolverState{T, <:MORFE.BorderedLinearSolvers.UMFPACKBackend{VERIFY}}, solution::AbstractVector, superharmonic, index::Int64, multiindex, resonance, resonance_set; reuse_factor) where {T, VERIFY} _bordered_solve!(ss::SparseLinearSolverState{T, <:MORFE.BorderedLinearSolvers.PardisoBackend{P, VERIFY}}, solution::AbstractVector, superharmonic, index::Int64, multiindex, resonance, resonance_set; reuse_factor) where {T, P, VERIFY}
_bordered_solve!(state, solution, superharmonic, index, multiindex,
	resonance, resonance_set; reuse_factor = Val(false)) -> solution

Solve the bordered system in place through KLU, UMFPACK, or Pardiso. Exact grouped reuse skips numeric factorisation only when the caller proves that the assembled matrix is identical. Monomial data is carried solely to produce contextual failure diagnostics.

function _cached_klu_factor(ss::SparseLinearSolverState{T}, ::SparseArrays.SparseMatrixCSC{T, Ti}) where {T, Ti}
_cached_klu_factor(state, matrix) -> KLUFactorization

Return the cached KLU factorisation with value and index types derived from the active sparse matrix. Call only after the first successful factorisation has populated fact.

function _cached_sparse_factor(ss::SparseLinearSolverState{T, <:MORFE.BorderedLinearSolvers.KLUBackend}, matrix) where T _cached_sparse_factor(ss::SparseLinearSolverState{T, <:MORFE.BorderedLinearSolvers.UMFPACKBackend}, matrix) where T

Return the selected backend's cached factor with its concrete type restored.

function _cached_umfpack_factor(ss::SparseLinearSolverState{T}, ::SparseArrays.SparseMatrixCSC{T, Ti}) where {T, Ti}
_cached_umfpack_factor(state, matrix) -> UmfpackLU

Return the cached UMFPACK factorisation with value and index types derived from the active sparse matrix. Call only after a successful factorisation has populated fact.

function _configured_residual_tolerance(::Type{T}, options) where T
_configured_residual_tolerance(T, options) -> tolerance or nothing

Resolve the backward-error threshold in the real scalar type associated with T. options = nothing selects the standard checked default used by the low-level constructor.

function _discard_klu_factor!(ss::SparseLinearSolverState, factorisation::KLU.KLUFactorization)
_discard_klu_factor!(state, factorisation) -> nothing

Remove a failed KLU factorisation from state and release its native symbolic and numeric storage before another analysis begins. Merely assigning nothing to state.fact would leave that storage alive until garbage collection, which can retain damaged KLU state across the immediate recovery attempt on Windows.

Base.finalize is Julia's required external spelling; it runs the finaliser already owned by KLUFactorization and prevents it from running twice later.

function _fresh_klu_factor!(ss::SparseLinearSolverState, A::SparseArrays.SparseMatrixCSC)

Create and cache a checked fresh KLU factorisation of A.

function _is_unrecoverable_failure(error)

Return whether a caught runtime failure must pass through without solver wrapping.

function _outer_resonance_targets(resonance_set, index::Int64)

Return configured outer-resonance target indices for one monomial position.

function _pardiso_factorise_solve!(args...)

Run Pardiso numeric factorisation followed by a solve.

function _pardiso_prepare!(args...)

Run Pardiso configuration and symbolic analysis for a bordered matrix.

function _pardiso_release!(args...)

Release extension-owned Pardiso factorisation storage.

function _pardiso_solve!(args...)

Solve with the current Pardiso numeric factorisation.

function _refactorise!(ss::SparseLinearSolverState, A::SparseArrays.SparseMatrixCSC)

Backward-compatible internal alias for _refactorise_klu!.

function _refactorise_klu!(ss::SparseLinearSolverState, A::SparseArrays.SparseMatrixCSC)
_refactorise_klu!(ss, A) -> KLU factorisation of `A`

Factorise the bordered matrix for the current monomial, reusing the symbolic analysis cached in ss.fact.

The first call analyses and factorises; every later call redoes only the numeric factorisation. Splitting them this way is what makes the constant-size formulation pay off: the sparsity pattern is identical for every monomial, so the ordering and symbolic phase — the expensive part — are computed once for the whole solve.

Why klu_factor! and not klu!

The numeric phase must re-pivot. The bordered matrix changes value on every monomial and its (1,1) block is near-singular at every resonance, where stability depends on pivoting being free to exchange rows across the border. klu_factor! re-pivots while reusing the cached symbolic analysis. KLU's exported klu! maps to klu_refactor, which replays the pivot sequence chosen at the first monomial and so loses accuracy exactly where it is needed.

Preconditions and aliasing

A's sparsity pattern must be identical on every call — SparseLinearSolverState guarantees this by construction, since only nzval is ever written.

klu takes A.nzval by reference rather than copying it, so the factorisation and the template share one value array: per-monomial assembly writes straight into what KLU reads, with no copy-in step. colptr/rowval stay KLU's own (0-based) copies, and are invariant in any case.

A factorisation is cached only if it succeeded. Both fresh and cached numeric factorisations use KLU's checked path because stored zeros in the constant border can make an unchecked status appear successful even when the numeric factor is singular. A failed cached factor has its native storage released immediately and is retried once from a fresh symbolic analysis.

function _refactorise_sparse!(ss::SparseLinearSolverState{T, <:MORFE.BorderedLinearSolvers.KLUBackend}, matrix) where T _refactorise_sparse!(ss::SparseLinearSolverState{T, <:MORFE.BorderedLinearSolvers.UMFPACKBackend}, matrix) where T

Dispatch numeric refactorisation to the selected built-in sparse backend.

function _refactorise_umfpack!(ss::SparseLinearSolverState, A::SparseArrays.SparseMatrixCSC)
_refactorise_umfpack!(state, matrix) -> UMFPACK factorisation

Factorise the current bordered matrix through SuiteSparse UMFPACK. Successful symbolic analysis is reused by lu!. A failed cached numeric factorisation is discarded and retried once from a fresh analysis; only a successful factorisation is retained in state.fact.

function _release_pardiso!(state::SparseLinearSolverState{T, <:MORFE.BorderedLinearSolvers.KLUBackend}) where T _release_pardiso!(state::SparseLinearSolverState{T, <:MORFE.BorderedLinearSolvers.UMFPACKBackend}) where T _release_pardiso!(state::SparseLinearSolverState{T, <:MORFE.BorderedLinearSolvers.PardisoBackend}) where T
_release_pardiso!(state) -> nothing

Finaliser: hand the Pardiso factorisation back. No-op on the KLU and UMFPACK paths, and never allowed to throw — a finaliser that raises would be reported out of context.

function _sparse_backward_error!(ss::SparseLinearSolverState, factorisation, solution, norm_b)
_sparse_backward_error!(state, factorisation, solution, norm_b) -> Real

Check the normwise backward error of a KLU or UMFPACK solution and, when necessary, perform at most state.max_refinement_steps correction solves with the supplied typed factorisation. Update the diagnostic maximum and refinement count and return the final backward error.

function _sparse_inf_norm!(workspace, matrix::SparseArrays.SparseMatrixCSC)
_sparse_inf_norm!(workspace, matrix) -> Real

Compute norm(matrix, Inf) for a CSC matrix using workspace for row sums and without allocating a dense intermediate.

function _sparse_residual!(residual, matrix::SparseArrays.SparseMatrixCSC, solution)
_sparse_residual!(residual, matrix, solution) -> residual

Overwrite a vector containing b with matrix * solution - b using SparseArrays' five-argument mul! kernel.

function _suite_sparse_bordered_solve!(ss::SparseLinearSolverState{T}, solution::AbstractVector, superharmonic, index::Int64, multiindex, resonance, resonance_set, reuse_factor::Val{REUSE}, ::Val{VERIFY}) where {T, REUSE, VERIFY}

Shared allocation-conscious solve for the built-in KLU and UMFPACK backends.

function _throw_bordered_failure(category::Symbol, backend::Symbol, index::Int64, multiindex, superharmonic, inner_resonance_mask, resonance_set; recovery_attempted, detail, cause)

Construct and throw a contextual _BorderedSolveFailure.

_try_build_pardiso_solver() -> solver or nothing

Extension hook implemented by MORFEPardisoExt. Return a configured Pardiso solver when the extension is active, or nothing so automatic selection can fall back to KLU.

Solve the cohomological equations that arise in the parametrisation method for computing Direct Normal Forms, NNMs, Spectral Submanifolds (SSMs) and such Invariant Manifolds of high-dimensional dynamical systems.


Problem statement

Given a full-order model of order ORD in FOM degrees of freedom, the parametrisation method seeks a change of coordinates

x(t) = W(z(t)),          z ∈ ℂᴺᵛᵃʳ

such that the reduced dynamics ż = R(z) is simpler. Expanding both W and R as dense polynomials in the NVAR = ROM + N_EXT reduced variables (ROM master-mode amplitudes plus N_EXT external forcing amplitudes) and matching coefficients monomial by monomial yields the cohomological equations: one linear system per multi-index α.


System structure for multi-index α

Let s = ⟨λ, α⟩ be the superharmonic frequency and let P = diag(ρ) be the diagonal 0/1 matrix of the master modes resonant at α, Q = I − P. The cohomological linear system is bordered and of constant size FOM + ROM:

┌                          ┐ ┌         ┐   ┌             ┐
│  L(s)     C(s) P         │ │  W[α]   │ = │  RHS_inv    │   FOM rows  (invariance)
│  P Ĵ(s)   P Ĉ(s) P + τQ  │ │  R[α]   │   │  P RHS_ort  │   ROM rows  (orthogonality)
└                          ┘ └         ┘   └             ┘

where:

  • L(s) (FOM × FOM) is the parametrisation operator,

  • C(s) (FOM × ROM) acts on the unknown reduced-dynamics coefficients,

  • Ĵ(s) (ROM × FOM) is the orthogonality row operator, stacking the rows Ĵ_r(s),

  • Ĉ(s) (ROM × ROM) is the orthogonality joint operator,

  • W[α] ∈ ℂᶠᵒᵐ is the parametrisation coefficient,

  • R[α] ∈ ℂᴿᴼᴹ are the master reduced-dynamics coefficients,

  • τ = 1 on the non-resonant diagonal, so those rows read R[r, α] = 0.

Non-resonant master modes stay in the system as trivial equations rather than being compacted away. That is what keeps the size — and on the sparse path the sparsity pattern — independent of α, so a single symbolic factorisation serves every monomial. It costs nothing in accuracy: permuting the unknowns as [W; R_res; R_non] makes the matrix block triangular with an exactly decoupled τI block, and each trivial row is a singleton in both its row and its column. External forcing modes are known and appear only on the right-hand side.

Why the system is bordered rather than reduced

The border is not an optimisation but a conditioning requirement. Inner resonance is flagged by |λ_r − s| < tol, and det L(λ_r) = 0 for every master eigenvalue, so "monomial α is resonant" means precisely "L(s_α) is numerically singular" — and a border is present only on exactly those monomials. Eliminating L(s) first, by forming L(s)⁻¹·RHS and L(s)⁻¹C(s), would therefore apply an inverse that does not usefully exist, with backward error growing like κ(L(s_α)) → ∞.

Factorising the bordered matrix as a whole keeps the backward error at κ of the bordered matrix, which stays bounded because the border spans the near-null directions of L(s) (Keller's bordering lemma). This is why the linear algebra below never inverts L(s) alone, and why its numeric factorisation must re-pivot on every monomial.


Module contents

SymbolDescription
InvarianceOperatorsPrecomputed invariance-equation operator coefficients
OrthogonalityOperatorsPrecomputed orthogonality-condition operator coefficients
LowerOrderResourcesLower-order coupling data and buffers
CohomologicalBuffersPre-allocated system-assembly scratch buffers
CohomologicalContextComposed struct bundling all precomputed operators and resources
solve_single_monomial!Solve the cohomological system for one multi-index

Source layout

The module is deliberately organised by responsibility:

FileResponsibility
CohomologicalEquations.jlFocused module composition, imports, and exports
SolveState.jlEquation operators, lower-order resources, and reusable assembly buffers
EquationAssembly.jlBordered equation assembly and the dense equation solve
MonomialSolve.jlNonlinear right-hand side, bordered-solver call, and W/R finalisation

Linear factorisation belongs to BorderedLinearSolvers; scheduling, checkpointing, symmetry, progress, and benchmarking belong to ParametrisationSolver.

CohomologicalBuffers{T, RT}

Pre-allocated scratch buffers for the cohomological system assembly and solve.

Both paths solve the same constant-size (FOM+ROM) × (FOM+ROM) bordered system, but they materialise it differently, so only one of the two matrix buffers is allocated:

  • dense path uses system_matrix, the bordered matrix itself, LU'd in place;

  • sparse path uses orthogonality_rows as the staging area for the ROM orthogonality rows, which are then scattered into the strided border positions of the sparse template's nzval (the sparse bordered matrix lives in SparseLinearSolverState).

The unused buffer is a 0×0 placeholder.

Fields

  • system_matrix::Matrix{T} — the (FOM+ROM) × (FOM+ROM) bordered matrix on the dense path, factorised in place by lu!. 0×0 on the sparse path.

  • orthogonality_rows::Matrix{T} — ROM × (FOM+ROM) staging area on the sparse path, holding the evaluated Ĵ(s) rows and corner before they are scattered into the template. 0×0 on the dense path.

  • rhs::Vector{T} — length FOM+ROM. Holds the right-hand side on entry and, in the same memory, the solution after the solve; the unpacking step reads W[α] from its first FOM entries and the resonant R[α] from the rest.

  • external_rhs::Vector{T} — length FOM scratch for evaluate_external_rhs!.

  • ml_result::Vector{T} — length FOM, receiving the nonlinear (multilinear-term) contribution from compute_multilinear_terms!.

  • dense_solution::Vector{T} — accepted solution retained while the dense matrix and right-hand side are reassembled for backward-error checks; empty when verification is disabled or on the sparse path.

  • dense_refinement::Vector{T} — lazily allocated dense correction vector used only after a failed backward-error check.

  • residual_tolerance::Union{Nothing, RT} — active scalar-type-aware backward-error threshold, or nothing when verification is disabled.

  • max_refinement_steps::Int — maximum dense iterative-refinement corrections.

CohomologicalBuffers(T, MT, FOM, ROM, options = nothing) -> CohomologicalBuffers

Allocate all buffers for a system of full-order dimension FOM and ROM master modes. Dispatches on the FOM matrix type MT: MT <: SparseMatrixCSC selects the sparse layout, everything else the dense one. options controls backward-error workspace and the refinement limit.

CohomologicalContext{T, ORD, ORDP1, NVAR, FOM, LT, MT}

Bundles all precomputed data required to solve the cohomological equations for every monomial.

The solve visits every multi-index in turn, and nothing in this struct varies with the multi-index: operator coefficients, resonance look-ups, buffers and the cached factorisation are all built once by solve_parametrisation and reused. That is what allows solve_single_monomial! to run without heap allocation. Related data is grouped into named sub-structs rather than kept flat, so each field's provenance is visible at the call site (ctx.orthogonality.J_coeffs, not a bare ctx.J_coeffs).

Type parameters

ParameterMeaning
TScalar type (typically ComplexF64)
ORDDifferential-equation order of the full-order model
ORDP1ORD + 1
NVARTotal reduced variables: ROM + N_EXT
FOMFull-order state dimension
LTElement type of the FOM matrices
MTMatrix type; sparse path when MT <: SparseMatrixCSC

Fields

  • linear_terms::NTuple{ORDP1, MT} — the ORD+1 FOM matrices B₀ … B_ORD whose combination L(s) = Σ_j s^j B_j forms the (1,1) block of the bordered system.

  • generalised_eigenmodes::Matrix{T} — FOM × NVAR matrix of generalised right eigenmodes, formed by concatenating the master right eigenmodes and the solved external right directions. It propagates higher derivative coefficients after the linear monomials have been initialised. The left eigenmodes are represented separately through orthogonality and are never stored in this field.

  • lambda_diag::Vector{T} — the NVAR reduced eigenvalues; the superharmonic of a monomial is s = ⟨lambda_diag, α⟩.

  • invariance::InvarianceOperators{T} — border-column coefficients; see InvarianceOperators.

  • orthogonality::OrthogonalityOperators{T} — orthogonality row, corner and external coefficients; see OrthogonalityOperators.

  • resonance_set::ResonanceSet — which master modes are resonant with which monomial, deciding per row whether the border is populated or masked out.

  • linear_monomial_skip_set::Set{Int} — positions of master linear monomials fixed from the eigenvectors before the loop. External linear directions are marked separately in the active conjugate-symmetry skip mask after they are solved.

  • lower_order::LowerOrderResources{NVAR, T} — coupling buffers and multiindex look-up; see LowerOrderResources.

  • buffers::CohomologicalBuffers{T} — assembly and solve scratch; see CohomologicalBuffers.

  • sparse_solver::Union{Nothing, SparseLinearSolverState{T}} — sparse-path template and factorisation handles, or nothing on the dense path. This field, not MT alone, is what the solve branches on.

InvarianceOperators{T}

Precomputed column-polynomial coefficients for the invariance equation.

Both fields hold coefficients of polynomials in the superharmonic s, evaluated by evaluate_column! once per monomial: the master columns become the C(s) border of the bordered system, the external ones contribute to its right-hand side. Precomputing them per order rather than per monomial is what keeps the inner solve loop allocation-free.

Fields

  • column_coeffs::Vector{Matrix{T}} — one FOM × ORD matrix per master mode r, the coefficients of the border column C_r(s). Distinct in both shape and role from OrthogonalityOperators corner_coeffs, which fills the ROM × ROM corner.

  • E_coeffs::Vector{Matrix{T}} — one FOM × ORD matrix per external variable or direction e. External amplitudes are known, so these never reach the matrix.

LowerOrderResources{NVAR, T}

Data needed to compute lower-order coupling vectors ξ[j] on every monomial.

Bundled into one struct so the per-monomial buffers and the multiindex lookup are allocated once for the whole solve rather than per call. Everything here is a pure function of the multiindex set, so none of it changes as the solve progresses.

Fields

  • multiindex_dict::Dict{SVector{NVAR, Int}, Int} — maps an exponent vector to its position in the multiindex set, turning the coupling lookup into a hash rather than a scan.

  • buffer::Vector{Vector{T}} — ORD vectors of length FOM holding the coupling terms ξ[j]. Reused across monomials and zeroed by the caller before each use.

  • candidate_indices::Vector{Vector{Int}} — for each of the L monomial positions, the multiindices that can contribute a coupling to it. Degree-1 monomials get an empty list, since a coupling needs two factors of degree ≥ 1.

  • unit_vectors::Vector{SVector{NVAR, Int}} — the NVAR unit exponent vectors eᵣ, materialised once because the coupling recurrence subtracts them constantly.

LowerOrderResources{NVAR, T}(mset, ORD, FOM) -> LowerOrderResources

Build the lower-order coupling resources for a multiindex set of NVAR variables, ODE order ORD, and full-order dimension FOM.

candidate_indices is precomputed here, once per monomial position: it lists the multiindices that can contribute a lower-order coupling to that monomial, which is a pure function of the multiindex set and so need not be recomputed during the solve.

OrthogonalityOperators{T}

Precomputed row and column-polynomial coefficients for the orthogonality conditions.

Together the three fields supply the bottom ROM rows of the bordered system: one row per master mode, assembled by assemble_orthogonality_matrix_and_rhs!. They are read off the left eigenvector order-blocks once per order, so evaluating a row at a given s costs one Horner pass and no allocation.

Fields

  • J_coeffs::Vector{Matrix{T}} — one ORD × FOM matrix per master mode r, the coefficients of the row operator Ĵ_r(s) acting on W[α].

  • corner_coeffs::Vector{Matrix{T}} — one (ORD-1) × ROM matrix per master mode, evaluating to the ROM × ROM corner block Ĉ(s) that couples the orthogonality rows to the unknown reduced-dynamics coefficients. See InvarianceOperators column_coeffs for the differently-shaped border columns.

  • E_coeffs::Vector{Matrix{T}} — one (ORD-1) × N_EXT matrix per master mode, contracting the known external amplitudes into the scalar right-hand side.

Internal marker selecting the allocation-free, uninstrumented solve path.

function _assemble_bordered_system!(ctx::CohomologicalContext{T, ORD, ORDP1, NVAR, FOM, LT, MT}, s, resonance::StaticArraysCore.SVector{ROM, Bool}, lower_order_couplings, external_dynamics) where {T, ORD, ORDP1, NVAR, FOM, LT, MT, ROM}
_assemble_bordered_system!(ctx, s, resonance, lower_order_couplings, external_dynamics)

Assemble the (FOM+ROM) × (FOM+ROM) bordered cohomological system into ctx.buffers.system_matrix and ctx.buffers.rhs.

  • The first FOM rows come from the invariance equation (operator + nonlinear RHS).

  • The last ROM rows come from the orthogonality conditions, with non-resonant modes contributing the trivial row R[r, α] = 0.

Called by the dense-path _solve_monomial!.

function _assemble_nonlinear_rhs!(::MORFE.CohomologicalEquations._NoMonomialInstrumentation, ctx, model, idx, W, ml_cache)
_assemble_nonlinear_rhs!(instrumentation, ctx, model, idx, W, ml_cache)

Instrumentation hook for nonlinear right-hand-side assembly. The production method returns nothing; benchmark instrumentation returns the corresponding @timed result.

function _dense_backward_error(A, x, b)
_dense_backward_error(A, x, b) -> Real

Compute norm(A*x-b, Inf) / (norm(A, Inf)*norm(x, Inf) + norm(b, Inf)) without allocating a residual vector. The denominator is floored at the real scalar type's smallest positive normal value.

function _finalise_monomial!(W::Parametrisation{ORD, NVAR, T}, R::ReducedDynamics{ROM, NVAR, T}, idx::Int64, ctx::CohomologicalContext{T, ORD, ORDP1, NVAR, FOM, LT} where LT, s, resonance::StaticArraysCore.SVector{ROM, Bool}, external_dynamics, lower_order_couplings) where {ORD, NVAR, T, ROM, FOM, ORDP1}
_finalise_monomial!(W, R, idx, ctx, s, resonance, external_dynamics,
	lower_order_couplings) -> nothing

Unpack the bordered solution into the primary coefficients of W and the resonant rows of R, write exact zeros to non-resonant master rows, and propagate the higher derivative coefficients using the generalised right eigenmodes.

function _monomial_metrics(::MORFE.CohomologicalEquations._NoMonomialInstrumentation, ::Nothing, ::Nothing)
_monomial_metrics(instrumentation, rhs_result, solve_result)

Convert instrumentation-specific phase results into the metrics delivered to solve observers. The production path returns nothing.

function _resonance_vector(resonance_set::ResonanceSet, monomial_idx::Int64, ::Val{ROM}) where ROM
_resonance_vector(resonance_set, monomial_idx, ::Val{ROM}) -> SVector{ROM, Bool}

Return a compile-time-sized boolean vector indicating which of the ROM master modes are resonant with the monomial at position monomial_idx in the multiindex set. Using Val{ROM} enables the compiler to emit a fully unrolled ntuple loop.

function _run_single_monomial!(instrumentation, W::Parametrisation{ORD, NVAR, T}, R::ReducedDynamics{ROM, NVAR, T}, idx::Int64, ctx::CohomologicalContext{T, ORD, ORDP1, NVAR, FOM, LT, MT}, model::NthOrderModel, ml_cache::MORFE.MultilinearTerms.MultilinearTermsCache) where {ORD, NVAR, T, ROM, FOM, ORDP1, LT, MT} _run_single_monomial!(instrumentation, W::Parametrisation{ORD, NVAR, T}, R::ReducedDynamics{ROM, NVAR, T}, idx::Int64, ctx::CohomologicalContext{T, ORD, ORDP1, NVAR, FOM, LT, MT}, model::NthOrderModel, ml_cache::MORFE.MultilinearTerms.MultilinearTermsCache, reuse_factor::Val{REUSE}) where {ORD, NVAR, T, ROM, FOM, ORDP1, LT, MT, REUSE} _run_single_monomial!(instrumentation, W::Parametrisation{ORD, NVAR, T}, R::ReducedDynamics{ROM, NVAR, T}, idx::Int64, ctx::CohomologicalContext{T, ORD, ORDP1, NVAR, FOM, LT, MT}, model::NthOrderModel, ml_cache::MORFE.MultilinearTerms.MultilinearTermsCache, reuse_factor::Val{REUSE}, superharmonic) where {ORD, NVAR, T, ROM, FOM, ORDP1, LT, MT, REUSE}
_run_single_monomial!(instrumentation, W, R, idx, ctx, model, ml_cache,
	reuse_factor, superharmonic = nothing) -> metrics

Canonical per-monomial pipeline shared by production and benchmark execution. Only nonlinear-right-hand-side assembly and the bordered solve are instrumentation hooks; preparation and coefficient finalisation stay outside benchmark timings.

Grouped execution supplies one canonical superharmonic for the entire structural factor group. Direct and public single-monomial execution leave it as nothing and compute the value from the monomial itself.

function _solve_monomial!(ctx::CohomologicalContext{T, ORD, ORDP1, NVAR, FOM, LT, MT}, index::Int64, multiindex, s, resonance::StaticArraysCore.SVector{ROM, Bool}, lower_order_couplings, external_dynamics) where {T, ORD, ORDP1, NVAR, FOM, LT, MT<:SparseMatrixCSC, ROM} _solve_monomial!(ctx::CohomologicalContext{T, ORD, ORDP1, NVAR, FOM, LT, MT}, index::Int64, multiindex, s, resonance::StaticArraysCore.SVector{ROM, Bool}, lower_order_couplings, external_dynamics) where {T, ORD, ORDP1, NVAR, FOM, LT, MT, ROM} _solve_monomial!(ctx::CohomologicalContext{T, ORD, ORDP1, NVAR, FOM, LT, MT}, index::Int64, multiindex, s, resonance::StaticArraysCore.SVector{ROM, Bool}, lower_order_couplings, external_dynamics, reuse_factor::Val{REUSE}) where {T, ORD, ORDP1, NVAR, FOM, LT, MT<:SparseMatrixCSC, ROM, REUSE} _solve_monomial!(ctx::CohomologicalContext{T, ORD, ORDP1, NVAR, FOM, LT, MT}, index::Int64, multiindex, s, resonance::StaticArraysCore.SVector{ROM, Bool}, lower_order_couplings, external_dynamics, reuse_factor::Val{REUSE}) where {T, ORD, ORDP1, NVAR, FOM, LT, MT, ROM, REUSE}
_solve_monomial!(ctx, index, multiindex, s, resonance,
	lower_order_couplings, external_dynamics)

Dense path. Assemble the (FOM+ROM) bordered system via _assemble_bordered_system!, then solve it in-place with lu! + ldiv!. The solution is written into ctx.buffers.rhs[1:FOM+ROM].

_solve_monomial!(ctx, index, multiindex, s, resonance,
	lower_order_couplings, external_dynamics)

Sparse path (dispatched when MT <: SparseMatrixCSC). Writes the bordered matrix into the constant-pattern template held by ctx.sparse_solver and solves it with at most one new numeric factorisation. When reuse_factor == Val(true), the existing factorisation is reused because the caller has established exact matrix identity.

The (1,1) block is evaluated by the untouched build_sparse_L_and_rhs! Horner pass on its own workspace — it needs the transient intermediates L[j](s) to accumulate the lower-order RHS — and is then scattered into the template. The border blocks are staged in ctx.buffers.orthogonality_rows by the same assembly routine the dense path uses, then scattered into their strided positions in nzval.

Results are written into ctx.buffers.rhs[1:FOM+ROM].

function _solve_prepared_system!(::MORFE.CohomologicalEquations._NoMonomialInstrumentation, ctx, index, multiindex, s, resonance, lower_order_couplings, external_dynamics, reuse_factor::Val)
_solve_prepared_system!(instrumentation, ctx, index, multiindex, s,
	resonance, lower_order_couplings, external_dynamics, reuse_factor)

Instrumentation hook for the bordered solve after all monomial-dependent inputs have been prepared. reuse_factor may be true only for an exactly identical grouped matrix.

function _superharmonic(multi, lambda_diag)

Return the monomial superharmonic sum(alpha[i] * lambda_diag[i]).

function solve_single_monomial!(W, R, idx::Int64, ctx, model, ml_cache) solve_single_monomial!(W, R, idx::Int64, ctx, sym, model, ml_cache, reuse_factor::Bool) solve_single_monomial!(W, R, idx::Int64, ctx, sym, model, ml_cache) solve_single_monomial!(W, R, idx::Int64, ctx, sym, model, ml_cache, reuse_factor::Val)
solve_single_monomial!(W, R, idx, ctx, model, ml_cache) -> nothing

Solve the cohomological equations for one multiindex-set position, updating W and R.

Operational workflow for constructing a parametrisation and reduced dynamics.

This internal submodule owns the work surrounding the mathematical cohomological equation: configuration, W/R storage preparation, checkpoint restoration and commits, conjugate reconstruction, causal monomial scheduling, progress, and benchmarking. It delegates each individual cohomological equation to CohomologicalEquations and delegates sparse factorisation to BorderedLinearSolvers.

Source organisation

FilePurpose
Configuration.jlUser-facing execution and checkpoint options
Checkpointing.jlValidated, atomic checkpoint persistence and restoration
ConjugateSymmetry.jlConjugate-pair bookkeeping and coefficient reconstruction
ProgressIndicator.jlAllocation-conscious terminal progress reporting
SolveSchedule.jlCausal jobs and exact structural factor groups
SolveExecution.jlTyped observers and plan execution
Benchmarking.jlTiming instrumentation and CSV reporting
SolutionStorage.jlCreation, reuse, initialisation, and checkpoint restoration of W/R
ExternalDirections.jlOrdered external solves and conjugate-block validation
SolvePreparation.jlBackend, operator, symmetry, resources, and complete context
SolveProblem.jlShort top-level phase orchestration

The module root contains only dependencies, includes, and exports so that these responsibilities remain visible rather than accumulating in one driver file.

CheckpointOptions(path; problem_id, resume = true, granularity = :factor_group)

Configure durable checkpoints for parametrise. Pass the resulting object as the checkpoint field of ParametrisationOptions.

Arguments

  • path::AbstractString — checkpoint directory. MORFE creates it and stores a manifest plus checksummed coefficient chunks beneath it.

  • problem_id::AbstractString — required, non-empty identifier for the physical problem. It is checked when resuming so that a checkpoint cannot silently be used for a different run.

  • resume::Bool = true — restore compatible completed work found at path. With resume = false, MORFE refuses an existing checkpoint manifest instead of overwriting it; use a new directory for a fresh run.

  • granularity::Symbol = :factor_group — when data are committed:

    • :factor_group writes after every exact factor-reuse group, giving finer restart points and more chunk files;

    • :degree writes once after each completed total degree, giving fewer, larger chunks.

A checkpoint is accepted only when its fingerprint matches the model, spectral data, resonance set, multiindex set, conjugate permutation, and numerical solver policy. Models containing application-defined callable kernels must implement checkpoint_fingerprint_data before they can be checkpointed.

Example

checkpoint = CheckpointOptions("checkpoints/beam-order-9";
	problem_id = "clamped-beam-v1",
	granularity = :degree)

options = ParametrisationOptions(checkpoint = checkpoint)
W, R = parametrise(model, spectral, 9; options)
CheckpointSession

Mutable state for one checkpoint directory: the validated CheckpointOptions, the problem fingerprint, and the most recently committed manifest. The manifest is refreshed under the writer lock after every chunk or degree commit.

Fields

  • options::CheckpointOptions — checkpoint path, resume policy, problem identifier, and commit granularity for this session.

  • fingerprint::String — SHA-256 identity of the mathematical problem and numerical policy against which every resumed manifest is checked.

  • manifest::Dict{String, Any} — in-memory copy of the last atomically committed manifest.

ConjugateSymmetryData{CP}

Self-contained optimisation layer for exploiting complex-conjugate symmetry in the cohomological solve.

Type parameters

ParameterMeaning
CPNoConjugatePermutation (inactive) or SVector{NVAR, Int} (active)

Compile-time dispatch on CP eliminates secondary-monomial bookkeeping when inactive. When active, secondary monomials (those whose conjugate partner has a lower multiindex-set index) are marked in skip_bits and skipped in the outer solve loop; their coefficients are filled by fill_conjugate_monomial!.

Fields

  • permutation::CP — the involutory mode permutation P swapping conjugate pairs, or NoConjugatePermutation when the optimisation is off.

  • monomial_map::Vector{Int} — monomial_map[i] is the position of the conjugate monomial P·γ for γ = mset[i], or 0 when it falls outside the multiindex set.

  • skip_bits::BitVector — length L; true marks a monomial the outer loop must not solve. Covers both the linear monomials (already known from eigenvectors) and the secondary monomials obtained by conjugation, so the loop needs one test rather than two.

NoConjugatePermutation

Sentinel type indicating that conjugate-symmetry exploitation is disabled. Dispatch on this type eliminates all symmetry bookkeeping at compile time.

ParametrisationOptions(;
	backend = :auto,
	grouping = :auto,
	residual_check = :off,
	residual_tolerance = nothing,
	max_refinement_steps = 3,
	validate_mset = true,
	checkpoint = nothing,
	show_progress = true,
	verbose = true,
	setup_io = stderr)

Example of operational controls passed to parametrise through its options keyword:

options = ParametrisationOptions(
	backend = :klu,
	residual_check = :backward_error,
	residual_tolerance = 1e-10,
	show_progress = false)

W, R = parametrise(model, spectral, expansion_order; options)

The fields below are constructor keywords of ParametrisationOptions; they are not direct keywords of parametrise. Mathematical choices (resonance and conjugate_permutation) deliberately remain explicit keywords of parametrise.

Solver options

  • backend::Symbol = :auto — linear solver for the bordered cohomological systems:

    • :auto uses dense LU for dense full-order matrices; for sparse matrices it uses

Pardiso when a Pardiso extension is active and otherwise KLU;
  • :klu requires sparse full-order matrices and forces KLU;

  • :umfpack requires sparse full-order matrices and forces Julia's built-in

SuiteSparse UMFPACK solver;
  • :pardiso requires sparse full-order matrices and an active Pardiso extension,

otherwise construction of the solver fails.
  • grouping::Symbol = :auto — exact reuse of a factorisation by monomials whose cohomological matrices are structurally identical:

    • :auto groups only when repeated or zero eigenvalues make reuse possible and grouping

actually reduces the number of factorisations;
  • :on always builds and processes the exact structural groups;

  • :off solves directly in graded-lexicographic order without grouping.

Grouping is exact; it never clusters approximately equal eigenvalues. Each group uses its first monomial's superharmonic consistently for every member.

  • residual_check::Symbol = :off — :backward_error verifies every bordered solve; :off skips this additional check.

  • residual_tolerance::Union{Nothing, Real} = nothing — positive backward-error limit. It is used only with residual_check = :backward_error. nothing selects the scalar-type-aware default sqrt(eps(real(T))) / 100 (approximately 1.49e-10 for Float64).

  • max_refinement_steps::Integer = 3 — maximum number of iterative-refinement corrections after a failed backward-error check on the dense, KLU and UMFPACK paths. Must be non-negative. A solve still throws if it remains outside the tolerance.

Validation and restart options

  • validate_mset::Bool = true — validate the multiindex set before solving: dimensions, allowed degrees, required master unit vectors, downward closure, and (when active) conjugate closure. Set this to false only when the same set has already been validated.

  • checkpoint::Union{Nothing, CheckpointOptions} = nothing — durable checkpoint and resume policy. nothing disables checkpoint I/O; see CheckpointOptions.

Output options

  • show_progress::Bool = true — show the in-place monomial progress indicator on stderr when it is an interactive terminal. It remains silent in redirected output and CI logs.

  • verbose::Bool = true — print the model/setup summary before building the resonance set. With the default setup_io = stderr, the summary is printed only when stderr is interactive; an explicitly supplied output stream is always honoured.

  • setup_io::IO = stderr — destination for the setup summary controlled by verbose. This does not redirect the progress indicator, which always uses stderr.

Common configurations

Quiet run:

options = ParametrisationOptions(show_progress = false, verbose = false)

Verified sparse solve with automatic exact factor reuse:

options = ParametrisationOptions(
	backend = :auto,
	grouping = :auto,
	residual_check = :backward_error,
	residual_tolerance = 1e-10,
	max_refinement_steps = 3)

All symbolic options are validated when ParametrisationOptions is constructed, so a misspelling such as backend = :suitesparse or grouping = :approximate fails immediately.

StructuralFactorKey{NVAR, ROM}

Exact mathematical identity of a bordered matrix for factor-reuse grouping.

Fields

  • exponents::SVector{NVAR, Int} — monomial powers accumulated by equal nonzero eigenvalue; powers belonging to zero eigenvalues do not affect the superharmonic.

  • resonance::SVector{ROM, Bool} — complete master-mode border mask.

_AbstractSolveObserver

Internal lifecycle interface notified after jobs, factor groups, completed degrees, and the complete plan. Concrete observers keep progress, checkpointing, and benchmarking out of the numerical executor.

Internal dispatch root for direct and exact-factor-grouped solve plans.

_BenchmarkInstrumentation

Instrumentation marker that times nonlinear right-hand-side assembly and the bordered solve while leaving preparation and coefficient finalisation outside the measurements.

_BenchmarkSolveObserver{M}

Accumulate per-monomial and per-degree benchmark rows in memory and write them after a successful solve.

Fields

  • mset::M — multiindex set used to render monomial exponents.

  • monomial_io::IOBuffer — buffered per-monomial CSV rows.

  • order_io::IOBuffer — buffered per-degree CSV rows.

  • benchmark_dir::String — destination directory for both CSV files.

  • cumulative_time::Float64 — cumulative measured RHS and solve time.

  • current_order::Int — degree of the most recently measured job.

  • order_accumulator::_OrderAccum — reusable totals for the current degree.

_CheckpointSolveObserver{S, W, R, M, SS}

Commit coefficient chunks and degree markers at lifecycle boundaries.

Fields

  • session::S — active CheckpointSession.

  • W::W — parametrisation whose completed coefficient slices are persisted.

  • R::R — reduced dynamics persisted alongside W.

  • mset::M — multiindex set used to collect all indices in degree-granularity mode.

  • sparse_solver::SS — sparse solver diagnostics, or nothing on the dense path.

_CompositeSolveObserver{A, B}

Forward every lifecycle event to two observers in order.

Fields

  • first::A — observer notified first.

  • second::B — observer notified after first completes.

_DirectSolvePlan

Causal jobs executed as independent singleton factorisation groups.

Fields

  • jobs::Vector{_SolveJob} — solve jobs in graded-lexicographic causal order.

_GroupedSolvePlan{T}

Degree-local groups whose jobs share exactly identical bordered matrices.

Fields

  • groups::Vector{_SolveGroup{T}} — ordered factor-reuse groups, never spanning degrees.

  • n_jobs::Int — total primary solves across all groups, used for progress reporting.

No-op solve observer used when no optional lifecycle action is requested.

_OrderAccum

Mutable per-degree benchmark totals, reset and reused between polynomial orders.

Fields

  • n_solved::Int — primary monomials measured in the current degree.

  • rhs_time::Float64 — cumulative nonlinear right-hand-side assembly time.

  • rhs_bytes::Int — cumulative allocations during right-hand-side assembly.

  • solve_time::Float64 — cumulative bordered-solve time.

  • solve_bytes::Int — cumulative allocations during bordered solves.

_ProgressSolveObserver

Track completed primary jobs and update the terminal progress indicator.

Fields

  • progress::_SimpleProgress — configured terminal progress state.

  • n_done::Int — number of primary jobs reported so far.

_SimpleProgress

Lightweight progress state for the -based terminal progress indicator.

Fields

  • n_total::Int — number of monomials that will actually be solved, which is fewer than the multiindex-set size whenever linear or conjugate-secondary monomials are skipped. The reported fraction is against this, not against the set size.

  • enabled::Bool — false when stderr is not a TTY, so CI logs stay clean without every call site having to test for it.

  • max_nl_degree::Int — highest nonlinearity degree in the model. Work per monomial grows with degree, so the fraction is raised to this power to make the displayed percentage track elapsed time rather than monomial count.

_SolveGroup{T}

Jobs sharing one canonical superharmonic and exactly the same bordered matrix.

Fields

  • superharmonic::T — common value of s = dot(alpha, lambda_diag) used to assemble and factor the first job.

  • jobs::Vector{_SolveJob} — jobs that reuse that numeric factorisation in causal order.

type _SolveJob
_SolveJob

A primary monomial solve and its optional conjugate reconstruction target.

Fields

  • index::Int — multiindex-set position solved by the cohomological system.

  • conjugate_target::Int — secondary position filled by conjugation afterwards, or 0 when no reconstruction is required.

function _announce_conjugate_symmetry(::NoConjugatePermutation) _announce_conjugate_symmetry(::StaticArraysCore.SVector)
_announce_conjugate_symmetry(permutation)

Emit the conjugate-symmetry assumptions once when a concrete conjugate permutation is active. The no-symmetry method is a compile-time no-op.

function _atomic_manifest(path, manifest)
_atomic_manifest(path, manifest) -> nothing

Write a TOML manifest to a process-specific temporary file and atomically replace path only after the complete document has been flushed.

function _build_complete_context(W, dimensions::NTuple{5, Int64}, unit_offset::Int64, linear_terms, master_right_modes, lambda_matrix, lambda_diag, invariance_C_coeffs, master_derivative_steps, orthogonality_J_coeffs, right_master_blocks, resonance_set, linear_skip_set, lower_order, buffers, sparse_solver)
_build_complete_context(W, dimensions, unit_offset, ...)

Read the final external directions from W, build the complete external operators, and return the context used by the nonlinear solve.

function _build_conjugate_symmetry(::NoConjugatePermutation, linear_skip_set::Set{Int64}, L::Int64) _build_conjugate_symmetry(perm::StaticArraysCore.SVector{NVAR, Int64}, linear_skip_set::Set{Int64}, mset::MultiindexSet{NVAR}, mdict::Dict{StaticArraysCore.SVector{NVAR, Int64}, Int64}) where NVAR
_build_conjugate_symmetry(perm_or_sentinel, linear_skip_set, ...) -> ConjugateSymmetryData

Factory for ConjugateSymmetryData.

function _build_context(linear_terms::NTuple{ORDP1, MT}, generalised_right_eigenmodes::Matrix{T}, lambda_diag::Vector{T}, inv_ops::InvarianceOperators{T}, orth_ops::OrthogonalityOperators{T}, resonance_set::ResonanceSet, linear_skip_set::Set{Int64}, lower_order::LowerOrderResources{NVAR, T}, buffers::CohomologicalBuffers{T}, sparse_solver) where {ORDP1, MT, T, NVAR}
_build_context(linear_terms, generalised_right_eigenmodes, lambda_diag,
			   inv_ops, orth_ops, resonance_set, linear_skip_set,
			   lower_order, buffers, sparse_solver) -> CohomologicalContext

Construct a CohomologicalContext from pre-assembled operator data and shared resources. All type parameters are inferred from the arguments.

function _build_monomial_map(mset::MultiindexSet{NVAR}, perm::StaticArraysCore.SVector{NVAR, Int64}, mdict::Dict{StaticArraysCore.SVector{NVAR, Int64}, Int64}) where NVAR
_build_monomial_map(mset, perm, mdict) -> Vector{Int}

Build the monomial conjugate map induced by the mode permutation perm. monomial_map[i] is the index in mset of the monomial P·γ where γ = mset[i] and (P·γ)[k] = γ[perm[k]]. Returns 0 when P·γ is not in mset.

function _build_solve_jobs(sym::ConjugateSymmetryData)
_build_solve_jobs(sym) -> Vector{_SolveJob}

Build jobs from the current skip mask. Checkpoint restoration and external-direction initialisation mutate that mask after symmetry discovery, so jobs must not be cached in ConjugateSymmetryData.

function _build_solve_plan(ctx, sym, mset, grouping::Symbol, ::Val{ROM}) where ROM
_build_solve_plan(ctx, sym, mset, grouping, Val(ROM)) -> _AbstractSolvePlan

Create a causal direct or grouped plan. Grouping is exact and never crosses a total degree. :auto returns the direct plan unless structural reuse is possible and actually reduces the number of numeric factorisations.

function _check_external_conjugate_block(conjugate_permutation, sys, ROM::Int64, NVAR::Int64)
_check_external_conjugate_block(conjugate_permutation, sys, ROM, NVAR)

Reject a conjugate_permutation whose external block disagrees with the external system's own conjugate involution.

Only checked when the system was re-based (external_basis(sys) !== nothing), because that is the only situation in which a hand-written permutation can be stale: the caller's external indices then refer to coordinates r′ that the constructor chose, not the ones they wrote down. For every system left in its own coordinates this is a no-op, so no existing model can trip it.

Getting this wrong is silent — fill_conjugate_monomial! would fill external monomials from the wrong partner — hence an error rather than a warning. Use full_conjugate_permutation to build the vector instead of writing it by hand.

function _commit_initial_degree!(session, W, R, mset, sparse_solver, completed_degree::Int64)

Commit degree-one coefficients once external directions are final.

function _completed_degrees(session::MORFE.ParametrisationSolver.CheckpointSession)
_completed_degrees(session) -> Vector{Int}

Return the polynomial degrees recorded as completely committed in session.manifest. The returned vector is converted to Int for consistency across TOML implementations.

function _completed_indices(job::MORFE.ParametrisationSolver._SolveJob) _completed_indices(group::Vector{MORFE.ParametrisationSolver._SolveJob})
_completed_indices(job_or_group) -> Vector{Int}

Return the sorted, unique coefficient positions made final by a job or factor group, including reconstructed conjugate secondaries.

function _compose_observers(a::MORFE.ParametrisationSolver._NoSolveObserver, b::MORFE.ParametrisationSolver._NoSolveObserver) _compose_observers(a::MORFE.ParametrisationSolver._NoSolveObserver, b::MORFE.ParametrisationSolver._AbstractSolveObserver) _compose_observers(a::MORFE.ParametrisationSolver._AbstractSolveObserver, b::MORFE.ParametrisationSolver._NoSolveObserver) _compose_observers(a::MORFE.ParametrisationSolver._AbstractSolveObserver, b::MORFE.ParametrisationSolver._AbstractSolveObserver)
_compose_observers(a, b) -> _AbstractSolveObserver

Elide no-op observers and otherwise return a composite that preserves notification order.

function _digest_text!(ctx, value)
_digest_text!(ctx, value) -> nothing

Append a textual fingerprint token and a zero-byte separator to the SHA context. The separator prevents adjacent values from producing ambiguous concatenations.

function _eigenvalue_representatives(lambda_diag)
_eigenvalue_representatives(lambda_diag) -> Vector{Int}

Assign equal nonzero eigenvalues the same representative index and assign zero eigenvalues the sentinel 0. The result defines the exact exponent aggregation used by factor keys.

function _embed_external_dynamics!(R::ReducedDynamics{ROM, NVAR, T}, external_polynomial::DensePolynomial{T, N_EXT, 2, A} where A<:AbstractMatrix{T}, mset::MultiindexSet{NVAR}) where {ROM, NVAR, T, N_EXT}
_embed_external_dynamics!(R, external_polynomial, mset)

Copy coefficients from the N_EXT-variable external polynomial into the last N_EXT rows of R, embedding them in the full NVAR = ROM + N_EXT multiindex set. Throw when a non-zero external coefficient has no destination, since silently dropping it would change the external dynamics.

function _execute_benchmarked_schedule!(W::Parametrisation{ORD, NVAR, T}, R::ReducedDynamics{ROM, NVAR, T}, ctx::CohomologicalContext{T, ORD, ORDP1, NVAR, FOM, LT, MT}, sym::ConjugateSymmetryData, model::NthOrderModel, ml_cache::MORFE.MultilinearTerms.MultilinearTermsCache; benchmark_dir, show_progress) where {ORD, NVAR, T, ROM, FOM, ORDP1, LT, MT}
_execute_benchmarked_schedule!(W, R, context, symmetry, model, cache;
	benchmark_dir, show_progress = true) -> nothing

Run the causal cohomological solve while timing nonlinear right-hand-side assembly and the bordered linear solve separately. On successful completion it overwrites two files:

  • benchmark_per_monomial.csv contains order, monomial index and exponents, time and allocations for each measured phase, monomial total, and cumulative measured time.

  • benchmark_per_order.csv aggregates those quantities by polynomial degree and records live GC bytes when each order is flushed.

Rows are buffered in memory and written only after the solve completes. The measured time does not include lower-order coupling assembly, coefficient unpacking, higher-derivative updates, progress output, or CSV writing. This diagnostic loop supports conjugate filling but deliberately does not use structural factor grouping or checkpoint callbacks.

benchmark_dir is required and is created when necessary. show_progress follows the ordinary schedule's TTY behaviour.

function _execute_job!(instrumentation, observer, W, R, job, degree, ctx, sym, model, ml_cache, reuse_factor::Val) _execute_job!(instrumentation, observer, W, R, job, degree, ctx, sym, model, ml_cache, reuse_factor::Val, superharmonic)
_execute_job!(instrumentation, observer, W, R, job, degree, ctx, sym, model,
	ml_cache, reuse_factor, superharmonic = nothing) -> nothing

Run one canonical monomial pipeline, reconstruct its optional conjugate, and notify the observer only after both coefficient positions are final.

function _execute_problem_schedule!(W, R, ctx, symmetry, model, cache, mset, sparse_solver, checkpoint_session, benchmark_dir, options)

Execute benchmark or ordinary scheduling with the appropriate concrete observer.

function _execute_solve_plan!(plan::MORFE.ParametrisationSolver._DirectSolvePlan, instrumentation, observer, W, R, ctx, sym, model, ml_cache) _execute_solve_plan!(plan::MORFE.ParametrisationSolver._GroupedSolvePlan, instrumentation, observer, W, R, ctx, sym, model, ml_cache)
_execute_solve_plan!(plan, instrumentation, observer, W, R, ctx, sym, model,
	ml_cache) -> nothing

Execute a direct or grouped plan in causal degree order. The grouped overload factorises the first job in each group and passes Val(true) only to later jobs with a provably identical bordered matrix.

function _execute_solve_schedule!(W::Parametrisation{ORD, NVAR, T}, R::ReducedDynamics{ROM, NVAR, T}, ctx, sym, model, ml_cache; show_progress, grouping, observer, instrumentation) where {ORD, NVAR, T, ROM}
_execute_solve_schedule!(W, R, context, symmetry, model, cache; ...) -> nothing

Build the solve plan, compose progress with the requested observer, and execute the plan. The concrete coefficient-container signature forms the inference boundary directly.

function _file_sha256(path)
_file_sha256(path) -> String

Return the lowercase SHA-256 digest of a checkpoint chunk without loading the complete file into memory.

function _fill_job_conjugate!(W, R, job, ::ConjugateSymmetryData{NoConjugatePermutation}) _fill_job_conjugate!(W, R, job, sym::ConjugateSymmetryData{<:StaticArraysCore.SVector})
_fill_job_conjugate!(W, R, job, sym) -> nothing

Reconstruct a job's conjugate secondary after its primary coefficients are final. This is a compile-time no-op when conjugate symmetry is disabled.

function _fingerprint_value!(ctx, value)
_fingerprint_value!(ctx, value) -> nothing

Recursively append a deterministic, type-aware representation of value to a SHA context. Arrays include their shape, dictionaries are traversed in sorted-key order, and opaque callables must provide checkpoint_fingerprint_data.

function _finish_observer!(::MORFE.ParametrisationSolver._NoSolveObserver) _finish_observer!(observer::MORFE.ParametrisationSolver._ProgressSolveObserver) _finish_observer!(observer::MORFE.ParametrisationSolver._CompositeSolveObserver) _finish_observer!(::MORFE.ParametrisationSolver._CheckpointSolveObserver) _finish_observer!(observer::MORFE.ParametrisationSolver._BenchmarkSolveObserver)

Finish a solve observer after the complete plan has executed successfully.

function _flush_order_row!(order_io::IO, order::Int64, a::MORFE.ParametrisationSolver._OrderAccum, cumul_time::Float64)

Append one completed degree's aggregate benchmark row to order_io.

function _group_solve_jobs(ctx, mset, jobs::Vector{MORFE.ParametrisationSolver._SolveJob}, ::Val{ROM}) where ROM
_group_solve_jobs(ctx, mset, jobs, Val(ROM)) -> Vector{_SolveGroup}

Partition causal jobs into degree-local groups with equal StructuralFactorKey. Group order follows the first occurrence of each key, and job order is preserved within a group.

function _has_structural_factor_reuse(lambda_diag)
_has_structural_factor_reuse(lambda_diag) -> Bool

Return whether zero or repeated eigenvalues make two distinct monomials capable of sharing an identical superharmonic expression and numeric factorisation.

function _initialise_waveform!(W::Parametrisation, R::ReducedDynamics, master_right_modes, master_eigenvalues, master_right_mode_derivatives, unit_offset::Int64, model)
_initialise_waveform!(W, R, master_right_modes, master_eigenvalues,
                      master_right_mode_derivatives, unit_offset, model)

Initialise the linear-monomial coefficients of W and R from spectral data:

  • W[:, 1, eᵣ] = master_right_modes[:, r] and, for ORD > 1, W[:, k, eᵣ] = master_right_mode_derivatives[:, k-1, r].

  • R[r, eᵣ] = master_eigenvalues[r].

Also embeds the external-system linear dynamics into the external rows of R via _embed_external_dynamics! when model.external_system !== nothing.

function _job_target(::ConjugateSymmetryData{NoConjugatePermutation}, ::Int64) _job_target(sym::ConjugateSymmetryData{<:StaticArraysCore.SVector}, index::Int64)
_job_target(sym, index) -> Int

Return the larger conjugate-secondary position reconstructed after solving index, or 0 when symmetry is inactive, the monomial is self-conjugate, or no partner is present.

function _linear_monomial_indices(mset::MultiindexSet{NVAR}) where NVAR
_linear_monomial_indices(mset) -> Vector{Int}

Return the positions of every unit-vector monomial in mset. These are the linear coefficients initialised from eigenvectors or external directions rather than by the main nonlinear solve.

function _lock_path(root)

Return the single-writer lock-directory path for a checkpoint.

function _make_progress(n_total::Int64, show_progress::Bool, max_nl_degree::Int64)
_make_progress(n_total, show_progress, max_nl_degree) -> _SimpleProgress

Construct a _SimpleProgress tracker. max_nl_degree controls the work-weighted percentage. Output is disabled automatically when stderr is not a TTY.

function _make_sparse_solver(::Type{<:SparseArrays.SparseMatrixCSC}, linear_terms, FOM::Int64, ROM::Int64) _make_sparse_solver(::Type{<:AbstractMatrix}, ::Any, ::Int64, ::Int64) _make_sparse_solver(::Type{<:SparseArrays.SparseMatrixCSC}, linear_terms, FOM::Int64, ROM::Int64, options::ParametrisationOptions) _make_sparse_solver(::Type{<:AbstractMatrix}, ::Any, ::Int64, ::Int64, options::ParametrisationOptions)
_make_sparse_solver(MT, linear_terms, FOM, ROM,
	options = ParametrisationOptions()) -> Union{SparseLinearSolverState, Nothing}

Dispatch helper: returns a SparseLinearSolverState when MT <: SparseMatrixCSC (sparse path), or nothing for all other matrix types (dense path). An explicit sparse backend request is rejected for dense matrices; sparse backend selection follows options.

function _manifest_path(root)

Return the manifest path inside a checkpoint directory.

function _mark_degree_complete!(session::MORFE.ParametrisationSolver.CheckpointSession, degree::Int64)
_mark_degree_complete!(session, degree) -> nothing

Commit a degree-completion marker under the writer lock without writing another coefficient chunk. Used after all factor-group chunks for the degree are durable.

function _normalise_manifest_lists!(manifest)
_normalise_manifest_lists!(manifest)

TOML.jl types an empty array from a parsed file as Vector{Union{}} on Julia 1.10 (older Base TOML.jl), whereas newer TOML.jl releases (Julia 1.12+) type it as Vector{Any}. A Vector{Union{}} can never accept a push!, since Union{} has no instances, so an empty chunks or completed_degrees array parsed back from disk would make the very first append fail depending solely on the Julia version in use. Call this right after every TOML.parsefile to make the manifest's list fields concrete and writable regardless of which TOML.jl version produced them.

function _on_degree_complete!(::MORFE.ParametrisationSolver._NoSolveObserver, args...) _on_degree_complete!(::MORFE.ParametrisationSolver._ProgressSolveObserver, args...) _on_degree_complete!(observer::MORFE.ParametrisationSolver._CompositeSolveObserver, args...) _on_degree_complete!(observer::MORFE.ParametrisationSolver._CheckpointSolveObserver, degree) _on_degree_complete!(observer::MORFE.ParametrisationSolver._BenchmarkSolveObserver, degree)

Notify a solve observer after every job in one polynomial degree is complete.

function _on_group_complete!(::MORFE.ParametrisationSolver._NoSolveObserver, args...) _on_group_complete!(::MORFE.ParametrisationSolver._ProgressSolveObserver, args...) _on_group_complete!(observer::MORFE.ParametrisationSolver._CompositeSolveObserver, args...) _on_group_complete!(observer::MORFE.ParametrisationSolver._CheckpointSolveObserver, degree, group) _on_group_complete!(::MORFE.ParametrisationSolver._BenchmarkSolveObserver, args...)

Notify a solve observer after one factor group has been fully committed in memory.

function _on_job_complete!(::MORFE.ParametrisationSolver._NoSolveObserver, args...) _on_job_complete!(observer::MORFE.ParametrisationSolver._ProgressSolveObserver, job, degree, metrics) _on_job_complete!(observer::MORFE.ParametrisationSolver._CompositeSolveObserver, args...) _on_job_complete!(::MORFE.ParametrisationSolver._CheckpointSolveObserver, args...) _on_job_complete!(observer::MORFE.ParametrisationSolver._BenchmarkSolveObserver, job, degree, metrics)
_on_job_complete!(observer, job, degree, metrics)
_on_group_complete!(observer, degree, group)
_on_degree_complete!(observer, degree)
_finish_observer!(observer)

Lifecycle hooks implemented by solve observers. Every execution path emits job events in causal order, one group event after its coefficients (including conjugate secondaries) are final, degree events only after all groups in that degree, and one final event.

function _open_checkpoint(options::CheckpointOptions, fingerprint, metadata)
_open_checkpoint(options, fingerprint, metadata) -> CheckpointSession

Create the directory-format checkpoint or validate an existing resumable manifest against its schema, problem identifier, fingerprint, and byte order. Existing checkpoints are never overwritten when resume == false.

function _open_problem_checkpoint(::Nothing, args...) _open_problem_checkpoint(checkpoint::CheckpointOptions, model, spectral, resonance_set, mset, conjugate_permutation, options::ParametrisationOptions, dimensions::NTuple{5, Int64}, ::Type{T}) where T
_open_problem_checkpoint(checkpoint, model, spectral, resonance_set, mset,
	conjugate_permutation, options, dimensions, T)

Return nothing when checkpointing is disabled. Otherwise calculate the problem fingerprint and open or validate its checkpoint before solver and coefficient storage are allocated. dimensions is (ORD, FOM, ROM, N_EXT, NVAR).

function _prepare_external_directions!(W, R, supplied::Bool, completed_indices, model, cache, symmetry, dimensions::NTuple{5, Int64}, unit_offset::Int64, lambda_matrix, lambda_diag, linear_terms, master_right_modes, invariance_C_coeffs, master_derivative_steps, orthogonality_J_coeffs, right_master_blocks, resonance_set, linear_skip_set, lower_order, buffers, sparse_solver)
_prepare_external_directions!(W, R, supplied, completed_indices, ...)

Solve fresh external linear directions in causal variable order, or accept directions from supplied/restored storage. Every external linear monomial is marked skipped before this function returns.

function _prepare_master_operators(linear_terms, W, R, master_right_modes, master_left_modes, master_left_mode_blocks, unit_offset::Int64, ROM::Int64)
_prepare_master_operators(linear_terms, W, R, master modes and blocks,
	unit_offset, ROM)

Precompute the external-independent orthogonality and invariance operators. Return the orthogonality row coefficients, the master order-block view, the invariance columns, and the master derivative steps.

function _prepare_problem_inputs(model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, LT, MT}, mset::MultiindexSet{NVAR}, spectral::SpectralData{ORD, ROM}, conjugate_permutation, options::ParametrisationOptions) where {ORD, ORDP1, N_NL, N_EXT, LT, MT, NVAR, ROM}
_prepare_problem_inputs(model, mset, spectral, conjugate_permutation, options)

Resolve concrete spectral arrays, validate dimensions and conjugate structure, and return the data required by storage and operator preparation. This is the single setup boundary for SpectralData fields whose higher-order blocks may be nothing.

function _prepare_shared_resources(model, W, mset::MultiindexSet{NVAR}, conjugate_permutation, completed_indices, dimensions::NTuple{5, Int64}, ::Type{MT}, options::ParametrisationOptions) where {NVAR, MT}
_prepare_shared_resources(model, W, mset, conjugate_permutation, completed_indices,
	dimensions, MT, options)

Build symmetry, lower-order coupling resources, the nonlinear cache, and numerical buffers. Restored checkpoint indices are applied to the symmetry skip mask before cache construction. Return (linear_skip_set, lower_order, symmetry, cache, buffers).

function _prepare_solution_storage(initial_solution, mset, model, master_right_modes, master_eigenvalues, master_right_mode_derivatives, dimensions::NTuple{5, Int64}, unit_offset::Int64, ::Type{T}) where T
_prepare_solution_storage(initial_solution, mset, model, master_right_modes,
	master_eigenvalues, master_right_mode_derivatives, dimensions, unit_offset, T)

Return (W, R, supplied). Supplied objects are returned unchanged and by identity. When no storage is supplied, allocate zeroed coefficient arrays and initialise their known linear coefficients from the spectral and external-system data.

function _problem_fingerprint(model, spectral, resonance_set, mset, conj_perm, options::ParametrisationOptions)
_problem_fingerprint(model, spectral, resonance_set, mset, conjugate_permutation,
	options) -> String

Return the SHA-256 identity of all mathematical inputs and numerical solver policy that affect checkpoint compatibility. Presentation and destination options are intentionally excluded. Opaque application callables must implement checkpoint_fingerprint_data.

function _progress_done!(progress::MORFE.ParametrisationSolver._SimpleProgress, completed::Int64)
_progress_done!(progress, completed)

Print the final completion line to stderr and clear trailing characters from the last progress update. This is a no-op when progress is disabled.

function _progress_tick!(progress::MORFE.ParametrisationSolver._SimpleProgress, completed::Int64, degree::Int64)
_progress_tick!(progress, completed, degree)

Print an in-place -overwritten progress line to stderr showing the current polynomial degree and the fraction of monomials solved. This is a no-op when progress is disabled.

function _replace_options(options::ParametrisationOptions; backend, grouping, residual_check, residual_tolerance, max_refinement_steps, validate_mset, checkpoint, show_progress, verbose, setup_io)
_replace_options(options; overrides...) -> ParametrisationOptions

Return a validated copy of options, replacing only the supplied keyword fields. This is the internal equivalent of an immutable record update and preserves the concrete setup_io type when no replacement is requested.

function _reset!(a::MORFE.ParametrisationSolver._OrderAccum)

Reset every per-degree benchmark accumulator field in place.

function _restore_checkpoint!(session::MORFE.ParametrisationSolver.CheckpointSession, W, R; verify_existing)
_restore_checkpoint!(session, W, R; verify_existing = false) -> BitSet

Verify every recorded chunk's presence, checksum, header, shape, and payload boundary, then restore its coefficient slices into W and R. With verify_existing, reject any restored slice that disagrees with the caller-provided initial solution. Return restored indices.

function _restore_solution_checkpoint!(::Nothing, W, R, supplied::Bool) _restore_solution_checkpoint!(session::MORFE.ParametrisationSolver.CheckpointSession, W, R, supplied::Bool)
_restore_solution_checkpoint!(session, W, R, supplied)

Restore committed coefficient slices into the selected storage and return (completed_indices, completed_degree). When supplied is true, every committed slice must already agree with the caller's storage. With no checkpoint, return (nothing, 0).

function _solve_external_directions!(W, R, partial_ctx_for, model, ml_cache, sym::ConjugateSymmetryData{NoConjugatePermutation}, N_EXT::Int64, ROM::Int64, unit_offset::Int64) _solve_external_directions!(W, R, partial_ctx_for, model, ml_cache, sym::ConjugateSymmetryData{<:StaticArraysCore.SVector}, N_EXT::Int64, ROM::Int64, unit_offset::Int64)
_solve_external_directions!(W, R, partial_ctx_for, model, ml_cache,
	symmetry, N_EXT, ROM, unit_offset)

Solve external forcing directions in increasing variable order. Partial contexts contain only directions already solved, as required by upper-triangular external dynamics. Conjugate secondaries are reconstructed immediately, and every external linear monomial is marked skipped before the nonlinear schedule is built.

function _spectral_conjugate_permutation(request, spectral::SpectralData, sys)
_spectral_conjugate_permutation(request, spectral, external_system)

Resolve :from_spectral by taking the master permutation stored in spectral and, when present, appending the conjugate block derived from external_system. Explicit vectors and nothing pass through unchanged.

function _structural_factor_key(multi::StaticArraysCore.SVector{NVAR, Int64}, resonance::StaticArraysCore.SVector{ROM, Bool}, representatives) where {NVAR, ROM}
_structural_factor_key(multi, resonance, representatives) -> StructuralFactorKey

Build the exact factor-reuse key from aggregated monomial powers and the full resonance mask. Equality of these keys implies equality of the bordered matrix, not merely closeness of the floating-point superharmonics.

function _with_checkpoint_lock(f, root)
_with_checkpoint_lock(f, root)

Execute f() while owning the checkpoint directory's single-writer lock. A pre-existing lock is treated as an active writer and raises instead of risking concurrent manifest updates; the lock is released in a finally block.

function _write_benchmark_csvs(mono_io::IOBuffer, order_io::IOBuffer, benchmark_dir::AbstractString)
_write_benchmark_csvs(monomial_io, order_io, benchmark_dir) -> nothing

Create benchmark_dir and write both completed in-memory CSV buffers to their stable filenames.

function _write_benchmark_headers(mono_io::IO, order_io::IO)

Write the fixed per-monomial and per-degree benchmark CSV headers.

function _write_chunk!(session::MORFE.ParametrisationSolver.CheckpointSession, W, R, degree::Int64, indices, sparse_solver; degree_complete)
_write_chunk!(session, W, R, degree, indices, sparse_solver;
	degree_complete) -> nothing

Atomically write a checksummed coefficient slice, then commit its manifest entry under the writer lock. When degree_complete is true, mark the degree complete in the same manifest update. Sparse solver diagnostics are included when available.

function checkpoint_fingerprint_data(::Any)
checkpoint_fingerprint_data(callable)

Return immutable, deterministic data identifying a nonlinear callable used by a checkpointed model. Extend this method for application-defined kernels. The default returns nothing, causing checkpoint creation to reject the opaque callable rather than treating a caller label or a source location as mathematical identity.

function fill_conjugate_monomial!(W::Parametrisation{ORD, NVAR, T}, R::ReducedDynamics{ROM, NVAR, T}, conj_idx::Int64, source_idx::Int64, sym::ConjugateSymmetryData{StaticArraysCore.SVector{NVAR, Int64}}) where {ORD, NVAR, T, ROM}
fill_conjugate_monomial!(W, R, conj_idx, source_idx, sym)

Fill the conjugate monomial at conj_idx from the already-solved source_idx using the W- and f-symmetry relations for a real-valued FOM:

W_{P·γ} = conj(W_γ)
f_{P·β}[r] = conj(f_β[perm[r]])   (master-mode rows only)

External rows of R at conj_idx are left untouched; they are already correct either because they are zero (mixed monomials) or because _embed_external_dynamics! has set them from the conjugate-symmetric external dynamics polynomial.

Precondition: all ORD time-derivative orders of W and master-mode rows (1:ROM) of R at source_idx must be finalised before this is called.

function solve_parametrisation(model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, LT, MT}, mset::MultiindexSet{NVAR}, spectral::SpectralData{ORD, ROM}, resonance_set::ResonanceSet; initial_solution, conjugate_permutation, benchmark_dir, options) where {ORD, ORDP1, N_NL, N_EXT, LT, MT, NVAR, ROM}
solve_parametrisation(
	model, mset, spectral::SpectralData, resonance_set;
	initial_solution = nothing,
	conjugate_permutation = :from_spectral,
	benchmark_dir = nothing,
	options = ParametrisationOptions()
) -> (W, R)

Prepare all workflow data, solve the requested parametrisation coefficients, and return the parametrisation and reduced dynamics.

The spectral input is one object. It replaces five separately maintained inputs: master eigenvalues, master right modes, master left modes, right-mode derivative blocks, and left-mode order blocks. Every former call site had to slice and keep those arrays mutually consistent — including the mirrored right/left block convention, where a swap is type-correct and compiles silently. SpectralData's explicitly named accessors own that convention now, and it is checked numerically by check_biorthogonality.

The external dynamics enter through model.external_system.first_order_dynamics, which _embed_external_dynamics! copies into the external rows of R; the superharmonics s are then contracted against diag(Λ) read back from R, so the external part of s is the diagonal of the external linear matrix. That matrix must be upper triangular — the ExternalSystem constructors guarantee it, re-basing the external coordinates when the supplied matrix is not, as explained in the ExternalSystems module docstring. When that happens the reduced external coordinates returned here are the re-based r′, related to the physical r by r = Q r′ with Q = external_basis(model.external_system). The linear-operator tuple is read from model.linear_terms.

Steps

  1. Select the coefficient storage: allocate and initialise W and R, or use the exact objects supplied through initial_solution.

  2. Restore checkpoint-committed coefficient slices into that storage. A supplied solution must agree with every committed slice; non-zero coefficients alone never count as completed work.

  3. Build symmetry and shared resources after restored indices have entered the skip mask.

  4. Solve the linear cohomological equations for each fresh external forcing direction via a partial context in which the not-yet-solved external columns of the generalised right-eigenmode matrix are set to zero. Supplied or restored external directions are accepted rather than recomputed.

  5. Build the full generalised_right_eigenmodes matrix by concatenating the master right eigenmodes with the solved external right directions. This matrix is stored as CohomologicalContext.generalised_eigenmodes; left eigenmodes enter only through the orthogonality operators.

  6. Assemble the full context and solve every monomial not marked linear, conjugate-secondary, or checkpoint-complete.

Arguments

  • model :: NthOrderModel — full-order model; model.linear_terms provides (B₀,…,B_ORD).

  • mset :: MultiindexSet{NVAR} — multiindex set over all NVAR = ROM + N_EXT variables.

  • spectral :: SpectralData{ORD, ROM} — master eigenvalues, the right physical modes and their derivative blocks, the left physical modes and their orthogonality blocks, and the conjugate involution. Build it with SpectralData(model, spectrum; master = …). The orthogonality row operators are read directly off the left blocks — no eigenvalue folding.

  • resonance_set :: ResonanceSet.

  • initial_solution — optionally supply an already-initialised (W, R) tuple. The solver mutates and returns those same objects, trusts their master and external linear data, and recomputes scheduled nonlinear coefficients. This is storage reuse, not implicit checkpoint completion or an iterative warm start.

  • conjugate_permutation — :from_spectral (default) takes the bundle's master-block involution and extends it over the external variables using the model's external system. Pass an NVAR-length vector to override it — perm[i] = j means mode j is the complex conjugate of mode i — or nothing to disable conjugate symmetry for this solve. A supplied permutation is the caller's assertion about the eigenvectors: two eigenvalues forming a conjugate pair is necessary but not sufficient (the eigenspace must be one-dimensional, or the eigenvectors chosen conjugately). SpectralData's :detect verifies exactly that before returning one.

  • options — execution, validation, residual-verification, and checkpoint policy in a ParametrisationOptions. Mathematical choices remain separate arguments.

  • benchmark_dir — nothing for the normal grouped/checkpoint-capable solve, or a directory in which the benchmark observer writes timing CSV files. Benchmark mode uses the shared direct execution plan and therefore does not perform factor grouping. It cannot be combined with checkpointing because benchmark timings do not form resumable checkpoint commits.

Returns

(W, R) — the solved Parametrisation and ReducedDynamics.

Module ParametrisationMethod — the user-facing entry point to the DPIM parametrisation.

Owns parametrise, the public entry point that turns a full-order model plus spectral data into a solved invariant manifold (W, R). It coordinates the following separate functions, so a new policy is a new method rather than a new branch:

StepFunctionDispatches on
build the monomial setbuild_multiindex_setthe expansion order
build the resonance setbuild_resonance_setthe resonance style
solvesolve_parametrisationdense vs sparse model

The coefficient containers themselves (Parametrisation, ReducedDynamics, …) live in ParametrisationObjects and are re-exported here, so this module remains the single namespace users need.

Load order

This module is included after ParametrisationSolver, whose documented workflow owns solve_parametrisation. The mathematical CohomologicalEquations module and the numerical BorderedLinearSolvers module therefore remain independent of this user-facing entry point. Checkpointing, symmetry, scheduling, progress, and benchmarking do not live in this module.

function build_multiindex_set(mset::MultiindexSet, ::Int64) build_multiindex_set(expansion_order::Integer, nvar::Int64) build_multiindex_set(x, ::Int64)
build_multiindex_set(expansion_order, nvar) -> MultiindexSet

Turn an expansion order into the monomial set the solve will run over. This is the dispatch seam for expansion policies: parametrise leaves its third argument untyped and delegates here, so a new policy is one new method and parametrise never changes.

Two policies ship today:

  • expansion_order::Integer — total-degree truncation, all_multiindices_up_to(nvar, order; min_degree = 1).

  • expansion_order::MultiindexSet — a set the caller built (e.g. the anisotropic z-total × θ-box sets used by parametric ROMs), used exactly as given.

Anything else raises an ArgumentError naming what is accepted, rather than a bare MethodError.

This function builds only; it does not validate. parametrise runs validate_multiindex_set once on the result, whatever its source, then tells the solve to skip its own check so the set is walked exactly once. Validation needs ROM and the conjugate permutation, neither of which an expansion policy has any business knowing.

function parametrise(model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, LT, MT}, spectral::SpectralData{ORD, ROM}, expansion_order; resonance, conjugate_permutation, options) where {ORD, ORDP1, N_NL, N_EXT, LT, MT, ROM}
parametrise(model, spectral::SpectralData, expansion_order;
    resonance = ResonanceConfig(),
    conjugate_permutation = :from_spectral,
    options = ParametrisationOptions()) -> (W, R)

Compute the invariant manifold and its reduced dynamics. This is the entry point.

Two positional arguments carry everything the reduction needs — the full-order model and the spectral data — and the third says how far to expand:

(; model, spectral) = build_model(case)    # or SpectralData(model, spectrum; master = …)
W, R = parametrise(model, spectral, 5)     # total degree ≤ 5
W, R = parametrise(model, spectral, mset)  # a monomial set you built

Arguments

  • model::NthOrderModel — full-order model; supplies the linear operators, the nonlinear terms, and the external system.

  • spectral::SpectralData — master eigenvalues and their right/left blocks, the outer eigenvalues resonance detection reads, and the master-block conjugate involution. Build it with SpectralData(model, spectrum; master = …).

  • expansion_order — an Integer (total-degree truncation) or a MultiindexSet used as given. Dispatch happens in build_multiindex_set, so a new expansion policy is a new method there and never a change here.

Keyword arguments

  • resonance::Union{ResonanceConfig, ResonanceSet} = ResonanceConfig() — either the policy (ResonanceConfig gathers style, tolerances and the outer-target settings) or a ResonanceSet you built yourself, used verbatim.

  • conjugate_permutation = :from_spectral — by default the master-block involution from spectral, extended over the external variables using the model's external system. Pass an explicit NVAR-length vector to override, or nothing to disable conjugate symmetry for this solve.

  • options::ParametrisationOptions = ParametrisationOptions() — backend selection, exact structural grouping, backward-error verification, multiindex validation, durable checkpointing, and presentation controls. Mathematical choices are not hidden in this object. See ParametrisationOptions for every accepted field, value, default, and its effect.

The operational fields are supplied when constructing ParametrisationOptions; they are not direct keywords of parametrise. For example:

options = ParametrisationOptions(
    backend = :auto,
    grouping = :auto,
    residual_check = :backward_error,
    residual_tolerance = 1e-10,
    show_progress = false,
    verbose = false)

W, R = parametrise(model, spectral, 7;
    resonance = ResonanceConfig(style = :complex_normal_form, tol = 0.05),
    options = options)

Thus use parametrise(...; options = ParametrisationOptions(backend = :umfpack)), not parametrise(...; backend = :umfpack). The available explicit sparse backends are KLU, UMFPACK, and Pardiso; see ParametrisationOptions for availability and automatic selection rules.

Returns

(W, R) — the solved Parametrisation and ReducedDynamics.

function print_setup(io::IO, model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T, MT}, spectral::SpectralData{ORD, ROM}, mset::MultiindexSet{NVAR}, resonance) where {ORD, ORDP1, N_NL, N_EXT, T, MT, NVAR, ROM}
print_setup(io, model, spectral, mset, resonance)

Print a summary of what parametrise is about to solve: model size and layout, nonlinear terms, external system, reduced dimensions, master eigenvalues, the monomial count, the resonance policy and the conjugate involution.

A normal function, so a caller who wants a different banner defines a method rather than editing parametrise. It prints only what the arguments genuinely carry: parametrise receives an NthOrderModel, and every backend's model is that same type, so there is nothing backend-specific to dispatch on here. Backends with richer information print their own summary before calling — they already have show methods for it.

Called by parametrise only when the output is going somewhere interactive; see the verbose / setup_io keywords there.

BifurcationKitInterface
function make_bk_problem
make_bk_problem(R::ReducedDynamics; bifparam_index=1, z0=nothing, p0=nothing)

Wrap the reduced dynamics R as a BifurcationKit.BifurcationProblem for continuation and bifurcation analysis.

The reduced dynamics ż = R(z, p) is split into master-mode coordinates z (first ROM variables) and external parameters p (the remaining external_system_size variables). bifparam_index selects which component of p is the continuation parameter.

Requires BifurcationKit.jl. Load it with using BifurcationKit to activate the MORFE extension.

Module InvarianceError — a posteriori validation of the computed invariant manifold.

After solve_parametrisation produces (W, R), this module measures how well the parametrisation satisfies the invariance equation

∂W/∂z · R(z) = F(W(z))

by evaluating both sides on random points z on the manifold and computing the relative residual. Results are plotted as convergence curves against the polynomial order and returned as structured data for further analysis.

InvarianceErrorWorkspace(model, W[, T = ComplexF64])

Reusable storage for invariance_error_residual!. Construct one workspace and reuse it when evaluating many points, for example along a phase orbit. The workspace contains only numerical scratch arrays; it does not retain a reduced coordinate or an external-parameter value.

function _jvp_last_block!(result::AbstractVector, W_poly::DensePolynomial{T, NVAR, N, A} where {N, A<:AbstractArray{T, N}}, z::AbstractVector, v::AbstractVector, pw_buf::AbstractMatrix) where {T, NVAR}
_jvp_last_block!(result, W_poly, z, v, pw_buf)

Accumulate J_{W[:,ORD,:]}(z) · v into result using the analytic Jacobian of the last stored derivative block. pw_buf is a pre-allocated (NVAR, max_exp+1) matrix reused across calls; on entry it is filled with pw_buf[j, e+1] = z[j]^e.

function _log_log_regression(radii, errors; min_points)
_log_log_regression(radii, errors; min_points=5) → (slope, intercept)

OLS fit of log(error) ~ slope·log(radius) + intercept. Points are sorted by radius and removed one at a time from the left (smallest radius first) as long as doing so increases the slope. This adaptively discards points saturated near machine precision without a hard threshold.

Running Σ-updates make each step O(1); total cost O(N). Returns (NaN, NaN) when fewer than min_points points remain.

function invariance_error_convergence(model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T} where T, W::Parametrisation{ORD, NVAR}, R::ReducedDynamics; n_samples, r_magnitudes, r_external, master_amplitude, rng) where {ORD, ORDP1, N_NL, N_EXT, NVAR}
invariance_error_convergence(model, W, R;
							 n_samples = 1000,
							 r_magnitudes = [0.0],
							 r_external = nothing,
							 master_amplitude = 1.0,
							 rng = Random.default_rng())
→ Vector of NamedTuples, one per entry in `r_magnitudes`

For each forcing magnitude |r| in r_magnitudes, draw n_samples points with master-mode coordinates from N(0,1) and external coordinates sampled uniformly on a sphere of radius |r|. Each result NamedTuple contains:

  • radii — ‖z_k‖ for each sample

  • radii_master — ‖(z_k)_master‖ for each sample

  • force_errors — ‖E(z_k)‖₂ (force residual)

  • state_errors — ‖L(s̄)⁻¹ E(z_k)‖₂ (state error estimate)

  • s_bar — median local superharmonic s̄ = median(⟨z,R(z)⟩/⟨z,z⟩)

  • max_order — total degree of the highest monomial in W

  • r_magnitude — the |r| value for this level

  • convergence_rate — OLS log-log slope of force_errors vs radii_master

				   (saturated points auto-trimmed)

Use plot_invariance_convergence to visualise the result.

To validate a physical parameter point, pass its exact reduced external coordinate as r_external. In this mode the external tail is fixed for the entire point cloud and master_amplitude scales only the randomly sampled master coordinates. The manifold, reduced dynamics, and full-order model are all evaluated at that same external point. r_magnitudes must remain [0.0] in fixed-target mode.

function invariance_error_norms(model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T} where T, W::Parametrisation{ORD, NVAR}, R::ReducedDynamics; n_samples, amplitude, r_external, rng) where {ORD, ORDP1, N_NL, N_EXT, NVAR}
invariance_error_norms(model, W, R;
					   n_samples = 1000,
					   amplitude = 1.0,
					   r_external = nothing,
					   rng = Random.default_rng())
→ NamedTuple{(:max, :mean, :rms, :pointwise)}

Evaluate the invariance-equation residual ‖E(z)‖₂ over a Gaussian point cloud in reduced coordinates.

amplitude is the standard deviation of each complex master-mode component (real and imaginary parts drawn i.i.d. from N(0, amplitude²/2)). External coordinates are fixed to zero unless r_external is provided.

function invariance_error_residual!(E::AbstractVector{T}, workspace::InvarianceErrorWorkspace{T, VT, MT} where {VT<:AbstractVector{T}, MT<:AbstractMatrix{T}}, model::NthOrderModel{ORD, ORDP1, N_NL, N_EXT, T} where T, W::Parametrisation{ORD, NVAR}, R::ReducedDynamics, z::AbstractVector; r_external) where {T, ORD, ORDP1, N_NL, N_EXT, NVAR}
invariance_error_residual!(E, workspace, model, W, R, z;
                           r_external = nothing)

Evaluate the vector invariance defect at the reduced coordinate z, without allocating the large full-order scratch arrays on each call. If r_external is supplied, it must equal the external-coordinate tail of z; this explicit check prevents evaluating the manifold and the full-order model at different parameter values.

The returned value is E. Positive or negative signs of the defect are an internal convention; its norm and modal projections are invariant to that convention.

function plot_invariance_convergence(args...; kwargs...)
plot_invariance_convergence(results; kwargs...)

Requires Plots.jl. Load it with using Plots to activate the MORFE extension.

Module RomIO — backend-agnostic persistence for computed ROMs.

save_rom writes the standard result layout every MORFE workflow shares:

dir/
  data/
    W.jls                 — serialised `Parametrisation`
    R.jls                 — serialised `ReducedDynamics`
    R_coefficients.csv    — complex reduced dynamics, one monomial per row
  figures/                — created empty, for downstream plots
  summary.txt             — caller metadata + julia version, git commit, timestamp

The CSV schema is exp_1,…,exp_NVAR,R1_re,R1_im,… with rows whose coefficients are all below drop_below omitted. read_rom_coefficients parses that schema back; it is the entry point every validation script uses.

normal_form_branch is the other direction: it turns a solved ReducedDynamics into the periodic-orbit data a plot needs, without depending on any plotting package.

function _real_roots(p::Vector{Float64})
_real_roots(p) -> Vector{Float64}

Real roots of the polynomial whose coefficients p are given in ascending powers, by companion-matrix eigenvalues.

Trailing near-zero coefficients are trimmed first: they would otherwise make the companion matrix singular and manufacture spurious roots at infinity. A root counts as real when its imaginary part is negligible against its own magnitude, since an exactly real root of a floating-point companion matrix generally comes back with a tiny imaginary part.

function cycle_amplitude(P::DensePolynomial{T, NVAR, 1, A} where A<:AbstractVector{T}, ρ::Real; ...) where {T, NVAR} cycle_amplitude(P::DensePolynomial{T, NVAR, 1, A} where A<:AbstractVector{T}, ρ::Real, external; n_phase) where {T, NVAR}
cycle_amplitude(P, ρ, external = (); n_phase = 4096) -> Float64

Amplitude of the scalar observable P on the periodic orbit of radius ρ: half the peak-to-peak excursion of

real P(ρ·exp(iφ), ρ·exp(-iφ), external…)

over one phase cycle, sampled at n_phase points.

P is a scalar polynomial in the reduced coordinates, as returned by observable_polynomial. external supplies the external coordinates, and must have NVAR - 2 entries; an autonomous ROM needs none.

Why half peak-to-peak, and why over phase

The orbit is z₁ = ρ·exp(iφ) traversed once, so a sweep over φ is a sweep over the period, and this is the physical peak amplitude of the oscillation. Taking the excursion rather than the value at one phase matters as soon as the manifold is curved: harmonics beyond the first make the orbit asymmetric, and a single-phase reading picks an arbitrary point on it.

Because it is an extremum over a full cycle, the result is invariant to the eigenvector gauge. Rescaling the master eigenvector by exp(iψ) multiplies the coefficient of z₁^a z̄₁^b by exp(i(a-b)ψ), which is a rigid shift φ → φ + ψ of the sampled signal and leaves its extrema alone. Raw W coefficients are not comparable between runs when the eigensolver picks a different phase; this quantity is, which makes it the right thing to bless in a reference.

n_phase defaults to the resolution the MORFE website's backbone figures use. The signal is band-limited by the expansion order, so far fewer samples serve for plotting: the error in an extremum falls as n_phase^-2.

See also observable_polynomial, normal_form_branch.

function normal_form_branch(R::ReducedDynamics{ROM, NVAR}; parameter, amplitudes, parameter_range, external_point, sheet, jump) where {ROM, NVAR}
normal_form_branch(R; parameter = nothing,
				   amplitudes = range(0, 3; length = 600),
				   parameter_range = (-Inf, Inf),
				   external_point = nothing,
				   sheet = :all, jump = 1e-3)
	-> (; amplitude, frequency, parameter, stable)

Periodic-orbit data for a reduced dynamics in complex normal form spanned by a single conjugate pair: the limit-cycle branch, or the backbone, as plain vectors ready to plot.

What it computes

With one conjugate pair the polar substitution z₁ = ρ·exp(iθ) removes the phase. Every monomial surviving a complex-normal-form reduction has a - b = 1 in its first two exponents, so z₁^a z̄₁^b η^c contributes ρ^(a+b) exp(iθ) η^c and the first row factors:

R₁(ρ, ρ, η) = ρ · g(ρ, η),      g(ρ, η) = Σ c_{abc} ρ^(a+b-1) η^c

which splits the dynamics into an amplitude equation and a phase equation,

ρ̇ = ρ · Re g(ρ, η)             Ω = θ̇ = Im g(ρ, η)

Everything returned is read off g. A limit cycle is a root of Re g, its frequency is Im g there, and it is stable where ∂ρ(ρ · Re g) < 0.

Two modes

  • parameter = nothing — the backbone. frequency is Im g(ρ) over amplitudes, evaluated at external_point; parameter comes back empty. No root-find is involved, and it needs no external coordinate, so an autonomous ROM (a conservative oscillator, say) is covered.

  • parameter = k — the bifurcation diagram in the k-th external coordinate. For each amplitude the corresponding parameter values are solved for, so a fold in ρ against the parameter is traced without any special handling.

Why it solves for the parameter, not the amplitude

At fixed ρ, Re g(ρ, η) is a real polynomial in η_k, so its roots come from one companion-matrix eigenvalue solve. There is no iteration, no initial guess, no continuation and no step-size control, and a fold is simply a monotone η(ρ).

The other orientation is worse than merely inconvenient. Continuing in the parameter makes ρ the unknown, which is the direction whose series diverges over most of a typical sweep; in examples/05_karman_vortex_street that arrangement once drove an order-7 branch to ρ = 234 before it was abandoned for this one.

This function assumes a single conjugate pair in complex normal form

ROM ≠ 2 is rejected, and so is any first-row monomial with a - b ≠ 1. The second check is the important one: such a monomial keeps a residual exp(i(a-b-1)θ), so R₁/z₁ is not a function of ρ alone and the polar reduction above is invalid. Nothing about the resulting numbers would look wrong, which is why this is an error rather than a warning. A :graph style reduction will fail here by design.

One root per amplitude, or all of them

Re g(ρ, ·) has as many roots as its degree in η_k, and a high-order reduction can put more than one of them inside a physically sensible window. They are different sheets: only the one growing out of the bifurcation is the continuation of the branch, and the others are the truncated series crossing zero again somewhere it has stopped converging.

  • sheet = :all (the default) returns every real root in range, so an amplitude with several roots appears several times. Nothing is decided for you.

  • sheet = :primary follows the sheet the sweep starts on: at each amplitude it keeps the root nearest the previous one, seeded from η = 0, and stops when the nearest root is farther than jump. That gap means the tracked sheet has ended and the next root belongs to a different one, so continuing would splice two branches into one curve. jump is in the parameter's own units; the default suits a coordinate of order 1e-2.

Marching in ρ is what makes this reliable: the sheets are single-valued in amplitude even where they fold in the parameter, so no fold-handling is needed.

Arguments

  • amplitudes — the ρ grid. Zero is fine and gives the linear behaviour.

  • parameter_range — (lo, hi) bounds; roots outside are dropped, as are complex roots.

  • external_point — values of the other external coordinates, defaulting to zeros. In parameter = k mode entry k is ignored, being what is solved for.

  • sheet, jump — branch selection, above.

Returns

Four vectors of equal length. In parameter = nothing mode parameter is empty and the other three follow amplitudes; otherwise each entry is one (ρ, η) point of the branch.

Plotting

MORFE draws nothing. Load a Makie backend and plot the vectors:

using CairoMakie

b = normal_form_branch(R; parameter = 1)
lines(b.parameter, b.amplitude; axis = (xlabel = "η", ylabel = "ρ"))

The cohomological solve is graded, so one curve per truncation order comes from truncating R with restrict_ReducedDynamics_to_degree and calling this again; no re-solve is needed.

See also ReducedDynamics, restrict_ReducedDynamics_to_degree.

function observable_polynomial(W::Parametrisation, i::Integer) observable_polynomial(W::Parametrisation, l::AbstractVector)
observable_polynomial(W, i::Int)            -> DensePolynomial
observable_polynomial(W, l::AbstractVector) -> DensePolynomial

Project a Parametrisation onto a single scalar observable, giving a polynomial in the reduced coordinates (z₁, z̄₁, η…) alone.

i picks one degree of freedom of the state; l applies a linear functional lᵀ u over all of them, which is how an integrated quantity (a lift coefficient, a reaction force) is obtained. Both read the displacement level of W, coefficients(W)[:, 1, :]; the higher derivative levels describe the same manifold and carry no extra positional information.

The point is size. W is (FOM, ORD, L) and FOM is the full model, so evaluating it once per phase sample over an amplitude sweep is out of the question; the projection is done once, on the coefficients, and every later evaluation costs L terms. The product is bilinear, not sesquilinear: transpose, never adjoint. z̄₁ is already carried by its own exponent, so conjugating here would conjugate it twice.

Truncate the result with restrict_polynomial_to_degree to get one observable per expansion order out of a single solve, exactly as restrict_ReducedDynamics_to_degree does for the dynamics.

u = observable_polynomial(W, dof)              # transverse displacement at one node
a = cycle_amplitude.(Ref(u), branch.amplitude) # its amplitude along a branch

See also cycle_amplitude, normal_form_branch.

function read_rom_coefficients(csv::AbstractString)
read_rom_coefficients(csv) -> (exponents::Matrix{Int}, coefficients::Matrix{ComplexF64})

Parse an R_coefficients.csv written by write_rom_coefficients_csv. Returns exponents of size (L, NVAR) (one monomial per row) and coefficients of size (L, NR).

function save_rom(dir::AbstractString, W::Parametrisation, R::ReducedDynamics; external_system, metadata, drop_below)
save_rom(dir, W, R; external_system = nothing,
         metadata = Pair{String, <:Any}[], drop_below = 1e-14)

Write the standard ROM result layout (see module docstring) under dir. metadata pairs are written first into summary.txt, followed by julia_version, the current git commit (when available) and a timestamp.

Pass external_system whenever the model had one. W and R record no external metadata of their own, so without it an archive of a re-based system cannot be mapped back to the physical external coordinates: its external columns and rows are expressed in r′, and only the basis Q recovers r = Q r′. When the system carries a basis it is serialised to data/external_basis.jls and reported in summary.txt; a system that was never re-based writes nothing, since r′ and r coincide.

function write_rom_coefficients_csv(path::AbstractString, exponents, coefficients::AbstractMatrix; drop_below)
write_rom_coefficients_csv(path, exponents, coefficients; drop_below = 1e-14)

Write the standard R_coefficients.csv. exponents is the vector of multiindex exponents (length L); coefficients is the (NR, L) complex matrix. Rows with all |c| ≤ drop_below are omitted.

This module is an extension using Symbolics.jl to introduce a clean and nice looking interface for defining and generating the NthOrderModel and ExternalSystem

function _differential_equations_helper(f!, order::Int64, nvars::Int64; p, ext_vars)
_differential_equations_helper(f!, order::Int, nvars::Int; p = ())

Helper for mirroring DifferentialEquations.jl interface. Used in MORFE.modelfromsymbolics(f, nvars::Int; p = ()).

function _differential_equations_helper_external(f, nvars::Int64; p)
_differential_equations_helper_external(f, order::Int, nvars::Int; p = ())

Helper for mirroring DifferentialEquations.jl interface. Used in MORFE.externalsystemfromsymbolics(f, nvars::Int; p = ()).

function _factor_info(e, F_groups::NTuple{NG, Vector{<:Union{Complex{Num}, Num}}}) where NG
_factor_info(e, F_groups)

Decompose monomial e into a scalar coefficient and a list of (group_index, var_index_in_group, exponent) tuples, one per symbolic factor. For polarization.

function _findgroup(sym, groups::NTuple{NG, Vector{Num}}) where NG
_findgroup(sym, groups)

Helper-function that returns in which group the symbol sym is.

function _findgroup_index(sym, groups::NTuple{NG, Vector{<:Union{Complex{Num}, Num}}}) where NG
_findgroup_index(sym, groups)

Return (group_index, var_index) such that groups[group_index][var_index] equals sym. For polarization.

function _get_taylor_expansion_around_0(expr::Num, all_vars::Vector{Num}, d::Int64) _get_taylor_expansion_around_0(expr::Complex{Num}, all_vars::Vector{Num}, d::Int64)
_get_taylor_expansion_around_0(expr, all_vars, d)

Helper function to determine wether a expression expr is a monome. Used in is_polynomial. Uses the function Symbolics.taylor to calulate a Taylor expansion around zero.

function _monomial_to_MultilinearMap(polarized_monomial::Vector{Union{Complex{Num}, Num}}, polarized_variables::NTuple{ORD, Vector{Vector{Num}}}, multiindex::NTuple{ORD, Int64}; has_ext) where ORD
_monomial_to_MultilinearMap

Defines a MultilinearMap for one monomial.

Keyword arguments

  • has_ext::Bool = false: boolean thet tells the MultilinearMap wether it is an external system or not

function _numeric_eltype(::Type{Num}) _numeric_eltype(::Type{Complex{Num}})
_numeric_eltype

Helper for the extraction of coefficients.

function _to_MyNum(m::Num) _to_MyNum(m::Complex{Num}) _to_MyNum(c::Complex) _to_MyNum(c::Real) _to_MyNum(m)
_to_MyNum

Helpers that define the correct build of MyNum for numbers.

function all_monomials_to_MultilinearMaps(F_by_multiindex_polarized::Dict{NTuple{ORD, Int64}, Vector{Union{Complex{Num}, Num}}}, dict_pol_vars::Dict{NTuple{ORD, Int64}, NTuple{ORD, Vector{Vector{Num}}}}; has_ext) where ORD
all_monomials_to_MultilinearMaps(F_by_multiindex_polarized, dict_pol_vars)

Defines and collects MultilinearMaps in a Tuple for every monomal in F_by_multiindex_polarized.

Arguments

  • F_by_multiindex_polarized: Dictionary that maps multiindices to a polarized monomial vector.

  • dict_pol_vars: Dictionary that maps multiindices to the autmatically generated variables that appear in the polarized monomial.

Keyword arguments

  • has_ext::Bool = false: boolean thet tells the MultilinearMap wether it is an external system or not

function check_all_vars_used(exprs::Vector{Num}, all_vars::Vector{Num}) check_all_vars_used(exprs::Vector{Complex{Num}}, all_vars::Vector{Num})
check_all_vars_used(exprs, all_vars)

Returns variables from all_vars that do not appear in any expression in exprs. Empty vector means all variables are used.

function check_constant_terms(exprs::Vector{NT}, all_vars::Vector{Num}) where NT
check_constant_terms(exprs, all_vars)

Returns a list of indices where exprs[i] has a nonzero constant term. Empty vector means all expressions vanish at the origin.

function check_expr(exprs::Vector{NT}, all_vars::Vector{Num}) where NT
check_expr(exprs, all_vars)

Make some checks wether exprs is correctly defined.

function degree_of_monomial(term::Complex{Num}) degree_of_monomial(term)
degree_of_monomial

Computes the degree of a monomial. Works on a single not vector expression!

function externalsystem_from_symbolics(f, nvars::Int64; p) externalsystem_from_symbolics(exprs::Vector{<:Union{Complex{Num}, Num}}, var::Vector{Num})
MORFE.externalsystem_from_symbolics(exprs, var)

Generates an MORFE.ExternalSystem. Expects the ODE describing the external system in the form

dr/dt var = exprs
MORFE.externalsystem_from_symbolics(f, nvars::Int; p = ())

Mirrors DifferentialEquations.jl's convention of defining ODEs, for the autonomous driver ṙ = E(r) in nvars external states.

Both layouts are accepted:

  1. in-place, mutating the first argument — f(dr, r, p, t)

  2. out-of-place, returning dr — f(r, p, t) -> dr

f must be polynomial in r and must not depend on t. p is passed through to f unchanged, so parameters may be closed over or supplied here.

Note that the expression method externalsystem_from_symbolics(exprs, var) this ultimately calls performs no polynomial or equilibrium-at-origin check: a non-polynomial right-hand side surfaces later as a _findgroup error, and a non-zero constant term is absorbed silently. Those checks run only on the model_from_symbolics path, via check_expr.

function extract_linear_matrices(exprs::Vector{NT}, groups::NTuple{ORDP1, Vector{Num}}) where {NT, ORDP1} extract_linear_matrices(exprs::Vector{NT}, groups::NTuple{ORDP1, Vector{Num}}, ext_var::Vector{Num}) where {NT, ORDP1}
extract_linear_matrices

Assemble linear_terms matrices for NthOrderModel. Inputs are a ODE of the variables defined in groups that is a NTuple, where every part of the Tuple consists of the state variables or the derivatives. e.g: ([z1, z2, z3], [dz1, dz2, dz3], ...) Returns tuple of matrices with B[i] is matric w.r.t. groups[i] so the ith derivative.

function extract_nonlinear_monomials(exprs::Vector{<:Union{Complex{Num}, Num}}, groups::NTuple{ORDP1, Vector{Num}}, linear_terms) where ORDP1 extract_nonlinear_monomials(exprs::Vector{<:Union{Complex{Num}, Num}}, F_groups_ext::NTuple{NG_EXT, Vector{Num}}, linear_terms, groups::NTuple{ORDP1, Vector{Num}}) where {NG_EXT, ORDP1} extract_nonlinear_monomials(exprs::Vector{NT}, groups::NTuple{ORDP1, Vector{Num}}) where {NT, ORDP1}
extract_nonlinear_monomials(exprs, groups, linear_terms)

Calculates the nonlinear part of exprs by substracting linear_terms. Seperates the nonlinear remainder into monomials and saves them in the Vector monomials and generates additional a Vector multideg_monomials that contains the multiindexdegree calculated by `multidegreemonomials`.

extract_nonlinear_monomials(exprs, F_groups_ext, linear_terms, groups)

Variant for NthOrderModel with external variables.

F_groups_ext = (groups[1:end-1]..., ext_var) — the groups used to compute multidegrees of nonlinear monomials. groups is the full derivative-group tuple and is used only to subtract the linear part (via nonlinear_remainder).

The returned multideg_monomials has tuples of length length(F_groups_ext), i.e. one entry per state-derivative group (excl. highest) plus one entry for the external group.

function generate_polynomial(dict::Dict{NTuple{ORD, Int64}, Vector{Union{Complex{Num}, Num}}}, var::Vector{Num}, N::Int64) where ORD
generate_polynomial(dict, var, N)

Generates a MORFE.DensePolynomial from a dictionary calculated from group_monomials.

function get_coefficients(exprs::Vector{<:Union{Complex{Num}, Num}}, var::Vector{Num})
get_coefficients(exprs, var)

Returns the coefficient of a monomial by evaluating every variable in var with 1. Attention: There is no check wether exprs is a monomial or not!

function group_monomials(monomials::Vector, multideg_monomials::Array{Array{NTuple{ORD, Int64}, 1}, 1}, N::Int64) where ORD group_monomials(monomials::Vector, deg_monomials::Vector{Vector{Int64}}, N::Int64)
group_monomials(monomials, multideg_monomials)

Collects monomials of the same multiindex in different components of the vector and defines a mapping F_by_multiindex that maps from the multiindices to the grouped monomial.

group_monomials(monomials, multideg_monomials)

Collects monomials of the same multiindex in different components of the vector and defines a mapping F_by_multiindex that maps from the multiindices to the grouped monomial.

function is_polynomial(exprs::Vector{NT}, all_vars::Vector{Num}) where NT
is_polynomial(exprs, all_vars)

Check whether all expressions in exprs are polynomial in all_vars.

Uses the Taylor ansatz: substitute all vars → ε * var, Taylor-expand in ε around 0 up to the maximum degree found in exprs. If the expression is polynomial of degree ≤ d, the Taylor expansion is exact and the difference to the original is zero. If any transcendental or rational term is present, the Taylor expansion differs from the original.

function model_from_symbolics(f!, order::Int64, nvars::Int64; p) model_from_symbolics(f!, order::Int64, nvars::Int64, f_ext, nvars_ext::Int64; p, p_ext) model_from_symbolics(exprs::Vector{<:Union{Complex{Num}, Num}}, groups::NTuple{ORDP1, Vector{Num}}) where ORDP1 model_from_symbolics(exprs::Vector{<:Union{Complex{Num}, Num}}, groups::NTuple{ORDP1, Vector{Num}}, ext_var::Vector{Num}, ext_exprs::Vector{<:Union{Complex{Num}, Num}}) where ORDP1
model_from_symbolics

Generates NthOrderModel. Inputs are a ODE of the variables defined in groups that is a NTuple, where every part of the Tuple consists of the state variables or the derivatives. e.g: ([z1, z2, z3], [dz1, dz2, dz3], ...). The ODE is supposed to be a vector equal to zero and is inputed in the variable exprs.

MORFE.model_from_symbolics(f!, order, nvars; p = ())

Mirrors DifferentialEquations.jl's convention of defining ODEs, generalised to order order.

f! must be in-place (arity order + 1) and mutates its first argument:

f!(dᵏu, dᵏ⁻¹u, ..., du, u, p, t)

Each dⁱu is a vector of length nvars. A non-mutating f(u, p, t) is not accepted here — only externalsystem_from_symbolics supports that layout.

f! must be polynomial in the state and must not depend on t; p is passed through to f! unchanged, so parameters may be closed over or supplied here.

model_from_symbolics

Generates NthOrderModel where the nonlinear forcing terms may also depend on external variables ext_var (the state of an ExternalSystem).

The multiindices of the resulting MultilinearMaps have ORD + 1 entries: (dx, dẋ, …, d_r) where the last entry counts the degree in the external variables.

Arguments

  • exprs : vector of ODE expressions (= 0), may contain both groups variables and ext_var

  • groups : NTuple of variable groups as in model_from_symbolics

  • ext_var: vector of external/forcing variables (state of the ExternalSystem)

MORFE.model_from_symbolics(f!, order, nvars, f_ext, nvars_ext; p = (), p_ext = ())

Mirrors DifferentialEquations.jl's convention of defining ODEs, generalised to order order = k, for a model coupled to an externalsystem_from_symbolics driver.

f! must be in-place and takes the external state r as one extra argument, after u and before p — so its arity is order + 2:

f!(dᵏu, dᵏ⁻¹u, ..., du, u, r, p, t)

Each dⁱu is a vector of length nvars and r one of length nvars_ext; referencing r in f! is how forcing enters the model. f_ext describes the external dynamics ṙ = E(r) and may be either layout accepted by externalsystem_from_symbolics: in-place f_ext(dr, r, p, t) or out-of-place f_ext(r, p, t) -> dr.

Both right-hand sides must be polynomial and independent of t. p is passed to f! and p_ext to f_ext, unchanged.

function multidegree_of_monomial(term::Complex{Num}, groups::NTuple{NG, Vector{Num}}) where NG multidegree_of_monomial(term, groups::NTuple{NG, Vector{Num}}) where NG
multidegree_of_monomial(term, groups)

Computes the degree of a monomial but separated in to the derivative orders defined in groups.

function nonlinear_remainder(exprs::Vector{NT}, groups::NTuple{ORDP1, Vector{Num}}, B) where {NT, ORDP1}
nonlinear_remainder

returns the nonlinear part of the equation as the same size as exprs with a minus so that it holds: expr: linearterms = - nonlinearremainder Also checks that the nonlinear part is not allowed to depend on the highest derivative variables.

function polarize(F_by_multiindex::Dict{NTuple{ORD, Int64}, Vector{Union{Complex{Num}, Num}}}, F_groups::NTuple{ORD, Vector{<:Union{Complex{Num}, Num}}}, N::Int64) where ORD
polarize(F_by_multiindex, F_groups, N)

Returns dictionary Fbymultiindex but polarized.

F_groups is the tuple of variable groups used for polarization — either groups[1:end-1] (pure state case) or (groups[1:end-1]..., ext_var) (with external forcing). The multiindex length must equal length(F_groups).

function polarize_monomial(term, F_groups::NTuple{NG, Vector{<:Union{Complex{Num}, Num}}}) where NG polarize_monomial(term, F_groups::NTuple{NG, Vector{<:Union{Complex{Num}, Num}}}, mi::Union{Nothing, NTuple{NG, Int64}}) where NG
polarize_monomial(term, F_groups, mi=nothing)

Polarize a single monomial term with respect to F_groups (i.e. groups[1:end-1]).

Every factor v^p, where v is the i-th variable of group g, is replaced by the product of p distinct "slot copies" of v: v_1 * v_2 * ... * v_p. Slots are consumed group-by-group, in the order the factors appear in term.

Returns (polarized_expr, slotvars):

  • polarized_expr :: Num — the polarized monomial (coefficient included).

  • slotvars :: NTuple{NG, Vector{Vector{Num}}} — slotvars[g][s] is the length-N vector of slot-s copies of the variables in F_groups[g] (this is the "x_s" vector you'd pass into eval).

If mi is not given, it is computed via multidegree_of_monomial.

function seperate_into_monomials(expr::Num) seperate_into_monomials(expr::Complex{Num}) seperate_into_monomials(exprs::Vector{<:Union{Complex{Num}, Num}}, var::Vector{Num})
seperate_into_monomials

Seperates nonlinear part in to the seperate mononmials. Works on a single not vector expression!