Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Chapter 6: Finite Element Model Updating and Optimal Sensor Placement

Authors
Affiliations
University of Alberta
University of Alberta

1Introduction

Optimization underpins many tasks in structural health monitoring. When deploying sensors on a bridge, we must decide where to place them to extract maximum information about structural behavior. When developing finite element models to predict structural response, we need methods to calibrate model parameters against measured data. These challenges, and many others in SHM, reduce to optimization problems where we seek the best solution among many possibilities.

This chapter introduces optimization from first principles, building from simple derivative-based methods to heuristic algorithms. We begin with finding extrema using derivatives, then progress to gradient descent for multivariate problems. The middle section covers system identification, where we use optimization to calibrate physics-based models against experimental data. We then introduce gradient-free methods that handle discontinuous objective functions and discrete design spaces. The chapter concludes with optimal sensor placement, showing how these techniques combine to solve a practical problem in SHM. Throughout, we use realistic examples drawn from bridge monitoring applications.


2Fundamentals of Optimization

2.1Finding Extrema Through Derivatives

The simplest optimization problems involve finding maxima or minima of single-variable functions. Consider a function f(θ)f(\theta) representing some quantity we wish to optimize, such as the error between model predictions and measurements. At a local minimum or maximum, the function’s rate of change must be zero, leading to the fundamental condition:

dfdθ=0\frac{df}{d\theta} = 0

This condition identifies critical points, but we must verify whether each point is a minimum, maximum, or inflection point using the second derivative test. A minimum requires d2fdθ2>0\frac{d^2f}{d\theta^2} > 0; a maximum requires d2fdθ2<0\frac{d^2f}{d\theta^2} < 0.

This approach works well for simple functions but encounters limitations in practice. Many objective functions lack analytical derivatives, contain discontinuities, or have multiple local extrema. Functions derived from finite element simulations or experimental measurements may be expensive to evaluate. These challenges motivate the optimization strategies developed throughout this chapter.

<Figure size 1500x1200 with 2 Axes>

Figure 1:Simple optimization of a univariate function showing the critical point where the derivative equals zero

2.2Gradient Descent Concept

Many FEMU-style objectives are available only through simulations or noisy measurements. Computing df/dθdf/d\theta symbolically is then impossible, and numerical root-finding can be prohibitively expensive because each trial requires a full model evaluation. Gradient descent offers a practical alternative: repeatedly move in the direction opposite the gradient, reducing the objective without requiring a closed-form solution.

The following visualization applies gradient descent to the objective function. Starting from an arbitrary guess, each step moves downhill along −∇f-\nabla f using a line search to select the step length that minimizes ff along that direction. After a few iterations, the iterates converge to the minimum identified analytically above.

<Figure size 1500x720 with 1 Axes>

2.3Gradient Descent for Multivariate Functions

Real engineering problems involve optimizing functions of many variables, such as calibrating a finite element model with many parameters or selecting sensor locations from hundreds of candidates. For a multivariate function f(θ)f(\boldsymbol{\theta}) where θ∈Rn\boldsymbol{\theta} \in \mathbb{R}^n, we extend the derivative to the gradient vector:

∇f(θ)=[∂f∂θ1∂f∂θ2⋮∂f∂θn]\nabla f(\boldsymbol{\theta}) = \begin{bmatrix} \frac{\partial f}{\partial \theta_1} \\ \frac{\partial f}{\partial \theta_2} \\ \vdots \\ \frac{\partial f}{\partial \theta_n} \end{bmatrix}

The gradient points in the direction of steepest increase. To minimize the function, we move in the opposite direction:

θk+1=θk−α∇f(θk)\boldsymbol{\theta}_{k+1} = \boldsymbol{\theta}_k - \alpha \nabla f(\boldsymbol{\theta}_k)

where α\alpha is the learning rate (step size). Iteration continues until convergence, typically when ∣∣∇f(θ)∣∣<ϵ||\nabla f(\boldsymbol{\theta})|| < \epsilon for some small tolerance ϵ\epsilon.

<Figure size 1800x750 with 2 Axes>

Figure 2:Gradient descent trajectory on a 2D quadratic function showing convergence to the minimum


3Gradient-Free Optimization Methods: Genetic Algorithm

Many optimization problems in structural health monitoring cannot be solved effectively with gradient-based methods. The objective function may be discontinuous, non-differentiable, or expensive to evaluate. Sensor placement involves discrete decisions that create a combinatorial search space, and damage detection objectives often have multiple local minima. These challenges motivate gradient-free methods that explore the solution space without requiring derivative information.

Genetic algorithms mimic biological evolution. The algorithm maintains a population of candidate solutions that evolve through selection, crossover, and mutation. Better solutions are more likely to pass their characteristics to the next generation, gradually improving overall population fitness. This population-based approach maintains diversity and explores multiple regions of the search space simultaneously, reducing sensitivity to local minima.

1
2
3
4
5
6
7
8
9
10
11
12
13
from scipy.optimize import differential_evolution

def multimodal_function(theta):
    return theta[0] * np.sin(4*np.pi*theta[0]) - theta[1] * np.sin(4*np.pi*theta[1] + np.pi) + 1

result_ga = differential_evolution(
    multimodal_function,
    bounds=[(-1, 1), (-1, 1)],
    seed=42,
    maxiter=100,
    popsize=15
)
print("Best θ:", result_ga.x, "Objective:", result_ga.fun)
Source
<Figure size 1800x750 with 2 Axes>
Loading...

