FhSim  3.1.0
Marine systems simulation
Loading...
Searching...
No Matches
How analytical Jacobians are assembled

FhSim assembles the global Jacobian from individual SimObject Jacobians in a two-part structure:

On-Diagonal Blocks (Block-Diagonal)

Since SimObjects are independent in the ODE function (each computes its own state derivatives), the on-diagonal blocks form a block-diagonal structure:

\[ J_{diag} = \begin{bmatrix} J_1 & 0 & \cdots & 0 \\ 0 & J_2 & \cdots & 0 \\ \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & \cdots & J_n \end{bmatrix} \]

where \( J_k \) is the Jacobian of the k-th SimObject.

Off-Diagonal Blocks (Port Connections)

When stateful SimObjects are connected via ports, the output of one SimObject becomes an input to another, creating off-diagonal Jacobian blocks. These are computed via the chain rule:

\[ \frac{\partial F_{consumer}}{\partial X_{source}} = \frac{\partial F_{consumer}}{\partial u} \cdot \frac{\partial u}{\partial X_{source}} \]

where \( u \) is the port signal. This requires the SimObjects to implement the port Jacobian API (see Cross-Object Port Jacobians below).

Hybrid Assembly (Fill-If-Zero Strategy)

In hybrid mode, the global Jacobian is assembled as follows:

  1. Analytical entries are written first (on-diagonal blocks and port Jacobian blocks).
  2. Numerical finite-difference entries are computed using graph coloring for efficiency.
  3. Numerical entries use a fill-if-zero strategy: they only fill positions in the Jacobian that are still zero after analytical assembly. This ensures analytical entries are never overwritten by numerical approximations.
Note
A consequence of fill-if-zero is that legitimate analytical zeros (entries that are mathematically zero) may be overwritten by numerical values if the numerical assembly targets the same position. This is rare in practice but worth noting.

Hybrid Jacobian Mode

The hybrid Jacobian mode is designed for scenarios where some SimObjects provide analytical Jacobians while others do not. This is particularly useful when:

  • Integrating legacy SimObjects (no OdeJacobian() implementation) with newer analytical models
  • Gradually migrating a model library to support analytical Jacobians
  • Using third-party SimObjects where you cannot modify the source code

How It Works

When <Jacobian Policy="hybrid"> is set (or Policy="auto" and not all SimObjects provide Jacobians), FhSim:

  1. Computes analytical Jacobians for all SimObjects that implement HasJacobians() = true.
  2. Computes analytical port Jacobian blocks for connections where both SimObjects implement HasPortJacobians() = true.
  3. Fills remaining Jacobian entries using numerical finite differences, optimized via graph coloring to minimize the number of ODE function evaluations.

Example: Mixed Legacy + Analytical Model

Consider a system with a legacy OldHydraulicModel (no Jacobian) connected to a new NewMooringModel (with analytical Jacobian):

<SIMULATION>
<Timing TStart="0" TEnd="100"/>
<Integrator Method="BDF">
<StepControl AbsTol="1e-8" RelTol="1e-6"/>
<Jacobian Type="dense" Policy="hybrid"/>
</Integrator>
</SIMULATION>

The NewMooringModel contributes its analytical Jacobian to the on-diagonal block, while the OldHydraulicModel block is filled numerically. Port connections between them are also handled numerically unless both objects implement port Jacobians.

Cross-Object Port Jacobians

When two stateful SimObjects are connected via ports, the receiving object's state derivatives depend on the sending object's states through the port signal. This creates off-diagonal blocks in the global Jacobian that must be computed for correct implicit integration.

The Port Jacobian API

To provide analytical port Jacobians, implement three additional methods:

1. HasPortJacobians()

Return true to indicate that OutputPortJacobian() and InputPortJacobian() are implemented:

bool MySimObject::HasPortJacobians() const override
{
return true;
}

2. OutputPortJacobian()

Computes the Jacobian of an output port with respect to local states: \( \frac{\partial (\text{port\_value})}{\partial X_{local}} \).

void MySimObject::OutputPortJacobian(const std::string& portName, double T,
const double* X, double* dPort_dX, int portSize, int nStates, int index) override
{
// For a 2-state system with a 1-element output port:
// port_value = 3*X[0] + 2*X[1]
// dPort_dX = [3, 2] (row-major, 1 x 2 matrix)
dPort_dX[0] = 3.0; // d(port)/dX[0]
dPort_dX[1] = 2.0; // d(port)/dX[1]
}

The matrix is stored in row-major order with dimensions [portSize x nStates].

