├── 1+1D_space_time_heat_equation └── main.py ├── 1+1D_time_stepping_heat_equation └── main.py ├── 1D_Poisson_scalar_force └── main.py ├── 1D_Poisson_vector_force └── main.py ├── 2+1D_nonlinear_heat_unknown_init_cond └── main.py ├── 2D_FSI_Lame_parameters ├── fsi_mesh.xml ├── fsi_subdomains.xml └── main.py ├── 2D_Navier_Stokes_boundary_control ├── main.py └── schaefer_turek_2D.xml ├── 2D_Poisson_diffusion_neural_networks ├── Results │ ├── data_driven.log │ ├── mixed.log │ └── physics_informed.log └── main.py ├── 2D_Poisson_thermal_fin ├── main.py ├── thermal_fin_mesh.xml └── thermal_fin_subdomains.xml └── README.md /1+1D_space_time_heat_equation/main.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch_sparse_solve import solve 3 | import matplotlib.pyplot as plt 4 | import os 5 | 6 | # number of spatial elements 7 | n_h = 150 # n_h+1 spatial DoFs; n_h-1 interior spatial DoFs 8 | # spatial mesh size 9 | h = 1.0 / n_h 10 | # number of time steps 11 | n_k = 50 # n_k temporal DoFs + initial condition 12 | # time step size 13 | k = 0.5 / n_k 14 | # number of unconstrained space-time DoFs 15 | n = (n_h-1) * n_k 16 | 17 | _t = torch.linspace(0, 0.5, n_k+1) 18 | _x = torch.linspace(0, 1, n_h+1) 19 | 20 | # initial condition 21 | u0_true = torch.sin(torch.pi * _x[1:-1]).reshape(1, n_h-1, 1).type(torch.float64) 22 | 23 | # right-hand side 24 | f = torch.kron( 25 | torch.exp(-0.5 * _t[1:]) * (0.5 + torch.pi**2 + (torch.pi**2-0.5)*_t[1:]), 26 | torch.sin(torch.pi * _x[1:-1]) 27 | ).reshape(1, n_k*(n_h-1), 1).type(torch.float64) 28 | 29 | def heat_matrix(): 30 | # NOTE: In the following, the spatial boundary nodes u(0, t) = u(1, t) = 0 31 | # are not included in the linear system. 32 | 33 | # define the 1+1D heat finite difference matrix as torch.sparse: 34 | diagonals = torch.zeros((4, n), dtype=torch.float64) 35 | diagonals[0, :] = -1. / k 36 | diagonals[1, :] = -1. / h**2 37 | diagonals[3, :] = -1. / h**2 38 | diagonals[2, :] = 1. / k + 2. / h**2 39 | 40 | for i in range(n_k-1): 41 | diagonals[1, (i+1)*(n_h-1)] = 0. # outside of diagonal blocks of size (n_h-1) x (n_h-1) 42 | diagonals[3, (i+1)*(n_h-1)+1] = 0. # outside of diagonal blocks of size (n_h-1) x (n_h-1) 43 | 44 | return torch.sparse.spdiags( 45 | diagonals=diagonals, 46 | offsets=torch.tensor([-n_h+1,-1, 0, 1]), 47 | shape=(n, n) 48 | ).unsqueeze(0) 49 | 50 | # prepare the true solution and the linear system 51 | A = heat_matrix() 52 | b_true = torch.Tensor(f).type(torch.float64) 53 | b_true[:, :n_h-1, :] += (1. / k) * u0_true 54 | u_true = solve(A, b_true) 55 | 56 | # prepare the initial guess 57 | b_guess = torch.ones_like(b_true).requires_grad_() 58 | u_guess = solve(A, b_guess) 59 | 60 | # prepare optimization 61 | iter = 0 62 | MAX_ITER = 1000 63 | optimizer = torch.optim.Rprop([b_guess], lr=0.1) 64 | print("Optimizing the initial condition of the 1+1D heat equation...") 65 | print(f"Number of parameters: {b_guess.numel()}") 66 | 67 | # Lists to store iteration numbers and corresponding loss values 68 | iterations = [] 69 | losses = [] 70 | 71 | if not os.path.exists("Results"): 72 | os.makedirs("Results") 73 | 74 | # optimize the initial condition and RHS as long as u_true and u_guess are not close enough 75 | while torch.norm(u_true - u_guess) > 1e-6 and iter < MAX_ITER: 76 | iter += 1 77 | loss = torch.norm(u_true - u_guess).item() 78 | 79 | # Append iteration and loss to lists 80 | iterations.append(iter) 81 | losses.append(loss) 82 | 83 | print(f"Iteration {iter}: Loss = {loss}") 84 | 85 | # zero the gradients 86 | optimizer.zero_grad() 87 | 88 | # solve the linear system 89 | u_guess = solve(A, b_guess) 90 | 91 | # compute the loss 92 | loss = torch.norm(u_guess-u_true) + 0.01 * torch.norm(b_guess) 93 | 94 | # backpropagate 95 | loss.backward() 96 | 97 | # update the RHS 98 | optimizer.step() 99 | 100 | # Plot iteration vs loss 101 | plt.figure() 102 | plt.plot(iterations, losses, label="Loss over iterations") 103 | plt.xlabel("Iteration") 104 | plt.ylabel("Loss") 105 | plt.yscale("log") 106 | plt.title("Loss function vs Iteration") 107 | plt.legend() 108 | plt.savefig("Results/loss_history.png") 109 | plt.clf() 110 | 111 | # plot the solution at the center of the spatial domain 112 | u_mid = [u_guess[0, i*(n_h-1)+n_h//2, 0].item() for i in range(n_k)] 113 | plt.title("Solution of the 1+1D heat equation at the center of the spatial domain") 114 | plt.plot(_t[1:], u_mid, label="Numerical solution") 115 | plt.plot(_t[1:], torch.exp(-0.5 * _t[1:]) * (1.0 + _t[1:]), label="True solution") 116 | plt.legend() 117 | plt.savefig("Results/solution_center.png") 118 | plt.clf() 119 | 120 | # plot u for the entire space-time domain (meshgrid) 121 | u_meshgrid = torch.flip(u_guess.reshape(n_k, n_h-1), [0]) 122 | plt.title("Solution of the 1+1D heat equation") 123 | plt.imshow(u_meshgrid.detach().numpy(), aspect='auto', extent=[_x[1], _x[-2], _t[1], _t[-1]]) 124 | plt.colorbar() 125 | plt.xlabel('x') 126 | plt.ylabel('t') 127 | plt.savefig("Results/solution.png") 128 | plt.clf() 129 | 130 | # plot the right-hand side 131 | b_meshgrid = torch.flip(b_guess.reshape(n_k, n_h-1), [0]) 132 | plt.title("Optimized RHS of the 1+1D heat equation") 133 | plt.imshow(b_meshgrid.detach().numpy(), aspect='auto', extent=[_x[1], _x[-2], _t[1], _t[-1]]) 134 | plt.colorbar() 135 | plt.xlabel('x') 136 | plt.ylabel('t') 137 | plt.savefig("Results/rhs.png") 138 | -------------------------------------------------------------------------------- /1+1D_time_stepping_heat_equation/main.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch_sparse_solve import solve 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | import os 6 | 7 | # number of spatial elements 8 | n_h = 150 # n_h+1 spatial DoFs; n_h-1 interior spatial DoFs 9 | # spatial mesh size 10 | h = 1.0 / n_h 11 | # number of time steps 12 | n_k = 50 # n_k temporal DoFs + initial condition 13 | # time step size 14 | k = 0.5 / n_k 15 | # number of unconstrained space-time DoFs 16 | n = (n_h-1) * n_k 17 | 18 | _t = torch.linspace(0, 0.5, n_k+1) 19 | _x = torch.linspace(0, 1, n_h+1) 20 | 21 | # initial condition 22 | u0_true = torch.sin(torch.pi * _x[1:-1]).reshape(1, n_h-1, 1).type(torch.float64) 23 | u0_guess = torch.ones_like(u0_true).requires_grad_() 24 | 25 | f = [ 26 | torch.Tensor( 27 | (torch.exp(-0.5 * tq) * (0.5 + torch.pi**2 + (torch.pi**2-0.5)*tq) ) * torch.sin(torch.pi * _x[1:-1]) 28 | ).reshape(1, n_h-1, 1).type(torch.float64) 29 | for tq in _t[1:] 30 | ] 31 | 32 | def heat_matrix(): 33 | # NOTE: In the following, the boundary nodes u(0) = u(1) = 0 34 | # are not included in the linear system. 35 | 36 | # define the system matrix for 1+1D heat equation with finite differences in time and space 37 | diagonals = torch.zeros((3, n_h-1), dtype=torch.float64) 38 | diagonals[0, :] = -1.0 / h**2 39 | diagonals[2, :] = -1.0 / h**2 40 | diagonals[1, :] = 1. / k + 2.0 / h**2 41 | 42 | return torch.sparse.spdiags( 43 | diagonals=diagonals, 44 | offsets=torch.tensor([-1, 0, 1]), 45 | shape=(n_h-1, n_h-1) 46 | ).unsqueeze(0) 47 | 48 | # solve the heat equation with backward Euler time stepping 49 | A_k = heat_matrix() 50 | 51 | def time_stepping(u0): 52 | # full space-time solution 53 | _u = [torch.zeros(1, (n_h-1), 1, dtype=torch.float64) for _ in range(n_k+1)] 54 | # add the initial condition to _u 55 | _u[0] = u0 56 | 57 | # time stepping loop for true solution 58 | for i in range(n_k): 59 | # solve the linear systems and update _u 60 | _u[i+1] = solve(A_k, f[i] + (1. / k) * _u[i]) 61 | 62 | return _u 63 | 64 | def mse_time(u1, u2): 65 | mse = 0. 66 | for i in range(n_k+1): 67 | mse += (1. / (n_k+1)) * torch.norm(u1[i] - u2[i]) 68 | return mse 69 | 70 | # prepare optimization 71 | iter = 0 72 | MAX_ITER = 500 73 | optimizer = torch.optim.Rprop([u0_guess], lr=0.1) 74 | print("Optimizing the initial condition of the 1+1D heat equation...") 75 | print(f"Number of parameters: {u0_guess.numel()}") 76 | 77 | # initialize full true and guessed solution trajectories 78 | u_true = time_stepping(u0_true) 79 | u_guess = time_stepping(u0_guess) 80 | 81 | # Lists to store iteration numbers and corresponding loss values 82 | iterations = [] 83 | losses = [] 84 | 85 | # optimize the initial condition as long as u_true and u_guess are not close enough 86 | while mse_time(u_true, u_guess) > 1e-6 and iter < MAX_ITER: 87 | iter += 1 88 | 89 | loss = mse_time(u_true, u_guess) 90 | 91 | # Append iteration and loss to lists 92 | iterations.append(iter) 93 | losses.append(loss.detach().numpy()) 94 | 95 | print(f"Iteration {iter}: Loss = {loss}") 96 | 97 | # zero the gradients 98 | optimizer.zero_grad() 99 | 100 | # solve the linear system 101 | u_guess = time_stepping(u0_guess) 102 | 103 | # compute the loss 104 | loss = torch.norm(mse_time(u_true, u_guess)) + 0.1 * torch.norm(u0_guess) 105 | 106 | # backpropagate 107 | loss.backward() 108 | 109 | # update the RHS 110 | optimizer.step() 111 | 112 | 113 | if not os.path.exists("Results"): 114 | os.makedirs("Results") 115 | 116 | # Plot iteration vs loss 117 | plt.figure() 118 | plt.plot(iterations, losses, label="Loss over iterations") 119 | plt.xlabel("Iteration") 120 | plt.ylabel("Loss") 121 | plt.yscale("log") 122 | plt.title("Loss function vs Iteration") 123 | plt.legend() 124 | plt.savefig("Results/loss_history.png") 125 | 126 | # plot u for the entire space-time domain (meshgrid) 127 | u_meshgrid = torch.zeros((n_k+1, n_h-1)) 128 | for i in range(n_k+1): 129 | u_meshgrid[i, :] = u_guess[i].flatten() 130 | u_meshgrid = torch.flip(u_meshgrid, [0]) 131 | plt.title("Solution of the 1+1D heat equation with time stepping") 132 | plt.imshow(u_meshgrid.detach().numpy(), aspect='auto', extent=[_x[1], _x[-2], _t[0], _t[-1]]) 133 | plt.colorbar() 134 | plt.xlabel('x') 135 | plt.ylabel('t') 136 | plt.savefig("Results/solution.png") 137 | plt.clf() 138 | 139 | # plot the initial condition 140 | plt.title("Optimized initial condition of the 1+1D heat equation with time stepping") 141 | plt.plot(_x[1:-1], u0_guess.detach().numpy().flatten(), label="Numerical solution") 142 | plt.plot(_x[1:-1], u0_true.detach().numpy().flatten(), label="True solution") 143 | plt.legend() 144 | plt.savefig("Results/initial_condition.png") 145 | -------------------------------------------------------------------------------- /1D_Poisson_scalar_force/main.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch_sparse_solve import solve 3 | import matplotlib.pyplot as plt 4 | import os 5 | 6 | # number of spatial elements 7 | n = 50 8 | # mesh size 9 | h = 1.0 / n 10 | # gravity force 11 | force_true = torch.Tensor([-1.]) 12 | force_guess = torch.Tensor([2.]).requires_grad_() 13 | 14 | def poisson_matrix(): 15 | # NOTE: In the following, the boundary nodes u(0) = u(1) = 0 16 | # are not included in the linear system. 17 | 18 | # define the 1D Poisson finite difference matrix as torch.sparse: 19 | # write [-1 / h**2, 2 / h**2, 1 / h**2] on the diagonals using torch.sparse.spdiags 20 | diagonals = torch.ones((3, n-1), dtype=torch.float64) * 1.0 / h**2 21 | diagonals[0, :] *= -1. 22 | diagonals[2, :] *= -1. 23 | diagonals[1, :] *= 2. 24 | 25 | A = torch.sparse.spdiags( 26 | diagonals=diagonals, 27 | offsets=torch.tensor([-1, 0, 1]), 28 | shape=(n-1, n-1) 29 | ) 30 | 31 | # reshape A to have batch dimension 1 32 | return A.unsqueeze(0) 33 | 34 | def poisson_rhs(force): 35 | # define the right-hand side 36 | b = force * torch.ones(1, n-1, 1, dtype=torch.float64) 37 | return b 38 | 39 | # prepare the true solution and the linear system 40 | A = poisson_matrix() 41 | b_true = poisson_rhs(force_true) 42 | u_true = solve(A, b_true) 43 | 44 | # prepare the initial guess 45 | b_guess = poisson_rhs(force_guess) 46 | u_guess = solve(A, b_guess) 47 | 48 | # prepare optimization 49 | iter = 0 50 | MAX_ITER = 100 51 | optimizer = torch.optim.Rprop([force_guess], lr=0.1) 52 | 53 | # Lists to store iteration numbers and corresponding loss values 54 | iterations = [] 55 | losses = [] 56 | 57 | # optimize the force parameter as long as u_true and u_guess are not close enough 58 | while torch.norm(u_true - u_guess) > 1e-6 and iter < MAX_ITER: 59 | iter += 1 60 | 61 | loss = torch.norm(u_true - u_guess).item() 62 | 63 | # Append iteration and loss to lists 64 | iterations.append(iter) 65 | losses.append(loss) 66 | 67 | print(f"Iteration {iter}: Loss = {loss}") 68 | 69 | # zero the gradients 70 | optimizer.zero_grad() 71 | 72 | # update the right-hand side 73 | b_guess = poisson_rhs(force_guess) 74 | 75 | # solve the linear system 76 | u_guess = solve(A, b_guess) 77 | 78 | # compute the loss 79 | loss = torch.norm(u_true - u_guess) 80 | 81 | # backpropagate 82 | loss.backward() 83 | 84 | # update the force parameter 85 | optimizer.step() 86 | 87 | 88 | if not os.path.exists("Results"): 89 | os.makedirs("Results") 90 | 91 | # Plot iteration vs loss 92 | plt.figure() 93 | plt.plot(iterations, losses, label="Loss over iterations") 94 | plt.xlabel("Iteration") 95 | plt.ylabel("Loss") 96 | plt.yscale("log") 97 | plt.title("Loss function vs Iteration") 98 | plt.legend() 99 | plt.savefig("Results/loss.png") 100 | plt.clf() 101 | 102 | # prepare the full solutions 103 | u_full_true = torch.zeros(n+1, dtype=torch.float64) 104 | u_full_true[1:-1] = u_true.squeeze() 105 | u_full_guess = torch.zeros(n+1, dtype=torch.float64) 106 | u_full_guess[1:-1] = u_guess.squeeze() 107 | 108 | # plot the solution 109 | x = torch.linspace(0, 1, n+1) 110 | plt.title("Inverse recovery of the force of the 1D Poisson equation") 111 | plt.plot(x, u_full_true.detach().numpy(), label=f"True solution (force={force_true.item()})") 112 | plt.plot(x, u_full_guess.detach().numpy(), label=f"Recovered solution (force={force_guess.item()})") 113 | plt.legend() 114 | plt.savefig("Results/force.png") 115 | plt.clf() 116 | 117 | # print solution at the midpoint 118 | print(f"u_true({x[n//2]}) = {u_full_true[n//2]}") 119 | print(f"u_guess({x[n//2]}) = {u_full_guess[n//2]}") 120 | -------------------------------------------------------------------------------- /1D_Poisson_vector_force/main.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch_sparse_solve import solve 3 | import matplotlib.pyplot as plt 4 | import os 5 | 6 | # number of spatial elements 7 | n = 50 8 | # mesh size 9 | h = 1.0 / n 10 | # right hand side 11 | # set dtype to torch.float64 to avoid numerical issues 12 | b_true = torch.pi**2 * torch.sin(torch.pi * torch.linspace(0, 1, n+1)[1:-1]).reshape(1, n-1, 1).type(torch.float64) 13 | b_guess = torch.ones(1, n-1, 1, dtype=torch.float64).requires_grad_() 14 | 15 | def poisson_matrix(): 16 | # NOTE: In the following, the boundary nodes u(0) = u(1) = 0 17 | # are not included in the linear system. 18 | 19 | # define the 1D Poisson finite difference matrix as torch.sparse: 20 | # write [-1 / h**2, 2 / h**2, 1 / h**2] on the diagonals using torch.sparse.spdiags 21 | diagonals = torch.ones((3, n-1), dtype=torch.float64) * 1.0 / h**2 22 | diagonals[0, :] *= -1. 23 | diagonals[2, :] *= -1. 24 | diagonals[1, :] *= 2. 25 | 26 | A = torch.sparse.spdiags( 27 | diagonals=diagonals, 28 | offsets=torch.tensor([-1, 0, 1]), 29 | shape=(n-1, n-1) 30 | ) 31 | 32 | # reshape A to have batch dimension 1 33 | return A.unsqueeze(0) 34 | 35 | # prepare the true solution and the linear system 36 | A = poisson_matrix() 37 | u_true = solve(A, b_true) 38 | 39 | # prepare the initial guess 40 | u_guess = solve(A, b_guess) 41 | 42 | # prepare optimization 43 | iter = 0 44 | MAX_ITER = 1000 45 | optimizer = torch.optim.Rprop([b_guess], lr=0.1) 46 | print("Optimizing the right-hand side of the 1D Poisson equation...") 47 | print(f"Number of parameters: {b_guess.numel()}") 48 | 49 | # Lists to store iteration numbers and corresponding loss values 50 | iterations = [] 51 | losses = [] 52 | 53 | # optimize the RHS as long as u_true and u_guess are not close enough 54 | while torch.norm(u_true - u_guess) > 1e-6 and iter < MAX_ITER: 55 | iter += 1 56 | 57 | loss = torch.norm(u_true - u_guess).item() 58 | 59 | # Append iteration and loss to lists 60 | iterations.append(iter) 61 | losses.append(loss) 62 | 63 | print(f"Iteration {iter}: Loss = {loss}") 64 | 65 | # zero the gradients 66 | optimizer.zero_grad() 67 | 68 | # solve the linear system 69 | u_guess = solve(A, b_guess) 70 | 71 | # compute the loss 72 | loss = torch.norm(u_true - u_guess) + 0.099 * torch.norm(b_guess) 73 | 74 | # backpropagate 75 | loss.backward() 76 | 77 | # update the RHS 78 | optimizer.step() 79 | 80 | if not os.path.exists("Results"): 81 | os.makedirs("Results") 82 | 83 | # Plot iteration vs loss 84 | plt.figure() 85 | plt.plot(iterations, losses, label="Loss over iterations") 86 | plt.xlabel("Iteration") 87 | plt.ylabel("Loss") 88 | plt.yscale("log") 89 | plt.title("Loss function vs Iteration") 90 | plt.legend() 91 | plt.savefig("Results/loss.png") 92 | plt.clf() 93 | 94 | # prepare the full solutions 95 | u_full_true = torch.zeros(n+1, dtype=torch.float64) 96 | u_full_true[1:-1] = u_true.squeeze() 97 | u_full_guess = torch.zeros(n+1, dtype=torch.float64) 98 | u_full_guess[1:-1] = u_guess.squeeze() 99 | 100 | # plot the solution 101 | x = torch.linspace(0, 1, n+1) 102 | plt.title("Inverse recovery of the RHS of the 1D Poisson equation") 103 | plt.plot(x, u_full_true.detach().numpy(), label=f"True solution") 104 | plt.plot(x, u_full_guess.detach().numpy(), label=f"Recovered solution") 105 | plt.legend() 106 | plt.savefig("Results/solution.png") 107 | plt.clf() 108 | 109 | plt.title("Inverse recovery of the RHS of the 1D Poisson equation") 110 | plt.plot(x[1:-1], b_true.detach().numpy().flatten(), label=f"True RHS") 111 | plt.plot(x[1:-1], b_guess.detach().numpy().flatten(), label=f"Recovered RHS") 112 | plt.legend() 113 | plt.savefig("Results/rhs.png") 114 | plt.clf() 115 | 116 | # print solution at the midpoint 117 | print(f"u_true({x[n//2]}) = {u_full_true[n//2]}") 118 | print(f"u_guess({x[n//2]}) = {u_full_guess[n//2]}") 119 | -------------------------------------------------------------------------------- /2+1D_nonlinear_heat_unknown_init_cond/main.py: -------------------------------------------------------------------------------- 1 | import torch 2 | import math 3 | import numpy as np 4 | import os 5 | 6 | # Import fenics and override necessary data structures with fenics_adjoint 7 | from fenics import * 8 | from fenics_adjoint import * 9 | import matplotlib.pyplot as plt 10 | 11 | import torch_fenics 12 | 13 | class Heat(torch_fenics.FEniCSModule): 14 | def __init__(self): 15 | super().__init__() 16 | 17 | # create mesh and create function space 18 | self.mesh = UnitSquareMesh(10, 10) 19 | self.V = FunctionSpace(self.mesh, 'P', 1) 20 | self.dofs = self.V.tabulate_dof_coordinates() 21 | print(f"Number of DoFs: {self.V.dim()}") 22 | 23 | def solve(self, u0): 24 | # temporal discretization 25 | t = 0. 26 | T = 1. 27 | k = 0.01 28 | 29 | # get time steps 30 | num_steps = int(T/k) 31 | _u = [None for _ in range(num_steps+1)] 32 | 33 | # u_n: solution from last time step 34 | u_n = u0 35 | _u[0] = u_n.copy(deepcopy=True) 36 | 37 | # variational problem 38 | u = Function(self.V) # u = u_{n+1}: current solution 39 | v = TestFunction(self.V) 40 | 41 | # right hand side 42 | f = Expression('(-2*t*exp(pow(t, 2)) + exp(t)*sin(pi*x[0])*sin(pi*x[1]) + exp(pow(t, 2)) + 2*pow(pi, 2)*exp(pow(t, 2)))*exp(-2*pow(t, 2) + t)*sin(pi*x[0])*sin(pi*x[1])', degree=4, t=t, pi=math.pi) 43 | f_old = Expression('(-2*t*exp(pow(t, 2)) + exp(t)*sin(pi*x[0])*sin(pi*x[1]) + exp(pow(t, 2)) + 2*pow(pi, 2)*exp(pow(t, 2)))*exp(-2*pow(t, 2) + t)*sin(pi*x[0])*sin(pi*x[1])', degree=4, t=t, pi=math.pi) 44 | 45 | F = (u-u_n)*v*dx + 0.5*k*dot(grad(u)+grad(u_n), grad(v))*dx + 0.5*k*(pow(u, 2)+pow(u_n, 2))*v*dx - 0.5*k*(f_old+f)*v*dx 46 | 47 | self.u_mid = [u0(Point(0.5, 0.5))] 48 | self.t_values = [t] 49 | i = 0 50 | while(t+k <= T+1e-8): 51 | # Update current time 52 | t += k 53 | 54 | # Compute solution 55 | f.t = t 56 | f_old.t = t-k 57 | solve(F == 0, u, DirichletBC(self.V, Constant(0), lambda _, on_boundary: on_boundary)) 58 | 59 | # save value at midpoint 60 | self.u_mid.append(u(Point(0.5, 0.5))) 61 | self.t_values.append(t) 62 | 63 | # Update previous solution 64 | u_n.assign(u) 65 | i += 1 66 | _u[i] = u_n.copy(deepcopy=True) 67 | 68 | return tuple(_u) # torch_fenics needs to return a tuple in solve() 69 | 70 | def input_templates(self): 71 | return Function(self.V) 72 | 73 | if __name__ == '__main__': 74 | # Construct the FEniCS module 75 | heat = Heat() 76 | 77 | if not os.path.exists("Results"): 78 | os.makedirs("Results") 79 | 80 | # get location of DoFs 81 | dofs = torch.tensor(heat.dofs, dtype=torch.float64) 82 | u0_true = torch.sin(math.pi * dofs[:,0]) * torch.sin(math.pi * dofs[:,1]) 83 | uT_true = torch.sin(math.pi * dofs[:,0]) * torch.sin(math.pi * dofs[:,1]) 84 | 85 | # return the true solution trajectory as vtk files 86 | vtkfile = File('Results/heat_solution_true.pvd') 87 | u = Function(heat.V) 88 | for t in np.linspace(0, 1, 100): 89 | _t = torch.tensor([t], dtype=torch.float64) 90 | u.vector()[:] = torch.sin(math.pi * dofs[:,0]) * torch.sin(math.pi * dofs[:,1]) * torch.exp(_t-_t*_t) 91 | vtkfile << u 92 | 93 | # perform optimization of u0_guess 94 | u0_guess = torch.autograd.Variable( 95 | torch.zeros(1, heat.V.dim(), dtype=torch.float64), 96 | requires_grad=True 97 | ) 98 | u_guess = heat(u0_guess) 99 | uT_guess = u_guess[-1] 100 | 101 | # prepare optimization 102 | iter = 0 103 | MAX_ITER = 100 104 | optimizer = torch.optim.Rprop([u0_guess], lr=0.1) 105 | print("Optimizing the initial condition in the nonlinear heat equation...") 106 | print(f"Number of parameters: {u0_guess.numel()}") 107 | 108 | # Lists to store iteration numbers and corresponding loss values 109 | iterations = [] 110 | losses = [] 111 | 112 | # optimize the parameters as long as u0_true and u0_guess, as well as uT_true and uT_guess are not close enough 113 | while torch.norm(u0_true - u0_guess) + torch.norm(uT_true - uT_guess) > 1e-6 and iter < MAX_ITER: 114 | iter += 1 115 | 116 | loss = (torch.norm(u0_true - u0_guess) + torch.norm(uT_true - uT_guess)).detach().numpy() 117 | 118 | # Append iteration and loss to lists 119 | iterations.append(iter) 120 | losses.append(loss) 121 | 122 | print(f"Iteration {iter}: Loss = {loss}") 123 | 124 | # zero the gradients 125 | optimizer.zero_grad() 126 | 127 | # solve the heat equation 128 | u_guess = heat(u0_guess) 129 | uT_guess = u_guess[-1] 130 | 131 | # compute the loss 132 | loss = torch.norm(u0_true - u0_guess) + torch.norm(uT_true - uT_guess) + 0.1 * torch.norm(u0_guess) 133 | 134 | # backpropagate 135 | loss.backward() 136 | 137 | # update the parameters 138 | optimizer.step() 139 | 140 | # Plot iteration vs loss 141 | plt.figure() 142 | plt.plot(iterations, losses, label="Loss over iterations") 143 | plt.xlabel("Iteration") 144 | plt.ylabel("Loss") 145 | plt.yscale("log") 146 | plt.title("Loss function vs Iteration") 147 | plt.legend() 148 | plt.savefig("Results/loss.png") 149 | plt.clf() 150 | 151 | # plot the solution at the midpoint 152 | plt.title("Solution at center of domain") 153 | plt.plot(heat.t_values, heat.u_mid, label="Numerical solution") 154 | plt.plot(heat.t_values, [math.exp(_t-_t*_t) for _t in heat.t_values], label="True solution") 155 | plt.legend() 156 | # save the plot 157 | plt.savefig("Results/heat_solution_center_trajectory.png") 158 | 159 | # return the recovered solution trajectory as vtk files 160 | vtkfile = File('Results/heat_solution_guess.pvd') 161 | u = Function(heat.V) 162 | for i, _u in enumerate(u_guess): 163 | u.vector()[:] = _u.detach().numpy().flatten() 164 | vtkfile << u 165 | 166 | 167 | -------------------------------------------------------------------------------- /2D_FSI_Lame_parameters/main.py: -------------------------------------------------------------------------------- 1 | import torch 2 | import numpy as np 3 | # Import fenics and override necessary data structures with fenics_adjoint 4 | from fenics import * 5 | from fenics_adjoint import * 6 | import matplotlib.pyplot as plt 7 | import os 8 | import torch_fenics 9 | 10 | set_log_level(30) # suppress FEniCS output 11 | parameters["reorder_dofs_serial"] = False # No DoF reordering -> easier solution vector manipulation 12 | 13 | class FluidStructureInteraction(torch_fenics.FEniCSModule): 14 | def __init__(self): 15 | super().__init__() 16 | 17 | # load mesh, subdomain and boundaries 18 | self.load_domain() 19 | 20 | # create function space 21 | element = { 22 | "u": VectorElement("Lagrange", self.mesh.ufl_cell(), 2), 23 | "v": VectorElement("Lagrange", self.mesh.ufl_cell(), 2), 24 | "p": FiniteElement("Lagrange", self.mesh.ufl_cell(), 1) 25 | } 26 | self.V = FunctionSpace(self.mesh, MixedElement(*element.values())) 27 | self._U = self.V.sub(0) 28 | self._V = self.V.sub(1) 29 | self._P = self.V.sub(2) 30 | print(f"Numer of DoFs: {self.V.dim():,} ({self._U.dim():,} + {self._V.dim():,} + {self._P.dim():,})") 31 | 32 | self.dof_at_tip = 10730 # beam tip DoF for this mesh 33 | print("DoF at tip of elastic beam:", self.dof_at_tip) 34 | 35 | def load_domain(self): 36 | # load mesh from xml file 37 | self.mesh = Mesh("fsi_mesh.xml") 38 | 39 | # load subdomain from xml file 40 | self.subdomains = MeshFunction("size_t", self.mesh, "fsi_subdomains.xml") 41 | 42 | # boundaries 43 | inflow = CompiledSubDomain("near(x[0], 0.) && on_boundary") 44 | wall = CompiledSubDomain("(near(x[1], 0.) || near(x[1], 0.41)) && on_boundary") 45 | outflow = CompiledSubDomain("near(x[0], 2.5) && on_boundary") 46 | cylinder = CompiledSubDomain("on_boundary && x[0]>0.1 && x[0]<0.3 && x[1]>0.1 && x[1]<0.3") 47 | beam_dirichlet = CompiledSubDomain("on_boundary && x[0]<0.3 && x[0]>0.2458257 && x[1]>0.1 && x[1]<0.3") 48 | 49 | self.facet_marker = MeshFunction("size_t", self.mesh, self.mesh.topology().dim()-1) 50 | self.facet_marker.set_all(0) 51 | inflow.mark(self.facet_marker, 1) 52 | wall.mark(self.facet_marker, 2) 53 | outflow.mark(self.facet_marker, 3) 54 | cylinder.mark(self.facet_marker, 4) 55 | beam_dirichlet.mark(self.facet_marker, 5) 56 | 57 | def input_templates(self): 58 | return Constant(0.) 59 | 60 | def solve(self, mu): 61 | # Define boundary conditions 62 | bc_u_inflow = DirichletBC(self._U, Constant((0, 0)), self.facet_marker, 1) 63 | bc_u_walls = DirichletBC(self._U, Constant((0, 0)), self.facet_marker, 2) 64 | bc_u_outflow = DirichletBC(self._U, Constant((0, 0)), self.facet_marker, 3) 65 | bc_u_cylinder = DirichletBC(self._U, Constant((0, 0)), self.facet_marker, 4) 66 | bc_u_beam = DirichletBC(self._U, Constant((0, 0)), self.facet_marker, 5) 67 | inflow_parabola = ('1.5*0.2*4.0*x[1]*(0.41 - x[1]) / pow(0.41, 2)', '0') 68 | bc_v_inflow = DirichletBC(self._V, Expression(inflow_parabola, degree=2), self.facet_marker, 1) 69 | bc_v_walls = DirichletBC(self._V, Constant((0, 0)), self.facet_marker, 2) 70 | bc_v_cylinder = DirichletBC(self._V, Constant((0, 0)), self.facet_marker, 4) 71 | bc_v_beam = DirichletBC(self._V, Constant((0, 0)), self.facet_marker, 5) 72 | bc_u = [bc_u_inflow, bc_u_walls, bc_u_outflow, bc_u_cylinder, bc_u_beam] 73 | bc_v = [bc_v_inflow, bc_v_walls, bc_v_cylinder, bc_v_beam] 74 | bc_p = [] 75 | bc = bc_u + bc_v + bc_p 76 | 77 | # material parameters 78 | mu_s = mu # 2nd Lame coefficient (solid) 79 | nu_s = 0.4 # Poisson ratio (solid) 80 | lambda_s = 2.0 * mu_s * nu_s / (1.0 - 2.0 * nu_s) # 1st Lame coefficient (solid) 81 | rho_s = 1.0e3 # density (solid) 82 | nu_f = 1.0e-3 # kinematic viscosity (fluid) 83 | rho_f = 1.0e3 # density (fluid) 84 | mu_f = nu_f * rho_f # dynamic viscosity (fluid) 85 | # extension parameters 86 | alpha_u = 1.0e-12 87 | alpha_v = 1.0e3 88 | alpha_p = 1.0e-12 89 | 90 | # integration measures 91 | dx = Measure("dx", domain=self.mesh, subdomain_data=self.subdomains) 92 | dx_solid = dx(1) # integrate over solid domain 93 | dx_fluid = dx(2) # integrate over fluid domain 94 | 95 | # split functions 96 | U = Function(self.V) 97 | (u, v, p) = split(U) 98 | Psi = TestFunction(self.V) 99 | (psi_u, psi_v, psi_p) = split(Psi) 100 | 101 | # parameters for variational form 102 | I = Identity(2) 103 | F_hat = I + grad(u) 104 | E_hat = 0.5 * (F_hat.T * F_hat - I) 105 | J_hat = det(F_hat) 106 | # stress tensors 107 | sigma_f = -p * I + mu_f * (grad(v) * inv(F_hat) + inv(F_hat).T * grad(v).T) 108 | sigma_s = 2.0 * mu_s * E_hat + lambda_s * tr(E_hat) * I 109 | 110 | # weak form 111 | # fluid equations 112 | fluid_convection = inner(rho_f * J_hat * grad(v) * inv(F_hat) * v, psi_v) * dx_fluid 113 | fluid_momentum = inner(J_hat * sigma_f * inv(F_hat).T, grad(psi_v)) * dx_fluid 114 | fluid_incompressibility = inner(div(J_hat * inv(F_hat) * v), psi_p) * dx_fluid 115 | fluid_u_extension = inner(alpha_u * grad(u), grad(psi_u)) * dx_fluid 116 | 117 | # solid equations 118 | solid_momentum = inner(F_hat * sigma_s, grad(psi_v)) * dx_solid 119 | solid_v_extension = alpha_v * inner(v, psi_u) * dx_solid 120 | solid_p_extension = alpha_p * (inner(grad(p), grad(psi_p)) + inner(p, psi_p)) * dx_solid 121 | 122 | F = fluid_convection + fluid_momentum + fluid_incompressibility + fluid_u_extension + solid_momentum + solid_v_extension + solid_p_extension 123 | 124 | # Compute Jacobian 125 | J = derivative(F, U) 126 | 127 | # Create solver 128 | problem = NonlinearVariationalProblem(F, U, bc, J) 129 | solver = NonlinearVariationalSolver(problem) 130 | 131 | prm = solver.parameters 132 | prm['newton_solver']['absolute_tolerance'] = 1e-8 133 | prm['newton_solver']['relative_tolerance'] = 1e-7 134 | prm['newton_solver']['maximum_iterations'] = 25 135 | prm['newton_solver']['relaxation_parameter'] = 1.0 136 | prm['newton_solver']['linear_solver'] = 'mumps' 137 | 138 | solver.solve() 139 | 140 | # print(U(0.6, 0.2)) # solution at the tip of the beam 141 | 142 | return ( 143 | project(u, self._U.collapse()), 144 | project(v, self._V.collapse()), 145 | project(p, self._P.collapse()) 146 | ) 147 | 148 | def save(self, U, name_prefix="test_"): 149 | # Save the solution as pvd 150 | _U = Function(self.V) 151 | _U.vector().set_local(U.detach().numpy().flatten()) 152 | _u, _v, _p = _U.split(deepcopy=True) 153 | _u.rename("u", "u") 154 | _v.rename("v", "v") 155 | _p.rename("p", "p") 156 | vtkfile_u = File(os.path.join("Results", name_prefix + "u.pvd")) 157 | vtkfile_u << _u 158 | vtkfile_v = File(os.path.join("Results", name_prefix + "v.pvd")) 159 | vtkfile_v << _v 160 | vtkfile_p = File(os.path.join("Results", name_prefix + "p.pvd")) 161 | vtkfile_p << _p 162 | 163 | if __name__ == '__main__': 164 | # Construct the FEniCS model 165 | fsi = FluidStructureInteraction() 166 | 167 | if not os.path.exists("Results"): 168 | os.makedirs("Results") 169 | 170 | mu_true = torch.tensor([[5.0e5]], dtype=torch.float64) 171 | mu_guess = torch.tensor([[5.0e3]], dtype=torch.float64, requires_grad=True) 172 | 173 | # compute the reference solution 174 | u_true, v_true, p_true = fsi(mu_true) 175 | uy_tip_true = u_true[0, fsi.dof_at_tip, 1] 176 | print(f"True mu: {mu_true.item():.5e}") 177 | print(f"True y-deformation at beam tip: {uy_tip_true.item():.5e}") 178 | # save the reference solution 179 | U_true = torch.cat((u_true.reshape(1,-1), v_true.reshape(1,-1), p_true), dim=1).flatten() 180 | fsi.save(U_true, "true_") 181 | 182 | # compute the initial guess 183 | u_guess, v_guess, p_guess = fsi(mu_guess) 184 | uy_tip_guess = u_guess[0, fsi.dof_at_tip, 1] 185 | print(f"Initial guess mu: {mu_guess.item():.5e}") 186 | print(f"Initial guess y-deformation at beam tip: {uy_tip_guess.item():.5e}") 187 | # save the initial guess 188 | U_guess = torch.cat((u_guess.reshape(1,-1), v_guess.reshape(1,-1), p_guess), dim=1).flatten() 189 | fsi.save(U_guess, "initial_") 190 | 191 | # prepare optimization 192 | iter = 0 193 | MAX_ITER = 50 194 | optimizer = torch.optim.Adam([mu_guess], lr=1.5e7) 195 | print("Optimizing the Lame-parameter in the Fluid-Structure Interaction problem...") 196 | print(f"Number of parameters: {mu_guess.numel()}") 197 | 198 | # optimize the parameters the true y-deformation at the beam tip and the guess are too far apart 199 | loss_history = [] 200 | gradient_history = [] 201 | mu_history = [mu_guess.item()] 202 | while iter < MAX_ITER: 203 | iter += 1 204 | error = torch.pow(uy_tip_true-uy_tip_guess, 2) 205 | if error < 1e-13: 206 | loss_history.append(error.item()) 207 | print(f"Reached sufficient accuracy: Error = {error.item():.5e}") 208 | break # reached sufficient accuracy 209 | print(f"Iteration {iter}: Error = {error.item():.5e}") 210 | 211 | # zero the gradients 212 | optimizer.zero_grad() 213 | 214 | # compute the loss 215 | loss = torch.pow(uy_tip_true-uy_tip_guess, 2) 216 | loss_history.append(loss.item()) 217 | 218 | # backpropagate 219 | loss.backward() 220 | print(f" Gradient: {mu_guess.grad.item():.5e}") 221 | gradient_history.append(mu_guess.grad.item()) 222 | 223 | # update the parameter 224 | optimizer.step() 225 | print(f" New guess mu: {mu_guess.item():.5e}") 226 | mu_history.append(mu_guess.item()) 227 | 228 | # solve the FSI equations 229 | u_guess, v_guess, p_guess = fsi(mu_guess) 230 | uy_tip_guess = u_guess[0, fsi.dof_at_tip, 1] 231 | print(f" Current guess y-deformation at beam tip: {uy_tip_guess.item():.5e}") 232 | 233 | # save the final guess 234 | U_guess = torch.cat((u_guess.reshape(1,-1), v_guess.reshape(1,-1), p_guess), dim=1).flatten() 235 | fsi.save(U_guess, "final_") 236 | 237 | # plot loss history 238 | plt.plot(loss_history) 239 | plt.xlabel("Iteration") 240 | plt.ylabel("Loss") 241 | plt.yscale("log") 242 | plt.title("Loss history") 243 | plt.savefig("Results/loss_history.png") 244 | plt.clf() 245 | 246 | # plot gradient history 247 | plt.plot([-g for g in gradient_history]) 248 | plt.xlabel("Iteration") 249 | plt.ylabel("Negative gradient") 250 | plt.yscale("log") 251 | plt.title("Gradient history") 252 | plt.savefig("Results/gradient_history.png") 253 | plt.clf() 254 | 255 | # plot mu history 256 | plt.plot(mu_history) 257 | plt.xlabel("Iteration") 258 | plt.ylabel("Parameter") 259 | plt.yscale("log") 260 | plt.title("Lame-parameter history") 261 | plt.savefig("Results/mu_history.png") 262 | -------------------------------------------------------------------------------- /2D_Navier_Stokes_boundary_control/main.py: -------------------------------------------------------------------------------- 1 | import torch 2 | import numpy as np 3 | # Import fenics and override necessary data structures with fenics_adjoint 4 | from fenics import * 5 | from fenics_adjoint import * 6 | import matplotlib.pyplot as plt 7 | import torch_fenics 8 | import os 9 | 10 | set_log_level(30) # suppress FEniCS output 11 | parameters["reorder_dofs_serial"] = False # No DoF reordering -> easier solution vector manipulation 12 | 13 | class NavierStokes(torch_fenics.FEniCSModule): 14 | def __init__(self): 15 | super().__init__() 16 | 17 | # load mesh from schaefer_turek_2D.xml 18 | self.mesh = Mesh("schaefer_turek_2D.xml") 19 | 20 | # create function space 21 | element = { 22 | "v": VectorElement("Lagrange", self.mesh.ufl_cell(), 2), 23 | "p": FiniteElement("Lagrange", self.mesh.ufl_cell(), 1) 24 | } 25 | self.V = FunctionSpace(self.mesh, MixedElement(*element.values())) 26 | self._V = self.V.sub(0) 27 | self._P = self.V.sub(1) 28 | 29 | # create function space for control 30 | self.W = FunctionSpace(self.mesh, 'P', 1) 31 | print(f"Number of DoFs (linear): {self.W.dim()}") 32 | 33 | # Problem data 34 | self.inflow_parabola = ('4.0*0.3*x[1]*(0.41 - x[1]) / pow(0.41, 2)', '0') 35 | self.nu = Constant(0.001) 36 | 37 | # Define boundaries 38 | self.inflow = 'near(x[0], 0)' 39 | self.outflow = 'near(x[0], 2.2)' 40 | self.walls = 'on_boundary && (near(x[1], 0) || near(x[1], 0.41)) && (x[0]<0.2 || x[0]>0.3)' 41 | self.cylinder = 'on_boundary && x[0]>0.1 && x[0]<0.3 && x[1]>0.1 && x[1]<0.3' 42 | self.control = 'on_boundary && (near(x[1], 0) || near(x[1], 0.41)) && (x[0]>=0.2-1e-4 || x[0]<=0.3+1e-4)' 43 | 44 | # get integrator over the control boundary 45 | facet_marker = MeshFunction("size_t", self.mesh, 1) 46 | facet_marker.set_all(0) 47 | CompiledSubDomain(self.control).mark(facet_marker, 1) 48 | CompiledSubDomain(self.cylinder).mark(facet_marker, 2) 49 | self.ds_control = Measure("ds", subdomain_data=facet_marker, subdomain_id=1) 50 | self.ds_cylinder = Measure("ds", subdomain_data=facet_marker, subdomain_id=2) 51 | 52 | # get the DoFs in W at the control boundary 53 | self.control_dofs = self.W.tabulate_dof_coordinates().reshape(-1, 2) 54 | self.control_dofs_ids = np.where(np.logical_and( 55 | np.logical_and(self.control_dofs[:, 0] >= 0.2, self.control_dofs[:, 0] <= 0.3), 56 | np.logical_or(np.isclose(self.control_dofs[:, 1], 0.0), np.isclose(self.control_dofs[:, 1], 0.41)) 57 | )) 58 | self.control_dofs = self.control_dofs[ 59 | self.control_dofs_ids 60 | ] 61 | self.assemble_drag_tensor() 62 | 63 | def assemble_drag_tensor(self): 64 | # preassemble vector for drag tensor such that drag(u) = U_h * drag_tensor 65 | dU = TrialFunction(self.V) 66 | dv, dp = split(dU) 67 | n = FacetNormal(self.mesh) 68 | D = 0.1 69 | v_bar = 2/3*4.0*0.3*0.205*(0.41 - 0.205) / pow(0.41, 2) 70 | self.drag_vector = assemble( 71 | 2/(v_bar**2*D)* 72 | ( 73 | - dot(dp * Identity(len(dv)), n)[0] 74 | + self.nu * dot(grad(dv), n)[0] 75 | ) * self.ds_cylinder 76 | ).get_local() 77 | 78 | def solve(self, q): 79 | # Define boundary conditions 80 | bc_v_inflow = DirichletBC(self._V, Expression(self.inflow_parabola, degree=2), self.inflow) 81 | bc_v_walls = DirichletBC(self._V, Constant((0, 0)), self.walls) 82 | bc_v_cylinder = DirichletBC(self._V, Constant((0, 0)), self.cylinder) 83 | bc_v = [bc_v_inflow, bc_v_walls, bc_v_cylinder] 84 | bc_p = [] 85 | bc = bc_v + bc_p 86 | 87 | # Define trial and test functions and function at old time step 88 | U = Function(self.V) 89 | Phi = TestFunctions(self.V) 90 | 91 | # Split functions into velocity and pressure components 92 | v, p = split(U) 93 | phi_v, phi_p = Phi 94 | 95 | # Define variational forms 96 | F = ( 97 | self.nu * inner(grad(v), grad(phi_v)) 98 | + dot(dot(grad(v), v), phi_v) 99 | - p * div(phi_v) 100 | + div(v) * phi_p 101 | ) * dx 102 | # add an integral over the control boundary 103 | n = FacetNormal(self.mesh) 104 | F -= q * inner(phi_v, n) * self.ds_control 105 | 106 | # Compute Jacobian 107 | J = derivative(F, U) 108 | 109 | # Create solver 110 | problem = NonlinearVariationalProblem(F, U, bc, J) 111 | solver = NonlinearVariationalSolver(problem) 112 | solver.solve() 113 | 114 | return ( 115 | project(v, self._V.collapse()), 116 | project(p, self._P.collapse()) 117 | ) 118 | 119 | def input_templates(self): 120 | return Function(self.W) 121 | 122 | def save(self, U, filename='Results/solution.pvd'): 123 | # Save the solution as pvd 124 | _U = Function(self.V) 125 | _U.vector().set_local(U.detach().numpy().flatten()) 126 | _v, _p = _U.split(deepcopy=True) 127 | 128 | # # plot magnitude of velocity with matplotlib 129 | # c = plot(sqrt(dot(_v, _v)), title='Velocity') 130 | # plt.colorbar(c, orientation='horizontal') 131 | # plt.show() 132 | 133 | vtkfile_v = File(filename.replace(".pvd", "_v.pvd")) 134 | vtkfile_v << _v 135 | vtkfile_p = File(filename.replace(".pvd", "_p.pvd")) 136 | vtkfile_p << _p 137 | 138 | if __name__ == '__main__': 139 | # Construct the FEniCS model 140 | navier_stokes = NavierStokes() 141 | 142 | if not os.path.exists("Results"): 143 | os.makedirs("Results") 144 | 145 | d = navier_stokes.control_dofs_ids[0].shape[0] 146 | # print(navier_stokes.control_dofs) 147 | 148 | # get torch sparse matrix that maps control DoFs to global DoFs 149 | indices = torch.cat( 150 | [torch.tensor(navier_stokes.control_dofs_ids[0]).reshape(-1, 1), 151 | torch.arange(d).reshape(-1, 1)], 152 | dim=1 153 | ) 154 | values = torch.ones((d,)) 155 | q_guess_matrix = torch.sparse_coo_tensor( 156 | indices.t(), 157 | values, 158 | (navier_stokes.W.dim(), d), 159 | dtype=torch.float64 160 | ) 161 | 162 | # get an initial guess for the control where only the control DoFs require a gradient 163 | q_guess_control = torch.zeros((d,), dtype=torch.float64, requires_grad=True) 164 | # add q_guess_control to the global control tensor q_guess 165 | q_guess = (q_guess_matrix @ q_guess_control).reshape((1,-1)) 166 | 167 | # compute the solution U = (v, p) for the initial guess 168 | v_guess, p_guess = navier_stokes(q_guess) 169 | # flatten v_guess: v_x and v_y are in the same tensor 170 | v_guess = v_guess.reshape(1, -1) 171 | # concatenate velocity and pressure to a single tensor 172 | U_guess = torch.cat((v_guess, p_guess), dim=1).flatten() 173 | # save the initial guess as pvd 174 | navier_stokes.save(U_guess, filename='Results/navier_stokes_initial_guess.pvd') 175 | assert torch.isnan(U_guess).sum().item() == 0, "U_guess contains NaN values!" 176 | 177 | # compute drag value for an arbitrary solution U 178 | drag_vector = torch.tensor(navier_stokes.drag_vector) 179 | 180 | # prepare optimization 181 | iter = 0 182 | MAX_ITER = 50 183 | optimizer = torch.optim.Rprop([q_guess_control], lr=0.1) 184 | print("Optimizing the control in the Navier-Stokes problem...") 185 | print(f"Number of parameters: {q_guess_control.numel()}") 186 | 187 | # optimize the parameters as long drag is not close enough to zero 188 | loss_history = [] 189 | while iter < MAX_ITER: 190 | iter += 1 191 | print(f"Iteration {iter}: Drag = {torch.pow(torch.dot(drag_vector, U_guess), 2)}") 192 | 193 | # zero the gradients 194 | optimizer.zero_grad() 195 | 196 | # add q_guess_control to the global control tensor q_guess 197 | q_guess = (q_guess_matrix @ q_guess_control).reshape((1,-1)) 198 | 199 | # solve the Navier-Stokes equation 200 | v_guess, p_guess = navier_stokes(q_guess) 201 | v_guess = v_guess.reshape(1, -1) # now v_x and v_y are in the same tensor 202 | # concatenate velocity and pressure to a single tensor 203 | U_guess = torch.cat((v_guess, p_guess), dim=1).flatten() 204 | 205 | # compute the loss 206 | # loss = torch.dot(drag_vector, U_guess) 207 | loss = torch.pow(torch.dot(drag_vector, U_guess), 2) 208 | loss_history.append(loss.item()) 209 | 210 | # backpropagate 211 | loss.backward() 212 | # update the parameters 213 | optimizer.step() 214 | 215 | # apply constraint (non-negativity) 216 | with torch.no_grad(): 217 | q_guess_control.clamp_( 218 | min=torch.tensor([0.]*d, dtype=torch.float64), 219 | max=torch.tensor([100.]*(d-3)+[0.7, 0.7, 100.], dtype=torch.float64) # enforce q(0.3, y) <= 0.7 to avoid failure of NonlinearVariationalSolver (Newton diverges) 220 | ) 221 | 222 | # save the optimized flow 223 | navier_stokes.save(U_guess, filename='Results/navier_stokes_optimized.pvd') 224 | 225 | # plot optimal control at boundary 226 | colors = ["red", "blue"] 227 | for i, y in enumerate([0., 0.41]): 228 | X = zip( 229 | list(navier_stokes.control_dofs[:, 0][navier_stokes.control_dofs[:, 1] == y]), 230 | list(q_guess_control.detach().numpy()[navier_stokes.control_dofs[:, 1] == y]) 231 | ) 232 | X = sorted(X, key=lambda x: x[0]) 233 | plt.plot( 234 | [x[0] for x in X], [x[1] for x in X], 235 | color=colors[i], label=f"Control at y={y}" 236 | ) 237 | plt.xlabel("x") 238 | plt.ylabel("q(x,y)") 239 | plt.title("Control at the control boundary") 240 | plt.legend() 241 | plt.savefig("Results/optimal_control_at_boundary.png") 242 | plt.clf() 243 | 244 | # plot loss history 245 | plt.plot(loss_history) 246 | plt.xlabel("Iteration") 247 | plt.ylabel("Loss") 248 | plt.yscale("log") 249 | plt.title("Loss history") 250 | plt.savefig("Results/loss_history.png") 251 | -------------------------------------------------------------------------------- /2D_Poisson_diffusion_neural_networks/Results/data_driven.log: -------------------------------------------------------------------------------- 1 | Number of parameters in the neural network: 81 2 | Number of DoFs: 1,681 3 | Iteration 0: Loss = 9.401031910940981 4 | Iteration 100: Loss = 0.05224335360997782 5 | Iteration 200: Loss = 0.025735705866030398 6 | Iteration 300: Loss = 0.007206255000686377 7 | Iteration 400: Loss = 0.0012534268512968785 8 | Iteration 500: Loss = 0.0005396643288943364 9 | Iteration 600: Loss = 0.00046765487115604093 10 | Iteration 700: Loss = 0.00044013941194380403 11 | Iteration 800: Loss = 0.00041476626914453165 12 | Iteration 900: Loss = 0.00038919411858285934 13 | -------------------------------------------------------------------------------- /2D_Poisson_diffusion_neural_networks/Results/mixed.log: -------------------------------------------------------------------------------- 1 | Number of parameters in the neural network: 81 2 | Number of DoFs: 25 3 | Iteration 0: Loss = 10.200612226415648 4 | Iteration 100: Loss = 0.08648861951517837 5 | Iteration 200: Loss = 0.029174584565765875 6 | Iteration 300: Loss = 0.002896221095157525 7 | Iteration 400: Loss = 0.0004495694949209146 8 | Iteration 500: Loss = 0.0003706593000747273 9 | Iteration 600: Loss = 0.0003592063823259324 10 | Iteration 700: Loss = 0.00034864508402666515 11 | Iteration 800: Loss = 0.00033791221993110057 12 | Iteration 900: Loss = 0.0003269688834737215 13 | Number of DoFs: 1,681 14 | MSE before training: 0.0005091303960597676 15 | Iteration 0: Loss = 0.056327230403146446 16 | Iteration 100: Loss = 0.027081264241023074 17 | Iteration 200: Loss = 0.023878444915978212 18 | Iteration 300: Loss = 0.022372865665357282 19 | Iteration 400: Loss = 0.021022414154959677 20 | Iteration 500: Loss = 0.01990341854027045 21 | Iteration 600: Loss = 0.01938231169421008 22 | Iteration 700: Loss = 0.01911970427083502 23 | Iteration 800: Loss = 0.019037910095637933 24 | Iteration 900: Loss = 0.01894200885314782 25 | MSE after training: 0.0002807348803984052 26 | -------------------------------------------------------------------------------- /2D_Poisson_diffusion_neural_networks/Results/physics_informed.log: -------------------------------------------------------------------------------- 1 | Number of parameters in the neural network: 81 2 | Number of DoFs: 1,681 3 | MSE before training: 9.399370007277463 4 | Iteration 0: Loss = 375.28846569331995 5 | Iteration 100: Loss = 0.7143815391266269 6 | Iteration 200: Loss = 0.5866234124635324 7 | Iteration 300: Loss = 0.5124212697238542 8 | Iteration 400: Loss = 0.41832905651331226 9 | Iteration 500: Loss = 0.34532190087364273 10 | Iteration 600: Loss = 0.29652521492146444 11 | Iteration 700: Loss = 0.26843672473065733 12 | Iteration 800: Loss = 0.21681661105371702 13 | Iteration 900: Loss = 0.1755849962034755 14 | MSE after training: 0.009186003649620276 15 | -------------------------------------------------------------------------------- /2D_Poisson_diffusion_neural_networks/main.py: -------------------------------------------------------------------------------- 1 | import torch 2 | # Import fenics and override necessary data structures with fenics_adjoint 3 | from fenics import * 4 | from fenics_adjoint import * 5 | import matplotlib.pyplot as plt 6 | import math 7 | import argparse 8 | import torch_fenics 9 | 10 | plt.rcParams['text.usetex'] = True 11 | 12 | class Poisson(torch_fenics.FEniCSModule): 13 | def __init__(self, n_elements=40): 14 | super().__init__() 15 | 16 | # create unit square mesh 17 | self.mesh = UnitSquareMesh(n_elements, n_elements) 18 | self.V = FunctionSpace(self.mesh, 'P', 1) 19 | print(f"Number of DoFs: {self.V.dim():,}") 20 | 21 | # get the DoFs in self.V 22 | self.dofs = self.V.tabulate_dof_coordinates().reshape(-1, 2) 23 | 24 | def solve(self, kappa): 25 | # Create trial and test functions 26 | u = TrialFunction(self.V) 27 | v = TestFunction(self.V) 28 | 29 | # right hand side 30 | f = Expression('-6*pi*sin(pi*x[0])*cos(pi*x[1]) + 2*pi*pi*(2*x[0]+3*x[1]*x[1]+1)*sin(pi*x[0])*sin(pi*x[1]) - 2*pi*sin(pi*x[1])*cos(pi*x[0])', degree=2, pi=math.pi) 31 | 32 | # homogeneous Dirichlet boundary conditions 33 | bc = DirichletBC(self.V, Constant(0), lambda _, on_boundary: on_boundary) 34 | 35 | # Construct bilinear form: 36 | a = dot(kappa * grad(u), grad(v))*dx 37 | L = f*v*dx 38 | 39 | # Solve the Poisson equation 40 | u = Function(self.V) 41 | solve(a == L, u, bc) 42 | 43 | # Return the solution 44 | return u 45 | 46 | def input_templates(self): 47 | return Function(self.V) 48 | 49 | def plot(self, u, plot_=True, title='Poisson solution', save=False, filename='solution.pvd'): 50 | # Plot the solution 51 | _u = Function(self.V) 52 | _u.vector().set_local(u.detach().numpy().flatten()) 53 | 54 | # Create matplotlib figure 55 | if plot_: 56 | c = plot(_u, title=title) 57 | plt.colorbar(c) 58 | plt.show() 59 | 60 | # Save the solution as a pvd file 61 | if save: 62 | vtkfile = File(filename) 63 | vtkfile << _u 64 | 65 | def data_driven_training(nn, n_elements=40, name="data", save=True): 66 | # Construct the FEniCS model 67 | poisson = Poisson(n_elements=n_elements) 68 | 69 | # Define the input and target 70 | input = torch.tensor(poisson.dofs, dtype=torch.float64) 71 | target = 1. + 2. * input[:, 0] + 3. * input[:, 1]**2 72 | target = target.reshape(-1, 1) 73 | 74 | # Define the loss function 75 | loss_fn = torch.nn.MSELoss() 76 | 77 | # Define the optimizer 78 | optimizer = torch.optim.Adam(nn.parameters(), lr=0.1) 79 | 80 | # Train the neural network 81 | for i in range(1000): 82 | # Zero the gradients 83 | optimizer.zero_grad() 84 | 85 | # Forward pass 86 | output = nn(input) 87 | loss = loss_fn(output, target) 88 | 89 | # Backward pass 90 | loss.backward() 91 | 92 | # Update the parameters 93 | optimizer.step() 94 | 95 | # Print the loss 96 | if i % 100 == 0: 97 | print(f"Iteration {i}: Loss = {loss.item()}") 98 | 99 | # plot the neural network solution, the target and the error 100 | u_nn = nn(input).detach() 101 | poisson.plot(u_nn, title=r'$\kappa_{NN}^{guess}$', plot_=True, save=save, filename=f"poisson_{name}_nn_solution.pvd") 102 | poisson.plot(target, title=r'$\kappa^{true}$', plot_=True, save=save, filename=f"poisson_{name}_target_solution.pvd") 103 | poisson.plot((u_nn - target)**2, title=r'$(\kappa^{true} - \kappa_{NN}^{guess})^2$', plot_=True, save=save, filename=f"poisson_{name}_error.pvd") 104 | 105 | return None 106 | 107 | def physics_informed_training(nn, n_elements=40, name="physics", learning_rate=0.1, save=True): 108 | # Construct the FEniCS model 109 | poisson = Poisson(n_elements=n_elements) 110 | 111 | # Define the input and target 112 | input = torch.tensor(poisson.dofs, dtype=torch.float64) 113 | target = 1. + 2. * input[:, 0] + 3. * input[:, 1]**2 114 | target = target.reshape(-1, 1) 115 | u_true = poisson(target.T) 116 | 117 | # Define the optimizer 118 | optimizer = torch.optim.Rprop(nn.parameters(), lr=learning_rate) 119 | 120 | print("MSE before training:", torch.nn.MSELoss()(nn(input), target).item()) 121 | 122 | # Train the neural network 123 | for i in range(1000): 124 | # Zero the gradients 125 | optimizer.zero_grad() 126 | 127 | # Forward pass 128 | kappa = nn(input).T 129 | # solve the Poisson equation 130 | u_guess = poisson(kappa) 131 | 132 | # compute the loss 133 | loss = torch.norm(u_true - u_guess) # NOTE: possible to also add data loss here, i.e. compare kappa with target 134 | 135 | # Backward pass 136 | loss.backward() 137 | 138 | # Update the parameters 139 | optimizer.step() 140 | 141 | # Print the loss 142 | if i % 100 == 0: 143 | print(f"Iteration {i}: Loss = {loss.item()}") 144 | 145 | print("MSE after training:", torch.nn.MSELoss()(nn(input), target).item()) 146 | 147 | # plot the neural network solution, the target and the error 148 | u_nn = nn(input).detach() 149 | poisson.plot(u_nn, title=r'$\kappa_{NN}^{guess}$', plot_=True, save=save, filename=f"poisson_{name}_nn_solution.pvd") 150 | poisson.plot(target, title=r'$\kappa^{true}$', plot_=True, save=save, filename=f"poisson_{name}_target_solution.pvd") 151 | poisson.plot((u_nn - target)**2, title=r'$(\kappa^{true} - \kappa_{NN}^{guess})^2$', plot_=True, save=save, filename=f"poisson_{name}_error.pvd") 152 | 153 | return None 154 | 155 | if __name__ == '__main__': 156 | # load arguments from CLI 157 | parser = argparse.ArgumentParser() 158 | parser.add_argument("--train_type", type=str, default="physics_informed", choices=["data_driven", "physics_informed", "mixed"], help="How to train the neural network: 'data_driven' or 'physics_informed' or 'mixed'?") 159 | args = parser.parse_args() 160 | 161 | # Define the neural network 162 | nn = torch.nn.Sequential( 163 | torch.nn.Linear(2, 20), 164 | torch.nn.Sigmoid(), 165 | torch.nn.Linear(20, 1) 166 | ).to(torch.float64) 167 | print("Number of parameters in the neural network:", sum(p.numel() for p in nn.parameters())) 168 | 169 | 170 | if args.train_type == "data_driven": 171 | data_driven_training(nn, n_elements=40, save=False) # train NN purely on data 172 | elif args.train_type == "mixed": 173 | data_driven_training(nn, n_elements=4, name="pretrain", save=False) # pre-training NN on coarse data 174 | physics_informed_training(nn, n_elements=40, name="finetune", save=False) # fine-tuning NN on physics on fine mesh 175 | elif args.train_type == "physics_informed": 176 | physics_informed_training(nn, n_elements=40, learning_rate=0.001, save=False) # train NN purely on physics 177 | else: 178 | raise ValueError(f"Invalid training type: {args.train_type}") 179 | -------------------------------------------------------------------------------- /2D_Poisson_thermal_fin/main.py: -------------------------------------------------------------------------------- 1 | import torch 2 | import numpy as np 3 | from fenics import * 4 | from fenics_adjoint import * 5 | import matplotlib.pyplot as plt 6 | import torch_fenics 7 | import os 8 | 9 | if not os.path.exists("Results"): 10 | os.makedirs("Results") 11 | 12 | class Poisson(torch_fenics.FEniCSModule): 13 | def __init__(self): 14 | super().__init__() 15 | 16 | # Load mesh from xml file and create function space 17 | self.mesh = Mesh("thermal_fin_mesh.xml") 18 | self.V = FunctionSpace(self.mesh, 'P', 1) 19 | print(f"Number of DoFs: {self.V.dim()}") 20 | 21 | # Load subdomains from xml file 22 | self.subdomains = MeshFunction("size_t", self.mesh, "thermal_fin_subdomains.xml") 23 | self.dx = Measure('dx', domain=self.mesh, subdomain_data=self.subdomains) 24 | 25 | # mark boundaries for boundary conditions 26 | self.neumann = CompiledSubDomain("near(x[1], 0.) && on_boundary") 27 | self.robin = CompiledSubDomain("!near(x[1], 0.) && on_boundary") 28 | # create facet marker 29 | self.facet_marker = MeshFunction("size_t", self.mesh, self.mesh.topology().dim()-1) 30 | self.facet_marker.set_all(0) 31 | self.neumann.mark(self.facet_marker, 1) 32 | self.robin.mark(self.facet_marker, 2) 33 | self.ds = Measure('ds', domain=self.mesh, subdomain_data=self.facet_marker) 34 | 35 | def solve(self, mu): 36 | # Create trial and test functions 37 | u = TrialFunction(self.V) 38 | v = TestFunction(self.V) 39 | 40 | # Construct bilinear form: 41 | # * subdomain integrals with different heat conductivities mu[i] = k_i 42 | a = mu[0] * inner(grad(u), grad(v)) * self.dx(1) + mu[1] * inner(grad(u), grad(v)) * self.dx(2) + mu[2] * inner(grad(u), grad(v)) * self.dx(3) + mu[3] * inner(grad(u), grad(v)) * self.dx(4) + mu[4] * inner(grad(u), grad(v)) * self.dx(5) 43 | 44 | # * boundary integral for Robin boundary condition with heat transfer coefficient mu[5] = Bi 45 | a += mu[5] * u * v * self.ds(2) 46 | 47 | # Construct linear form 48 | L = Constant(1.) * v * self.ds(1) 49 | 50 | # Solve the Poisson equation 51 | u = Function(self.V) 52 | solve(a == L, u) 53 | 54 | # Return the solution 55 | return u 56 | 57 | def input_templates(self): 58 | # Declare templates for the inputs to Poisson.solve 59 | return Constant((0, 0, 0, 0, 0, 0)) 60 | 61 | def plot(self, u, plot_=True, title='Poisson solution', save=False, filename='Results/solution.pvd'): 62 | # Plot the solution 63 | _u = Function(self.V) 64 | _u.vector().set_local(u.detach().numpy().flatten()) 65 | 66 | # Create matplotlib figure 67 | if plot_: 68 | c = plot(_u, title='Temperature') 69 | plt.colorbar(c) 70 | plt.show() 71 | 72 | # Save the solution as a pvd file 73 | if save: 74 | vtkfile = File(filename) 75 | vtkfile << _u 76 | 77 | 78 | if __name__ == '__main__': 79 | # Construct the FEniCS model 80 | poisson = Poisson() 81 | 82 | if not os.path.exists("Results"): 83 | os.makedirs("Results") 84 | 85 | # mu = [k0, k1, k2, k3, k4, Bi] -> parameters in PDE which are to be learned 86 | mu_true = torch.tensor([[0.1, 8.37317446377103, 6.572276066240383, 0.46651735398635275, 1.8835410659596712, 0.01]], dtype=torch.float64) 87 | 88 | # get the true solution of the Poisson equation 89 | u_true = poisson(mu_true) 90 | 91 | # plot the true solution 92 | poisson.plot(u_true, plot_=False, title="Reference solution", save=True, filename='Results/thermal_fin_true_solution.pvd') 93 | 94 | # get a reference mu for regularization 95 | mu_reference = torch.tensor([[1., 1., 1., 1., 1., 0.1]], dtype=torch.float64) 96 | 97 | # perform optimization of mu_guess 98 | mu_guess = torch.tensor(0.5 * torch.ones(1, 6, dtype=torch.float64), requires_grad=True) # = [0.5, 0.5, 0.5, 0.5, 0.5, 0.5] 99 | u_guess = poisson(mu_guess) 100 | 101 | # prepare optimization 102 | iter = 0 103 | MAX_ITER = 100 104 | optimizer = torch.optim.Rprop([mu_guess], lr=0.01) 105 | print("Optimizing the parameters in the thermal fin problem...") 106 | print("Number of parameters: {mu_guess.numel()}") 107 | 108 | # Lists to store iteration numbers and corresponding loss values 109 | iterations = [] 110 | losses = [] 111 | 112 | # optimize the parameters as long as u_true and u_guess are not close enough at the Neumann boundary 113 | # loss_history = [] 114 | while torch.norm(u_true - u_guess) > 1e-6 and iter < MAX_ITER: 115 | iter += 1 116 | 117 | loss = torch.norm(u_true - u_guess).detach().numpy() 118 | 119 | # Append iteration and loss to lists 120 | iterations.append(iter) 121 | losses.append(loss) 122 | 123 | print(f"Iteration {iter}: Loss = {loss}") 124 | 125 | # zero the gradients 126 | optimizer.zero_grad() 127 | 128 | # solve the Poisson equation 129 | u_guess = poisson(mu_guess) 130 | 131 | # compute the loss 132 | loss = torch.norm(u_true - u_guess) + 0.1 * torch.norm((mu_guess - mu_reference) / mu_reference) 133 | 134 | # backpropagate 135 | loss.backward() 136 | 137 | # update the parameters 138 | optimizer.step() 139 | 140 | # apply constraints 141 | with torch.no_grad(): 142 | mu_guess.clamp_( 143 | min=torch.tensor([0.1, 0.1, 0.1, 0.1, 0.1, 0.01], dtype=torch.float64), 144 | max=torch.tensor([10., 10., 10., 10., 10., 1.], dtype=torch.float64) 145 | ) 146 | # Plot iteration vs loss 147 | plt.figure() 148 | plt.plot(iterations, losses, label="Loss over iterations") 149 | plt.xlabel("Iteration") 150 | plt.ylabel("Loss") 151 | plt.yscale("log") 152 | plt.title("Loss function vs Iteration") 153 | plt.legend() 154 | plt.savefig("Results/loss.png") 155 | plt.clf() 156 | 157 | # plot the recovered solution 158 | poisson.plot(u_guess, plot_=False, title="Recovered solution", save=True, filename='Results/thermal_fin_recovered_solution.pvd') 159 | print(f"Recovered parameters: {mu_guess}") 160 | print(f"True parameters: {mu_true}") 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Optimal Control with PDEs solved by a Differentiable Solver 2 | [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.13353212.svg)](https://doi.org/10.5281/zenodo.13353212) 3 | 4 | This repository uses differentiable PDE solvers for optimal control problems. 5 | Therein, two different approaches are shown: 6 | 1) using [torch_sparse_solve](https://github.com/flaport/torch_sparse_solve) to enable differentiation through sparse direct solvers 7 | 2) using [torch-fenics](https://github.com/barkm/torch-fenics) to seamlessly integrate [FEniCS](https://fenicsproject.org/) solvers with [PyTorch](https://github.com/pytorch/pytorch) and especially neural network based control 8 | 9 | The first four examples require the [torch_sparse_solve](https://github.com/flaport/torch_sparse_solve) library, along with its [dependencies](https://github.com/flaport/torch_sparse_solve#dependencies), and [matplotlib](https://github.com/matplotlib/matplotlib), 10 | where the last five examples require [torch-fenics](https://github.com/barkm/torch-fenics) with its [dependencies](https://github.com/barkm/torch-fenics#install) including [FEniCS](https://fenicsproject.org/). 11 | 12 | ## Authors 13 | - [Denis Khimin](https://github.com/Denis-Khimin) 14 | - [Julian Roth](https://github.com/mathmerizing) 15 | - [Alexander Henkes](https://github.com/ahenkes1) 16 | - [Thomas Wick](https://github.com/tommeswick) 17 | 18 | ## Numerical Examples 19 | ### Example 1: 1D Poisson with scalar-valued force (clothesline) 20 | 21 | The goal is to determine an approximate force, denoted as $f^{\text{guess}}$, that produces a specific outcome. The qualiy of the force $f^{\text{guess}}$ is measured by the distance between the 22 | corresponding solution of the PDE (see below) $u(f^{\text{guess}})$ and some desired solution $u(f^{\text{true}})$ for a given force $f^{\text{true}}$ which generates it. 23 | Mathematically, this involves minimizing a tracking-type 24 | cost functional (or loss function) $J$, subject to constraints imposed by the 1D Poission equation on the domain [0,1]. 25 | More precisely, in the PDE constraint, we solve 26 | for a function $u \colon (0,1) \to \mathbb{R}$. The overall problem reads as 27 | ```math 28 | \begin{align*} 29 | \min_{f^{\text{guess}}}&\quad J(f^{\text{guess}}) := \| u(f^{\text{true}}) - u(f^{\text{guess}})\| \\ 30 | \text{ s.t.}&-\partial_x^2 u(x) = f^{\text{guess}}, \\ 31 | &\hspace{2em}u(0) = u(1) = 0. 32 | \end{align*} 33 | ``` 34 | From a mechanical perspective, this example simulates a clothesline that deforms under gravity. 35 | The solver tries then to determine the scalar-valued gravity from observations of the clothesline's deformation. 36 | The solver can be found in [Example 1: 1D_Poisson_scalar_force](./1D_Poisson_scalar_force/main.py). 37 | 38 | ### Example 2: 1D Poisson with vector-valued force 39 | 40 | This example builds on the previous one, but now the gravitational force is vector valued, i.e., it depends on the spatial point $x$. 41 | Instead of determining a scalar value, we aim to determine a vector-valued gravity field. 42 | The solver can be found in [Example 2: 1D_Poisson_vector_force](./1D_Poisson_vector_force/main.py). 43 | 44 | ### Example 3: 1+1D space-time heat equation with vector-valued force and initial condition 45 | 46 | In the light of Example 1, the goal here is quite similar. We want to find a control, i.e., a right hand side of a PDE wich leads to a certain outcome. 47 | Once again, we minimize a tracking-type cost functional regularized with a Tikhonov term, where the constraint is given by the heat equation (a nonstationary PDE). 48 | The overall problem reads as 49 | ```math 50 | \begin{align*} 51 | \min_{f^{\text{guess}}}\quad J(f^{\text{guess}}) := \| u&(f^{\text{true}}) - u(f^{\text{guess}})\| + \alpha \| f^{\text{guess}} \| \\ 52 | \text{ s.t.}\quad\partial_t u(x,t) -\partial_x^2 u(x,t) &= f^{\text{guess}}(x,t), \\ 53 | u(0,t) &= u(1,t) = 0,\\ 54 | u(x,0) &= u_0(x). 55 | \end{align*} 56 | ``` 57 | In this experiment we are looking for a space-time function $u \colon (0,1) \times (0,T) \to \mathbb{R}$. 58 | The discretization is also performed in a space-time fashion, i.e., not in a time incremental way. 59 | 60 | From a mechanical perspective, this example simulates the heat equation and tries to learn the right-hand side 61 | of the PDE along with the initial conditions. The solver can be found 62 | in [Example 3: 1+1D_space_time_heat_equation](./1+1D_space_time_heat_equation/main.py). 63 | 64 | ### Example 4: 1+1D time-stepping heat equation with vector-valued initial condition 65 | 66 | This example is similar to the previous one, but instead of using space-time discretization, i.e., one big system matrix, 67 | it employs the backward Euler time stepping scheme. As a result, only the initial condition is optimized in this case, 68 | not the right-hand side of the PDE. The solver can be found 69 | in [Example 4: 1+1D_time_stepping_heat_equation](./1+1D_time_stepping_heat_equation/main.py). 70 | 71 | ### Example 5: 2D Poisson problem for thermal fin with subdomain-dependent heat conductivities 72 | 73 | In this example, we consider a 2D Poisson problem describing heat dissipation in a thermal fin, see [Sec. 5.1](https://epubs.siam.org/doi/10.1137/16M1081981). 74 | The PDE constraint in strong form reads as 75 | ```math 76 | \begin{align*} 77 | \sum_{i = 0}^4 - \nabla \cdot (\kappa_i 1_{\Omega_i}(x) \nabla u(x)) &= 0, \qquad \forall x \in \Omega, \\ 78 | \kappa_i \nabla u \cdot n + Bi(u) &= 0, \qquad \forall x \in \Gamma_R \cap \Omega_i, \quad 0 \leq i \leq 4, \\ 79 | \kappa_0 \nabla u \cdot n &= 1. \qquad \forall x \in \Gamma_N. 80 | \end{align*} 81 | ``` 82 | The parameters that need to be learned are then $\mu^{guess} = (\kappa_0, \kappa_1, \kappa_2, \kappa_3, \kappa_4, Bi) \in \mathbb{R}^6$ and the 83 | loss function is defined as 84 | ```math 85 | \begin{align*} 86 | J(\mu^{\text{guess}}) := \|u(\mu^{\text{true}}) - u(\mu^{\text{guess}})\|_2 + 0.1 \left\|\frac{\mu^{\text{guess}}-\mu^{\text{ref}}}{\mu^{\text{ref}}}\right\|_2, 87 | \end{align*} 88 | ``` 89 | where $\mu^{ref}$ are some reference coefficients. 90 | The solver can be found in [Example 5: 2D_Poisson_thermal_fin](./2D_Poisson_thermal_fin/main.py). 91 | 92 | ### Example 6: 2+1D nonlinear heat equation with unknown initial condition 93 | 94 | Here, the PDE constraint is given by the nonlinear heat equation $\partial_t u - \Delta u + u^2 = f$ with initial conditions $u(t=0) = u_0$. 95 | As in the previous nonstationary examples, $u$ represents a space-time function $u\colon [0,1]^2 \times (0,1) \to \mathbb{R}$. 96 | The objective here is to determine an initial condition that minimizes a specific loss function, thereby leading to the desired observations. 97 | Unlike in Example 4, we employ a Crank-Nicolson time-stepping scheme here. 98 | 99 | The solver can be found in [Example 6: 2+1D_nonlinear_heat_unknown_init_cond](./2+1D_nonlinear_heat_unknown_init_cond/main.py). 100 | 101 | ### Example 7: 2D Navier-Stokes with boundary control to minimize drag 102 | 103 | In this experiment, we consider the stationary Navier-Stokes equations on a 2D rectangular domain with a cylindrical obstacle obstructing the flow. 104 | The objective is to determine a Neumann boundary control that minimizes the drag coefficient on the boundary of the obstacle. 105 | In the strong form the PDE is given as 106 | ```math 107 | \begin{align*} 108 | - \nu \Delta v + \nabla p + (v \cdot \nabla)v &= 0 \qquad \text{in } \Omega, \\ 109 | \nabla \cdot v &= 0 \qquad \text{in } \Omega, 110 | \end{align*} 111 | ``` 112 | where we have to determine the vector-valued velocity $v: \Omega\subset\mathbb{R}^2 \rightarrow \mathbb{R}^2$ and the scalar-valued 113 | pressure $p: \Omega\subset\mathbb{R}^2 \rightarrow \mathbb{R}$ such that the drag coefficient 114 | ```math 115 | \begin{align*} 116 | C_D(v, p) = 500 \int_{\Gamma_{\text{obstacle}}}\sigma(v, p) \cdot n \cdot \begin{pmatrix} 117 | 1 \\ 0 118 | \end{pmatrix}\ \mathrm{d}x. 119 | \end{align*} 120 | ``` 121 | is minimized. 122 | The solver can be found in [Example 7: 2D_Navier_Stokes_boundary_control](./2D_Navier_Stokes_boundary_control/main.py). 123 | 124 | ### Example 8: 2D Fluid-Structure Interaction with parameter estimation for Lamé parameter 125 | 126 | The geometrical setting of this experiment is similar to the previous one. 127 | However, the PDE constraint in this case is governed by a Fluid-Structure Interaction (FSI) model. 128 | The objective is to determine the Lamé parameters, which are material properties that reproduce a desired observation. 129 | The solution variables include the vector-valued displacement, the vector-valued velocity, and the scalar-valued pressure. 130 | The loss function measures the difference between the current displacement and the desired displacement. 131 | 132 | The solver can be found in [Example 8: 2D_FSI_Lame_parameters](./2D_FSI_Lame_parameters/main.py). 133 | 134 | ### Example 9: 2D Poisson with spatially-variable diffusion coefficient combined with neural networks 135 | 136 | In our final example, we consider a 2D Poisson problem with a spatially-variable diffusion coefficient that we want to optimize. 137 | The PDE constraint is defined as: Find $u: \Omega \subset \mathbb{R}^2 \rightarrow \mathbb{R}$ such that 138 | ```math 139 | \begin{align*} 140 | - \nabla \cdot (\kappa(x,y) \nabla u(x,y)) &= f, \qquad \forall (x,y) \in \Omega, \\ 141 | u &= 0 \qquad \forall (x,y) \in \partial \Omega. 142 | \end{align*} 143 | ``` 144 | The true (or desired) diffusion coefficient is given by $\kappa^{true}(x,y) = 1 + 2x + 3y^2$ and the tracking-type loss function 145 | is defined as $J(\kappa^{\text{guess}}) := \left\|u(\kappa^{\text{true}}) - u(\kappa^{\text{guess}})\right\|_2$. 146 | Unlike the previous examples, our goal here is to find a network surrogate for $\kappa^{\text{guess}}$. 147 | To achieve this, we use a fully connected neural network with a single hidden layer containing 20 neurons 148 | and a sigmoid activation function, resulting in 81 trainable parameters. 149 | 150 | The solver can be found in [Example 9: 2D_Poisson_diffusion_neural_networks](./2D_Poisson_diffusion_neural_networks/main.py). 151 | 152 | 153 | 154 | 155 | --------------------------------------------------------------------------------