Figure 3:Genetic algorithm locating the global optimum of a multimodal function (line numbers highlight the evolutionary loop)


4Application 1: Finite Element Model Updating and Damage Localization

4.1The Inverse Problem in Structural Identification

Finite element model updating (FEMU) addresses an inverse problem: given measured structural responses, determine the physical parameters that produce those responses. This contrasts with forward analysis, where responses are computed from known parameters. In damage detection, FEMU identifies localized stiffness reductions by calibrating model parameters to match measured modal properties.

FEMU treats damage identification as an optimization problem. We parameterize unknown stiffness modifications, then search for parameter values that minimize discrepancies between model predictions and measurements. Unlike data-driven methods, FEMU uses physics-based models to produce interpretable damage indicators directly linked to structural mechanics.

Finite-element model-updating workflow for damage identification. The iterative process adjusts FE model parameters \boldsymbol{\theta} to minimize the residual J(\boldsymbol{\theta}) = \sum_i w_i\, r_i^2 between predicted and measured modal properties \{\omega_i^{\text{exp}}, \boldsymbol{\varphi}_i^{\text{exp}}\}.

Figure 4:Finite-element model-updating workflow for damage identification. The iterative process adjusts FE model parameters θ\boldsymbol{\theta} to minimize the residual J(θ)=∑iwi ri2J(\boldsymbol{\theta}) = \sum_i w_i\, r_i^2 between predicted and measured modal properties {ωiexp,φiexp}\{\omega_i^{\text{exp}}, \boldsymbol{\varphi}_i^{\text{exp}}\}.

4.2Problem Formulation

Consider a two-span continuous bridge with span lengths L1=30L_1 = 30 m and L2=80L_2 = 80 m. Structural stiffness depends on the moment of inertia II through the flexural rigidity EIEI. Damage reduces local stiffness, modeled here as a multiplicative factor on each span’s moment of inertia.

The damage parameter vector θ=[θ1,θ2]T\boldsymbol{\theta} = [\theta_1, \theta_2]^T contains the stiffness retention factor for each span, with θi∈[0,1]\theta_i \in [0, 1]. A healthy span has θi=1\theta_i = 1; θi=0.7\theta_i = 0.7 indicates 30% stiffness loss. The effective moment of inertia is:

Ii(θi)=θi⋅I0I_i(\theta_i) = \theta_i \cdot I_0

where I0I_0 is the baseline moment of inertia.

Two-span continuous bridge with damage parameterization. Stiffness retention factors \theta_1 and \theta_2 quantify structural integrity in each span, where \theta_i = 1.0 indicates no damage and \theta_i < 1.0 represents stiffness reduction. Accelerometers (red circles) measure modal responses at selected locations.

Figure 5:Two-span continuous bridge with damage parameterization. Stiffness retention factors θ1\theta_1 and θ2\theta_2 quantify structural integrity in each span, where θi=1.0\theta_i = 1.0 indicates no damage and θi<1.0\theta_i < 1.0 represents stiffness reduction. Accelerometers (red circles) measure modal responses at selected locations.

The complete code for generating modal measurements is shown below:

Modal Analysis in OpenSeeS Utility
modal_measurement.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
"""
Modal Measurement Script for FEMU
Generates synthetic measured modal data from a damaged structure.

Note: This script expects modal_analysis_utility to be in sys.path.
"""

import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import CubicSpline
import os