Note
If your port output depends on CommonComputations, ensure they are executed before computing the port Jacobian.

3. InputPortJacobian()

Computes the sensitivity of ODE derivatives to an input port value: \( \frac{\partial F}{\partial (\text{input\_port})} \).

void MySimObject::InputPortJacobian(const std::string& portName, double T,
const double* X, double* dF_dInput, int portSize, int nStates) override
{
// For a 2-state system with a 1-element input port u:
// dX[0]/dt = ... + 5*u
// dX[1]/dt = ... - 1*u
// dF_dInput = [5, -1]^T (row-major, 2 x 1 matrix)
dF_dInput[0] = 5.0; // dF[0]/du
dF_dInput[1] = -1.0; // dF[1]/du
}

The matrix is stored in row-major order with dimensions [nStates x portSize].

Chain Rule Assembly

For a port connection from SourceObject to ConsumerObject, the off-diagonal Jacobian block is:

\[ J_{block} = \frac{\partial F_{consumer}}{\partial u} \cdot \frac{\partial u}{\partial X_{source}} \]

FhSim computes this using the two methods above and assembles the result into the global Jacobian at the appropriate row/column offsets.

Example: Two Connected Stateful Objects

[SpringDamper] --force--> [Vessel]
(2 states) (6 states)

The SpringDamper outputs a force port that depends on its 2 states. The Vessel receives this force and its 6 state derivatives depend on it. The off-diagonal block is a 6x2 matrix computed as:

J_block (6x2) = dF_vessel/d(force) (6x1) * d(force)/dX_spring (1x2)

Both objects must implement HasPortJacobians(), OutputPortJacobian(), and InputPortJacobian() for this block to be computed analytically.

Structural Sparsity & Port Dependencies

For efficient numerical Jacobian assembly, FhSim uses structural sparsity information to determine which Jacobian entries are potentially non-zero.

PortDependencyPolicy

The PortDependencyPolicy enum controls how FhSim determines which states an output port depends on:

enum class PortDependencyPolicy {
All,
UserSpecified
};
Policy Behavior
All (default) Assumes the port depends on all local states. Conservative but safe. May result in more numerical perturbations than necessary.
UserSpecified The SimObject author provides an exact list of state indices via GetPortStateDependency(). Enables tighter sparsity patterns and fewer perturbations.

GetPortStateDependency()

When GetPortDependencyPolicy() returns UserSpecified, this method provides the exact dependency list:

std::vector<int> MySimObject::GetPortStateDependency(const std::string& portName) const override
{
if (portName == "force") {
return {0, 1}; // This port depends only on states 0 and 1
}
return {}; // Empty = fall back to all states
}

This affects the sparsity pattern for off-diagonal Jacobian blocks: only the specified state columns are marked as potentially non-zero, reducing the number of numerical perturbations needed.

Graph Coloring Optimization

When numerical finite-difference assembly is required, FhSim uses a greedy largest-first graph coloring algorithm on the column conflict graph. States that do not appear in the same row can be perturbed simultaneously, reducing the number of ODE function evaluations from O(n) to O(number of colors), where the number of colors is typically much smaller than n for sparse systems.

This is an internal optimization; users do not need to configure it directly. Providing accurate GetPortStateDependency() information improves the coloring efficiency.

Robustness Features

Armijo Backtracking Line Search

The Engine Backward Euler path (BackwardEuler_i, legacy alias BackwardEuler_i) uses an Armijo backtracking line search during Newton iteration. This improves convergence for difficult nonlinear systems by adaptively reducing the Newton step size when the residual does not decrease sufficiently.

Parameters: maximum 5 backtracking iterations, sufficient decrease constant c = 1e-4.

Singular Jacobian Detection

FhSim detects singular Jacobians by checking for NaN or Inf values in the Newton step solution. When detected, the simulation aborts the current step with an error message:

Error: BackwardEuler_i singular Jacobian produced NaN/Inf, Newton iteration aborted

This can occur when:

  • The system is at an equilibrium point with zero derivatives
  • The Jacobian is mathematically singular (e.g., redundant equations)
  • Numerical overflow during Jacobian computation

Self-Connection Blocking