def generate_modal_measurements(save_figures=False):
    from modal_analysis_utility import ModalAnalysis
    """
    Generate synthetic measured modal data from damaged structure.
    
    Parameters:
    -----------
    save_figures : bool
        If True, save mode shape plots to ch08_figures/
    
    Returns:
    --------
    dict : Dictionary containing all measurement data
    """
    np.random.seed(42)
    
    # True damage scenario: 30% damage in span 1, 20% in span 2
    theta_true = np.array([0.70, 0.80])
    
    # Create "measured" data from damaged structure
    spans = [30, 80]
    supports = ['hinge', 'roller', 'roller']
    E, rho, A, I_base = 25e9, 2500, 8.0, 2.67
    
    modal_true = ModalAnalysis(spans, supports, E=E, rho=rho, A=A, I=I_base, I_factors=theta_true)
    
    # Run analysis to get results
    result_true = modal_true.analyze(n_modes=4, plot=False)
    
    # Add measurement noise
    noise_freq = 0.01
    noise_mode = 0.02
    freq_meas = result_true['frequencies'] * (1 + noise_freq * np.random.randn(4))
    modes_meas = result_true['mode_shapes'] + noise_mode * np.random.randn(*result_true['mode_shapes'].shape)
    
    # Normalize measured mode shapes
    for i in range(modes_meas.shape[1]):
        modes_meas[:, i] /= np.max(np.abs(modes_meas[:, i]))
    
    # Create and save mode shape figure if requested
    if save_figures:
        script_dir = os.path.dirname(os.path.abspath(__file__))
        fig_dir = os.path.join(script_dir, '..', 'ch08_figures')
        os.makedirs(fig_dir, exist_ok=True)
        
        # Create the plot manually (replicate _plot_mode_shapes logic)
        n_modes = 4
        n_cols = 2
        n_rows = 2
        
        fig, axes = plt.subplots(n_rows, n_cols, figsize=(14, 10))
        
        for mode_idx in range(n_modes):
            row = mode_idx // n_cols
            col = mode_idx % n_cols
            ax = axes[row, col]
            
            mode_num = mode_idx + 1
            mode_shape = result_true['mode_shapes'][:, mode_idx]
            node_positions = result_true['node_positions']
            
            # Enforce zero at supports
            plot_x = node_positions.copy()
            plot_y = mode_shape.copy()
            
            support_positions = [0, spans[0], spans[0] + spans[1]]
            for support_x in support_positions:
                support_idx = np.argmin(np.abs(node_positions - support_x))
                plot_y[support_idx] = 0.0
            
            # Smooth curve with spline
            unique_x, unique_indices = np.unique(plot_x, return_index=True)
            unique_y = plot_y[unique_indices]
            
            if len(unique_x) > 3:
                spline = CubicSpline(unique_x, unique_y)
                x_smooth = np.linspace(unique_x.min(), unique_x.max(), 500)
                y_smooth = spline(x_smooth)
                ax.plot(x_smooth, y_smooth, 'b-', linewidth=2, label='Mode Shape')
            else:
                ax.plot(unique_x, unique_y, 'b-', linewidth=2, label='Mode Shape')
            
            # Add support triangles
            for support_x in support_positions:
                triangle_height = 0.2
                triangle = plt.Polygon([
                    [support_x - 1.5, -triangle_height],
                    [support_x + 1.5, -triangle_height],
                    [support_x, 0]
                ], color='green', alpha=0.6, zorder=5)
                ax.add_patch(triangle)
            
            # Add frequency and period text
            freq = result_true['frequencies'][mode_idx]
            period = result_true['periods'][mode_idx]
            ax.text(0.02, 0.98, f'f = {freq:.3f} Hz\nT = {period:.3f} s', 
                   transform=ax.transAxes, fontsize=10, 
                   verticalalignment='top', bbox=dict(boxstyle='round', 
                   facecolor='wheat', alpha=0.5))
            
            # Formatting
            total_length = sum(spans)
            ax.set_xlim(-2, total_length + 2)
            ax.set_ylim(-1.05, 1.05)
            ax.axhline(y=0, color='k', linestyle='--', linewidth=0.5, alpha=0.3)
            ax.set_xlabel('Position (m)', fontsize=11)
            ax.set_ylabel('Amplitude', fontsize=11)
            ax.set_title(f'Mode {mode_num}', fontsize=12, fontweight='bold')
            ax.grid(True, alpha=0.3)
        
        plt.tight_layout()
        
        # Save the figure
        fig.savefig(os.path.join(fig_dir, 'modal_measurements.png'), dpi=150, bbox_inches='tight')
        plt.close(fig)
    
    return {
        'theta_true': theta_true,
        'spans': spans,
        'supports': supports,
        'E': E,
        'rho': rho,
        'A': A,
        'I_base': I_base,
        'result_true': result_true,
        'freq_meas': freq_meas,
        'modes_meas': modes_meas
    }
Mode shapes and natural frequencies of the damaged bridge structure. The plot shows the first four vibration modes with their corresponding frequencies and periods.

Figure 6:Mode shapes and natural frequencies of the damaged bridge structure. The plot shows the first four vibration modes with their corresponding frequencies and periods.

The forward model performs eigenvalue analysis on the damaged structure, yielding natural frequencies ωn(θ)\omega_n(\boldsymbol{\theta}) and mode shapes ϕ(θ)\boldsymbol{\phi}(\boldsymbol{\theta}) at sensor locations. The inverse problem minimizes the discrepancy between predicted and measured modal properties:

J(θ)=wf∑n=1Nm(ωn(θ)−ωnmeasωnmeas)2+wϕ∑n=1Nm(1−MACn(θ))J(\boldsymbol{\theta}) = w_f \sum_{n=1}^{N_m} \left(\frac{\omega_n(\boldsymbol{\theta}) - \omega_n^{\text{meas}}}{\omega_n^{\text{meas}}}\right)^2 + w_\phi \sum_{n=1}^{N_m} \left(1 - \text{MAC}_n(\boldsymbol{\theta})\right)

where NmN_m is the number of measured modes, wfw_f and wϕw_\phi are weighting factors, and MACn\text{MAC}_n is the Modal Assurance Criterion for mode nn:

MACn(θ)=∣ϕnmeasTϕn(θ)∣2(ϕnmeasTϕnmeas)(ϕn(θ)Tϕn(θ))\text{MAC}_n(\boldsymbol{\theta}) = \frac{|\boldsymbol{\phi}_n^{\text{meas}T} \boldsymbol{\phi}_n(\boldsymbol{\theta})|^2}{(\boldsymbol{\phi}_n^{\text{meas}T} \boldsymbol{\phi}_n^{\text{meas}})(\boldsymbol{\phi}_n(\boldsymbol{\theta})^T \boldsymbol{\phi}_n(\boldsymbol{\theta}))}

The MAC quantifies mode shape correlation, ranging from 0 (orthogonal) to 1 (identical). The dual objective uses both frequency and spatial information to guide identification.

4.3Gradient-Based Model Updating

Gradient-based optimization uses derivative information to navigate parameter space. For FEMU, we compute the gradient ∇θJ\nabla_{\boldsymbol{\theta}} J using automatic differentiation, avoiding finite difference approximations. The update rule is:

θk+1=θk−αk∇θJ(θk)\boldsymbol{\theta}_{k+1} = \boldsymbol{\theta}_k - \alpha_k \nabla_{\boldsymbol{\theta}} J(\boldsymbol{\theta}_k)

where αk\alpha_k is the step size from a line search. We constrain θi∈[0.5,1.0]\theta_i \in [0.5, 1.0] to ensure physically reasonable stiffness values.

The following implementation uses PyTorch for automatic differentiation. The complete code is shown below:

Gradient-based FEMU implementation script
gradient_femu.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
"""
Gradient-Based FEMU Script
Performs gradient descent optimization for finite element model updating.
Generates and saves all visualization figures.

Note: This script expects modal_analysis_utility to be in sys.path
and measurement data variables to be available in the calling scope.
"""

import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
import seaborn as sns
import os
import json

def run_gradient_femu(theta_true, spans, supports, E, rho, A, I_base, freq_meas, modes_meas, result_true):
    """
    Run gradient-based FEMU and generate all figures.
    
    Parameters:
    -----------
    theta_true : array
        True damage parameters
    spans : list
        Span lengths
    supports : list
        Support types
    E, rho, A, I_base : float
        Material and geometric properties
    freq_meas : array
        Measured frequencies
    modes_meas : array
        Measured mode shapes
    result_true : dict
        True modal analysis results
    """
    from modal_analysis_utility import ModalAnalysis
    
    sns.set_style('whitegrid')
    plt.rcParams.update({'font.size': 10, 'figure.dpi': 100})
    
    torch.manual_seed(42)
    
    # Define helper functions inside the main function to access parameters
    def run_modal_analysis(theta_values):
        """Utility wrapper to ensure consistent calls to ModalAnalysis."""
        theta_values = np.clip(np.array(theta_values, dtype=float), 0.5, 1.0)
        modal = ModalAnalysis(spans, supports, E=E, rho=rho, A=A, I=I_base, 
                              I_factors=theta_values)
        return modal.analyze(n_modes=4, plot=False)

    class ModalAnalysisAutograd(torch.autograd.Function):
        """Custom autograd function with finite-difference gradients."""
        eps = 1e-3

        @staticmethod
        def forward(ctx, theta_tensor):
            theta_numpy = theta_tensor.detach().cpu().numpy()
            result = run_modal_analysis(theta_numpy)
            ctx.save_for_backward(theta_tensor.detach().clone())
            return (torch.tensor(result['frequencies'], dtype=torch.float32, device=theta_tensor.device),
                    torch.tensor(result['mode_shapes'], dtype=torch.float32, device=theta_tensor.device))

        @staticmethod
        def backward(ctx, grad_freq, grad_modes):
            (theta_saved,) = ctx.saved_tensors
            eps = ModalAnalysisAutograd.eps
            if grad_freq is None:
                grad_freq = torch.zeros(4, device=theta_saved.device)
            if grad_modes is None:
                grad_modes = torch.zeros(result_true['mode_shapes'].shape, device=theta_saved.device)
            grad_theta = torch.zeros_like(theta_saved)
            for i in range(len(theta_saved)):
                theta_plus = theta_saved.clone()
                theta_minus = theta_saved.clone()
                theta_plus[i] = torch.clamp(theta_plus[i] + eps, 0.5, 1.0)
                theta_minus[i] = torch.clamp(theta_minus[i] - eps, 0.5, 1.0)
                res_plus = run_modal_analysis(theta_plus.cpu().numpy())
                res_minus = run_modal_analysis(theta_minus.cpu().numpy())
                freq_plus = torch.tensor(res_plus['frequencies'], dtype=torch.float32, device=grad_freq.device)
                freq_minus = torch.tensor(res_minus['frequencies'], dtype=torch.float32, device=grad_freq.device)
                modes_plus = torch.tensor(res_plus['mode_shapes'], dtype=torch.float32, device=grad_modes.device)
                modes_minus = torch.tensor(res_minus['mode_shapes'], dtype=torch.float32, device=grad_modes.device)
                freq_grad = (freq_plus - freq_minus) / (2 * eps)
                modes_grad = (modes_plus - modes_minus) / (2 * eps)
                grad_theta[i] = torch.sum(grad_freq * freq_grad) + torch.sum(grad_modes * modes_grad)
            return (grad_theta.to(theta_saved.device),)

    class FEMUModel(nn.Module):
        """FEMU model with gradient computation"""
        def __init__(self, initial_theta=[0.95, 0.95]):
            super().__init__()
            self.psi = nn.Parameter(torch.tensor(initial_theta, dtype=torch.float32))
            
        def get_theta(self):
            """Convert unconstrained ψ to constrained θ ∈ [0.5, 1.0]"""
            return 0.5 + 0.5 * torch.sigmoid(self.psi)
        
        def forward(self, freq_target, modes_target, w_freq=1.0, w_mac=0.5):
            """Compute objective function"""
            theta_current = self.get_theta()
            freq_pred, modes_pred = ModalAnalysisAutograd.apply(theta_current)
            freq_error = torch.sum(((freq_pred - freq_target) / freq_target)**2)
            mac_sum = 0.0
            for i in range(4):
                mode_pred = modes_pred[:, i]
                mode_meas = modes_target[:, i]
                numerator = torch.abs(torch.dot(mode_pred, mode_meas))**2
                denominator = torch.dot(mode_pred, mode_pred) * torch.dot(mode_meas, mode_meas)
                mac = numerator / (denominator + 1e-8)
                mac_sum += (1 - mac)
            J = w_freq * freq_error + w_mac * mac_sum
            return J, freq_pred, modes_pred
    
    # Gradient-based optimization
    model = FEMUModel(initial_theta=[0.95, 0.95])
    optimizer = optim.Adam(model.parameters(), lr=0.05)
    
    freq_target = torch.tensor(freq_meas, dtype=torch.float32)
    modes_target = torch.tensor(modes_meas, dtype=torch.float32)
    
    history_grad = {'iteration': [], 'objective': [], 'theta_1': [], 'theta_2': [], 
                    'freq': [], 'modes': []}
    
    n_iterations = 200
    for iteration in range(n_iterations):
        optimizer.zero_grad()
        J, freq_pred, modes_pred = model(freq_target, modes_target, w_freq=1.0, w_mac=0.5)
        J.backward()
        optimizer.step()
        theta_current = model.get_theta().detach().numpy()
        if iteration % 10 == 0:
            history_grad['iteration'].append(iteration)
            history_grad['objective'].append(J.item())
            history_grad['theta_1'].append(theta_current[0])
            history_grad['theta_2'].append(theta_current[1])
            history_grad['freq'].append(freq_pred.detach().numpy().copy())
            history_grad['modes'].append(modes_pred.detach().numpy().copy())
    
    theta_grad_final = model.get_theta().detach().numpy()
    