Self-connections (a SimObject's output port connected to its own input port) create algebraic loops that are incompatible with implicit integration. When implicit integration is active (HasJacobians() or HasHybridJacobians() returns true), self-connections are detected and reported as an error:

Error: Self-connection detected on SimObject 'X' with implicit integration.
Algebraic loops are not supported. Remove the self-connection or use explicit integration.

Architectural Limitations

Block-Diagonal On-Diagonal Assumption

The on-diagonal Jacobian blocks are assumed to be block-diagonal — that is, each SimObject's state derivatives depend only on its own states, not on other SimObjects' states. This is inherent to the FhSim architecture where OdeFcn() is called per-object. Cross-object dependencies are handled exclusively through port connections (off-diagonal blocks).

Off-Diagonal Blocks Treated as Dense

Within each off-diagonal block (from a port connection), the Jacobian is treated as dense in the block bounds. Even if GetPortStateDependency() specifies a sparse dependency pattern, the entire submatrix from consumer rows to source columns is considered potentially non-zero. Structural sparsity within the block is only used to reduce numerical perturbations, not to skip zero entries during assembly.

Value Compression

Analytical port Jacobian entries with absolute value below a compression threshold are set to zero during assembly. This prevents near-zero numerical noise from filling the Jacobian. The threshold is defined internally and is typically on the order of 1e-15.

Fill-If-Zero Strategy

As noted in How analytical Jacobians are assembled, numerical Jacobian entries only fill positions that are still zero after analytical assembly. This means:

  • Analytical entries are always preserved.
  • Legitimate analytical zeros (mathematically zero entries) may be overwritten by numerical values if the numerical assembly targets the same position.
  • In practice, this is rare because analytical zeros typically correspond to true structural zeros that are excluded from the sparsity pattern.

Performance Considerations

System Size Recommended Approach
< 50 states Dense Jacobian (default)
50-200 states, limited bandwidth Banded Jacobian (<Jacobian Type="band"> + <LinearSolver Type="BAND">)
50-200 states, chain topology Block-tridiagonal (<LinearSolver Type="block_tridiagonal">)
50-5000 states, general sparse Sparse Jacobian with CSR format (<LinearSolver Type="sparse">, METIS ordering)
50-5000 states, 2D/3D mesh (single SimObject) <LinearSolver Type="sparse"> — METIS nested dissection is near-optimal for mesh sparsity
Multi-body, per-SimObject Jacobians Component Block GS (<LinearSolver Type="component">)
> 5000 states Sparse Jacobian, consider iterative solver

For most marine simulation SimObjects, dense Jacobians are appropriate since individual objects typically have fewer than 20 states. Sparse Jacobians become beneficial when a single SimObject has many states (e.g., distributed parameter systems like cables or flexible bodies with many nodes).

For hybrid Jacobian mode, performance depends on the ratio of analytical to numerical entries. Maximizing the number of SimObjects with analytical Jacobians and port Jacobian support will reduce the number of numerical perturbations needed.

Directional Derivatives

FhSim supports computing directional derivatives of outputs with respect to inputs, which is essential for sensitivity analysis, optimization, and FMI 3.0 co-simulation.

Forward vs Adjoint Mode

Two modes are available for computing directional derivatives:

Mode Description Best When
Forward (mode=0) Seed the inputs and solve \((I - \Delta t\,J)\,v = \Delta t\,\text{seed}\) Few inputs, many outputs
Adjoint (mode=1) Seed the outputs and solve the transposed system \((I - \Delta t\,J)^{T}\mu = \lambda\) Few outputs, many inputs

Both modes apply the same operator, one backward-Euler step of size \(\Delta t\), so they are exact transposes of one another: seeding one end and reading the other gives the same number whichever mode is used. Each mode costs one assembly of \(J\) and one LU factorisation regardless of how many inputs or outputs are named, so the choice between them is about which end is convenient to seed, not about cost.

GetDirectionalDerivative locates a port by its SimObject's state offset and does not read the port name, so its \(C\) and \(B\) are plain state selections. Use GetAnalyticalDirectionalDerivative for the derivative that follows port connections through the real OutputPortJacobian and InputPortJacobian factors.

DLL Interface

The directional derivative is exposed through the FhSim DLL:

void GetDirectionalDerivative(void* context,
const char* outputObj[], // Array of output SimObject names
const char* outputPort[], // Array of output port names
int nOutputs, // Number of outputs
const char* inputObj[], // Array of input SimObject names
const char* inputPort[], // Array of input port names
int nInputs, // Number of inputs
const double dvKnown[], // Direction vector for inputs (size nInputs)
double dvUnknown[], // Result: directional derivative (size nOutputs)
int mode); // 0 = forward mode, 1 = adjoint mode

Mathematical Formulation

For a single time step with implicit integration, the input-output sensitivity is:

\[ \frac{dy}{du} = C \cdot (I - \Delta t \cdot J)^{-1} \cdot \Delta t \cdot B \]

where:

  • C = d(output_ports)/dX — from OutputPortJacobian()
  • J = dF/dX — system Jacobian from OdeJacobian()
  • B = dF/d(input_ports) — from InputPortJacobian()

Forward mode computes dy/du · dvKnown by solving (I - Δt·J) · v = Δt · B · dvKnown and reading C · v. Adjoint mode computes dvKnownᵀ · dy/du by solving (I - Δt·J)ᵀ · μ = Cᵀ · λ and then λ_input = Δt · Bᵀ · μ.

Neither mode adds port-coupling terms of its own. Every coupling — direct, composed through a stateless intermediate, and self-coupling — is already in J, which JacobianAssembler::OdeJacobian assembles, so taking them from J alone counts each one exactly once.

Thread Safety

The directional derivative computation uses per-instance workspace buffers allocated during model initialization. Each FhSim DLL context has its own isolated workspaces, making the function safe for concurrent use across multiple FMU instances (FMI 3.0).

Stateless Chain Jacobian Assembly

FhSim's analytical Jacobian assembler handles SimObjects that have no internal states but mediate forces or signals between stateful objects — so-called stateless intermediates. A typical example is a spring element that receives a position from object A, computes a force, and feeds it back to A and/or forward to object B.

One-hop stateless chains

The assembler recognises two one-hop patterns automatically:

Pattern Description
Cross-object chain (A→S→B) Stateful object A drives stateless S, whose output drives stateful object B. The composed Jacobian contribution dF_B/dX_A = dF_B/dInput_B · dOutput_S/dInput_S · dPort_A/dX_A is added to the off-diagonal block J[B, A].
Self-coupling (A→S→A) Stateful object A drives stateless S, whose output feeds back into A itself. The composed contribution augments the diagonal block J[A, A].

Both patterns are discovered at model-build time in ModelAssemblyService::BuildPortJacobianLinks (src/engine/model/ModelAssemblyWiring.cpp) and stored on the assembly result:

  • portJacobianLinks — cross-object composed links (hasIntermediate = true).
  • selfPortCouplingLinks — self-coupling links.

Multi-hop chains (A→S1→S2→B) are not handled analytically. A warning is emitted to the log and the affected objects fall back to numerical Jacobian evaluation.

InputOutputJacobian method

A stateless SimObject must implement InputOutputJacobian to participate in analytical chain assembly:

virtual void InputOutputJacobian(
const std::string& outPortName,
const std::string& inPortName,
double T,
const double* X, // nullptr for stateless objects
double* dY_dU, // output: outSize × inSize column-major matrix
int outSize,
int inSize,
int index = -1
) override;

dY_dU[i + j*outSize] must contain dOutput[i] / dInput[j].

For a scalar spring with Force = -K * Position:

void CStatelessSpring::InputOutputJacobian(
const std::string&, const std::string&,
double, const double*, double* dY_dU,
int, int, int)
{
dY_dU[0] = -m_K;
}

Return true from HasPortJacobians() to advertise the capability (there is no separate HasInputOutputJacobians() — the assembler checks HasPortJacobians() on intermediate objects).

Numerical fallback heuristic

The assembler tracks which stateless intermediates are fully covered by the chain-rule logic in m_coveredStatelessObjects (src/engine/model/JacobianAssembler.h). When deciding whether a stateful object needs a numerical Jacobian (needNumerical), only stateless objects not in this set trigger the fallback. This prevents spurious numerical evaluations when all stateless intermediates implement InputOutputJacobian.

Sparsity pattern

GetJacobianSparsity adds the composed off-diagonal entries (A→S→B) and the extra diagonal columns implied by self-coupling (A→S→A) to the declared sparsity pattern. No additional configuration is required.

Example

The test scenario tests/in/ImplicitIntegration/ImplicitIntegration_BDF_StatelessChain_in.xml demonstrates both patterns:

  • FO1 → StatelessSpring → FO2 (cross-object chain): FO2 is driven by FO1 through a spring.
  • StatelessSpring → FO1 (self-coupling): the spring output feeds back into FO1, shifting its decay rate.

Analytical solutions for the test:

x1(t) = exp(-1.5 t) (self-coupling shifts effective rate from -1 to -1.5)
x2(t) = exp(-1.5 t) - exp(-2 t)

The test ImplicitIntegrationSim.BDF_StatelessChainCoupling in tests/integrator/methods/ImplicitIntegrationSim_test.cpp verifies both trajectories against these closed-form solutions with BDF integration and diagnostics-enabled Jacobian verification (<Diagnostics><Triggers Once="true"/><Settings><JacobianVerify/></Settings></Diagnostics>).

See Also