Skipping gradient FEMU execution (CI environment or OpenSees not available)
Results will be loaded from pre-generated figures
Gradient-based FEMU optimization results showing convergence history, parameter evolution, and modal frequency comparison between measured, identified, and undamaged states.

Figure 7:Gradient-based FEMU optimization results showing convergence history, parameter evolution, and modal frequency comparison between measured, identified, and undamaged states.

Loading...
Optimization landscape showing the objective function J(\theta_1, \theta_2) with gradient descent trajectory from initial guess to optimum, displayed in both 2D contour and 3D surface views.

Figure 8:Optimization landscape showing the objective function J(θ1,θ2)J(\theta_1, \theta_2) with gradient descent trajectory from initial guess to optimum, displayed in both 2D contour and 3D surface views.

4.4Genetic Algorithm for Model Updating

Gradient-free methods are useful when objective functions are non-smooth or derivatives are unavailable. Genetic algorithms (GA) maintain a population of candidate solutions and evolve them through selection, crossover, and mutation. This population-based search explores multiple regions simultaneously, reducing sensitivity to local minima.

For FEMU, each individual encodes a damage hypothesis θ\boldsymbol{\theta}. The fitness equals −J(θ)-J(\boldsymbol{\theta}), since GAs maximize fitness. Selection favors individuals with lower JJ, crossover combines parent solutions, and mutation maintains exploration.

The implementation uses scipy.optimize.differential_evolution, a GA variant with strong convergence properties. The complete code is shown below:

Genetic Algorithm FEMU implementation script
ga_femu.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
"""
Genetic Algorithm FEMU Script
Performs GA optimization for finite element model updating.
Generates and saves all visualization figures.

Note: This script expects modal_analysis_utility to be in sys.path
and measurement data variables to be available in the calling scope.
"""

import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.optimize import differential_evolution
import time
import os
import json

def run_ga_femu(theta_true, spans, supports, E, rho, A, I_base, freq_meas, modes_meas, result_true):
    """
    Run GA-based FEMU and generate all figures.
    
    Parameters:
    -----------
    theta_true : array
        True damage parameters
    spans : list
        Span lengths
    supports : list
        Support types
    E, rho, A, I_base : float
        Material and geometric properties
    freq_meas : array
        Measured frequencies
    modes_meas : array
        Measured mode shapes
    result_true : dict
        True modal analysis results
    """
    from modal_analysis_utility import ModalAnalysis
    
    sns.set_style('whitegrid')
    plt.rcParams.update({'font.size': 10, 'figure.dpi': 100})
    
    # Define objective function inside to access parameters
    def objective_function_ga(theta_vec):
        """Objective function for GA optimization"""
        modal = ModalAnalysis(spans, supports, E=E, rho=rho, A=A, I=I_base,
                             I_factors=theta_vec)
        result = modal.analyze(n_modes=4, plot=False)
        freq_pred = result['frequencies']
        modes_pred = result['mode_shapes']
        freq_error = np.sum(((freq_pred - freq_meas) / freq_meas)**2)
        mac_error = 0.0
        for i in range(4):
            mode_p = modes_pred[:, i]
            mode_m = modes_meas[:, i]
            numerator = np.abs(np.dot(mode_p, mode_m))**2
            denominator = np.dot(mode_p, mode_p) * np.dot(mode_m, mode_m)
            mac = numerator / (denominator + 1e-10)
            mac_error += (1 - mac)
        J = 1.0 * freq_error + 0.5 * mac_error
        return J
    
    bounds = [(0.5, 1.0), (0.5, 1.0)]
    popsize = 15
    
    history_ga = {'generation': [], 'objective': [], 'theta_1': [], 'theta_2': []}
    ga_iter_counter = {'value': 0}
    
    def callback_ga(xk, convergence):
        """Callback to record optimization history"""
        iter_idx = ga_iter_counter['value']
        history_ga['generation'].append(iter_idx)
        history_ga['objective'].append(objective_function_ga(xk))
        history_ga['theta_1'].append(float(xk[0]))
        history_ga['theta_2'].append(float(xk[1]))
        ga_iter_counter['value'] += 1
        return False
    
    # Initialize population near [0.95, 0.95]
    np.random.seed(42)
    dim = len(bounds)
    init_base = np.array([0.95, 0.95])
    init_pop = np.tile(init_base, (popsize * dim, 1))
    init_pop += 0.02 * np.random.uniform(-1, 1, size=init_pop.shape)
    lower_bounds = np.array([b[0] for b in bounds])
    upper_bounds = np.array([b[1] for b in bounds])
    init_pop = np.clip(init_pop, lower_bounds, upper_bounds)
    
    start_time = time.time()
    result_ga = differential_evolution(
        objective_function_ga,
        bounds,
        strategy='best1bin',
        maxiter=100,
        popsize=popsize,
        mutation=(0.5, 1.0),
        recombination=0.7,
        seed=42,
        callback=callback_ga,
        polish=True,
        workers=1,
        init=init_pop
    )
    ga_time = time.time() - start_time
    
    theta_ga_final = result_ga.x
    
    # Ensure final optimum is recorded
    history_ga['generation'].append(ga_iter_counter['value'])
    history_ga['objective'].append(result_ga.fun)
    history_ga['theta_1'].append(float(theta_ga_final[0]))
    history_ga['theta_2'].append(float(theta_ga_final[1]))
    
    modal_ga_final = ModalAnalysis(spans, supports, E=E, rho=rho, A=A, I=I_base,
                                   I_factors=theta_ga_final)
    result_ga_final = modal_ga_final.analyze(n_modes=4, plot=False)
    
Skipping GA FEMU execution (CI environment or OpenSees not available)
Results will be loaded from pre-generated figures
Genetic Algorithm FEMU optimization results showing convergence history across generations, parameter evolution for both spans, and modal frequency comparison between measured, GA-identified, and undamaged states.

Figure 9:Genetic Algorithm FEMU optimization results showing convergence history across generations, parameter evolution for both spans, and modal frequency comparison between measured, GA-identified, and undamaged states.

Loading...
Optimization landscape showing the objective function J(\theta_1, \theta_2) with Genetic Algorithm trajectory from initial population to final optimum, displayed in both 2D contour and 3D surface views.

Figure 10:Optimization landscape showing the objective function J(θ1,θ2)J(\theta_1, \theta_2) with Genetic Algorithm trajectory from initial population to final optimum, displayed in both 2D contour and 3D surface views.


5Application 2: Optimal Sensor Placement

Sensor placement decisions directly affect monitoring system effectiveness. With a limited sensor budget, the goal is to maximize information content while minimizing redundancy, so that modal parameters can be accurately identified from the measured responses.

5.1Information-Based Placement Criterion

We select pp sensors from nn candidate locations by maximizing the Fisher Information Matrix (FIM). For modal analysis, the FIM quantifies mode shape observability:

F(s)=ΦsTΦs\mathbf{F}(\mathbf{s}) = \boldsymbol{\Phi}_s^T \boldsymbol{\Phi}_s

where Φs∈Rp×m\boldsymbol{\Phi}_s \in \mathbb{R}^{p \times m} contains mm mode shapes at pp sensor locations. The D-optimality criterion maximizes the determinant:

J(s)=det⁡(F(s))J(\mathbf{s}) = \det(\mathbf{F}(\mathbf{s}))

Large determinant values indicate well-conditioned mode shape matrices where modes remain distinguishable despite measurement noise, preventing ill-conditioning during modal parameter extraction.

5.2Genetic Algorithm Implementation

Sensor placement is a combinatorial problem: selecting pp from nn locations yields (np)\binom{n}{p} possible configurations. For a bridge with 50 candidate locations and 8 sensors, this exceeds 500 million configurations. Genetic algorithms search this discrete space through evolutionary operations without exhaustive enumeration.

We use differential evolution, which encodes sensor locations as continuous variables θ∈[0,1]n\theta \in [0,1]^n and selects the pp largest values. This continuous relaxation improves convergence while preserving discrete sensor assignments.

5.3Two-Span Bridge Example

We apply optimal sensor placement to the two-span continuous bridge from the FEMU example (Figure 5), which has spans of 30 m and 80 m for a total length of 110 m. A finite element model with 0.5 m mesh spacing is used to perform modal analysis on the intact structure and extract mode shapes at potential sensor locations.

Candidate sensor locations are defined at 2.5 m intervals starting from 2.5 m, excluding support positions. This yields uniformly distributed accelerometer candidates across the bridge deck. We extract the first four mode shapes at these locations, as they contain the most significant structural information for damage detection.

The following code demonstrates the modal analysis utility used to generate the sensor grid data:

Modal Analysis Utility for Sensor Grid
sensor_grid_modal_data.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
"""
Generate modal data for sensor grid locations using FE model.

This script performs modal analysis on the intact bridge structure and extracts
mode shape amplitudes at candidate sensor locations for optimal sensor placement.
"""

import numpy as np
import matplotlib.pyplot as plt
import json
import os
import sys

def generate_sensor_grid_data(save_data=True):
    """
    Generate modal data at sensor grid locations for optimal sensor placement.
    
    Parameters
    ----------
    save_data : bool
        If True, save the sensor grid data to JSON file
    
    Returns
    -------
    dict
        Dictionary containing sensor locations and mode shape data
    """
    # Add path to common utilities
    script_dir = os.path.dirname(os.path.abspath(__file__))
    project_root = os.path.abspath(os.path.join(script_dir, '..', '..', '..'))
    common_util_path = os.path.join(project_root, 'common_utility_data')
    if common_util_path not in sys.path:
        sys.path.insert(0, common_util_path)
    
    from modal_analysis_utility import ModalAnalysis
        
    # Bridge geometry (same as FEMU example)
    spans = [30.0, 80.0]  # Two-span continuous bridge
    supports = ['hinge', 'roller', 'roller']  # Support types
    support_positions = [0.0, 30.0, 110.0]  # Support locations
    
    # Structural properties
    E = 30e9  # Young's modulus (Pa) - concrete
    rho = 2500  # Density (kg/m³)
    A = 6.0  # Cross-sectional area (m²)
    I_base = 4.5  # Base moment of inertia (m⁴)
    
    # Intact bridge (no damage)
    I_factors = [1.0, 1.0]  # No stiffness reduction
    
    # Perform modal analysis on intact structure
    # Use first 4 modes for sensor placement study
    modal = ModalAnalysis(spans, supports, E=E, rho=rho, A=A, I=I_base, I_factors=I_factors)
    result = modal.analyze(n_modes=4, plot=False)
    
    # Define sensor grid: every 2.5 m starting from 2.5 m (excluding supports)
    mesh_size = 0.5  # FE mesh size
    sensor_spacing = 2.5  # Sensor grid spacing
    
    # Generate candidate sensor locations
    candidate_locations = []
    x = 2.5  # Start at 2.5 m
    while x < 110.0:
        # Exclude support locations (with 0.1 m tolerance)
        is_support = any(abs(x - sup) < 0.1 for sup in support_positions)
        if not is_support:
            candidate_locations.append(x)
        x += sensor_spacing
    
    candidate_locations = np.array(candidate_locations)
    n_candidates = len(candidate_locations)
        
    # Extract mode shape amplitudes at candidate locations
    # Interpolate from FE node positions to candidate locations
    node_positions = modal.node_positions
    n_modes = 4
    mode_shapes_at_candidates = np.zeros((n_candidates, n_modes))
    
    for i, loc in enumerate(candidate_locations):
        for mode_idx in range(n_modes):
            # Linear interpolation
            mode_shape_at_loc = np.interp(loc, node_positions, modal.mode_shapes[:, mode_idx])
            mode_shapes_at_candidates[i, mode_idx] = mode_shape_at_loc
        
    # Extract frequencies
    frequencies = modal.frequencies[:n_modes]
        
    # Prepare data for saving
    data = {
        'candidate_locations': candidate_locations.tolist(),
        'mode_shapes': mode_shapes_at_candidates.tolist(),  # sampled at candidate locations
        'frequencies': frequencies.tolist(),
        'n_modes': n_modes,
        'n_candidates': n_candidates,
        'bridge_geometry': {
            'spans': spans,
            'supports': supports,
            'support_positions': support_positions,
            'total_length': sum(spans)
        }
    }
    
Running in CI environment - using pre-generated sensor grid data.
Mode shapes at candidate sensor grid locations.

Figure 11:Mode shapes at candidate sensor grid locations.

5.3.2Optimal Sensor Placement Optimization

With modal data available, we determine the optimal placement of 8 accelerometers to maximize observability of the first three modes. The genetic algorithm maximizes the determinant of the Fisher Information Matrix, which measures the information content of a given sensor configuration.


Running genetic algorithm optimization...
  Iteration 10: log-det(FIM) = 5.3663
  Iteration 20: log-det(FIM) = 5.3663
  Iteration 30: log-det(FIM) = 5.3751
  Iteration 40: log-det(FIM) = 5.4168
  Iteration 50: log-det(FIM) = 5.4286
  Iteration 60: log-det(FIM) = 5.4286
  Iteration 70: log-det(FIM) = 5.4286
  Iteration 80: log-det(FIM) = 5.4286
  Iteration 90: log-det(FIM) = 5.4286
  Iteration 100: log-det(FIM) = 5.4286

Optimization completed in 7.48 seconds
Source
<Figure size 1800x675 with 1 Axes>

Figure 12:Convergence history showing the log-determinant of the Fisher Information Matrix increasing as the genetic algorithm identifies better sensor configurations.

Final optimal configuration with 8 accelerometers (red triangles) distributed across the bridge deck. Gray circles indicate candidate sensor locations, and green triangles represent bridge supports.

Figure 13:Final optimal configuration with 8 accelerometers (red triangles) distributed across the bridge deck. Gray circles indicate candidate sensor locations, and green triangles represent bridge supports.

Source
<Figure size 1800x1500 with 3 Axes>

Figure 14:Mode shape observability analysis. Left: Coverage of the first three mode shapes by optimal sensor locations (vertical dashed lines). The sensor distribution captures critical regions where mode shape amplitudes are significant. Right: Fisher Information Matrix showing the information content for each mode pair, with diagonal dominance indicating strong individual mode observability.

Source
Optimal Sensor Placement Summary
==================================================
Number of Sensors: 8
Number of Modes: 4
Candidate Locations: 42
GA Iterations: 100
Runtime: 7.48 s

Loading...

5.3.3Results and Discussion

The genetic algorithm identifies a sensor layout that captures the essential dynamic behavior of the bridge using a limited number of accelerometers. The convergence history shows a steady increase in information content before the solution stabilizes.

The final sensor pattern distributes instruments across both spans, placing sensors near regions of large modal amplitude and away from nodal lines where motion is small. The resulting measurement set is informative for all targeted modes.

In practice, this optimal configuration is a starting point. Actual deployment must account for access, safety, power, cabling, and maintenance. The final layout may require adjustments, but sensors moved short distances from their ideal positions typically retain most of their information content.

5.4Alternative Sensor Placement Approaches

Beyond the Fisher Information approach, other strategies for sensor placement evaluate how well a limited sensor set supports inverse problem solutions. Two practical alternatives use model updating and force identification, respectively.

5.4.1Model Updating Based Placement

This approach treats sensor placement as a model calibration problem. If a small set of sensors provides enough information to accurately identify structural parameters, those sensors are well placed.

The procedure is as follows. Select a trial set of sensor locations from the available candidates. Using only measurements from these sensors, perform system identification to estimate structural parameters (stiffness, mass, or damage indicators). Use the identified model to predict responses at all candidate locations, including those not instrumented. Compare predictions against reference values: low prediction error indicates the trial sensors captured sufficient structural information.

Repeating this process for different trial configurations identifies which sensor combinations yield the lowest prediction errors. The configuration that best reconstructs the full structural response from a subset of measurements is selected as optimal.

Model-updating (FEMU) approach for sensor placement. A trial sensor subset S \subset C records modal data; an inverse problem identifies \hat{\boldsymbol{\theta}} = \arg\min J(\boldsymbol{\theta}); the identified model predicts responses at unmeasured locations and the prediction error e(S) = \sum_{i \in C \setminus S} \|\hat{y}_i - y_i\|^2 is computed. The configuration with the lowest error is selected: S^{*} = \arg\min_S e(S).

Figure 15:Model-updating (FEMU) approach for sensor placement. A trial sensor subset S⊂CS \subset C records modal data; an inverse problem identifies θ^=arg⁡min⁡J(θ)\hat{\boldsymbol{\theta}} = \arg\min J(\boldsymbol{\theta}); the identified model predicts responses at unmeasured locations and the prediction error e(S)=∑i∈C∖S∥y^i−yi∥2e(S) = \sum_{i \in C \setminus S} \|\hat{y}_i - y_i\|^2 is computed. The configuration with the lowest error is selected: S∗=arg⁡min⁡Se(S)S^{*} = \arg\min_S e(S).

5.4.2Force Identification Based Placement

A second approach evaluates sensor configurations by their ability to reconstruct unknown input forces. This is relevant for operational monitoring where external loads are not directly measured.

The method uses a simulation study. A known random force is applied to the finite element model and the acceleration response is recorded at all candidate sensor locations. For each candidate sensor subset, the original force is reconstructed from only those measurements using the structural model to invert from accelerations to input forces. Some sensor combinations allow accurate force recovery; others produce large errors. Testing many subsets identifies the configuration that minimizes the force identification error.

Force-identification approach for sensor placement. A known force F(t) is applied to the model and responses y_i(t) are recorded at all candidates i \in C. A sensor subset S is used to solve the inverse problem \hat F(t) = M^{-1} \cdot \{y_S(t)\}, and the reconstructed force is compared against the true input via e(S) = \|F - \hat F\|^2. The subset minimising this error, S^{*} = \arg\min_S e(S), is selected.

Figure 16:Force-identification approach for sensor placement. A known force F(t)F(t) is applied to the model and responses yi(t)y_i(t) are recorded at all candidates i∈Ci \in C. A sensor subset SS is used to solve the inverse problem F^(t)=M−1⋅{yS(t)}\hat F(t) = M^{-1} \cdot \{y_S(t)\}, and the reconstructed force is compared against the true input via e(S)=∥F−F^∥2e(S) = \|F - \hat F\|^2. The subset minimising this error, S∗=arg⁡min⁡Se(S)S^{*} = \arg\min_S e(S), is selected.

5.5Practical Considerations

The mathematically optimal sensor layout rarely translates directly to field conditions. Real structures impose constraints that algorithms cannot anticipate. Some locations may be inaccessible, too confined, or too dangerous for installation and maintenance. Power and data cables add cost and complexity. Some areas experience moisture, mechanical vibration, or electromagnetic interference that degrades signal quality.

Budget constraints matter as well. Fewer sensors reduce hardware and installation costs but may weaken monitoring capability. The practical approach is to begin with the theoretical optimum, then adjust individual locations to satisfy site requirements while preserving the overall spatial distribution. A sensor moved a short distance from its ideal position usually retains most of its information content.

Consulting site personnel during the design phase helps identify restrictions early. Experienced engineers know which areas are accessible, which surfaces suit sensor mounting, and where cables can be routed. Combining this knowledge with optimization results produces sensor networks that are both informative and maintainable.


6Conclusion

This chapter introduced optimization methods and their application to structural health monitoring. We began with finding extrema through derivatives, moved to gradient-based methods for multivariate functions, and showed how these techniques support system identification and finite element model updating. Gradient-free methods extended the treatment to discrete design spaces and non-differentiable objectives, with optimal sensor placement as the practical application.

Careful problem formulation and appropriate algorithm selection allow us to extract maximum information from limited sensor networks, calibrate complex models against measured data, and make defensible decisions about monitoring system design. These optimization techniques underpin many advanced SHM methodologies, including the machine learning approaches covered in the next chapter.


7Exercises