Solving Rollercoaster Dynamics
using Physics-Informed Neural Networks

Simulation study conducted using PyTorch

Key Idea
PINNs effectively exploit automatic differentiation to differentiate neural networks with respect to their input coordinates with which the underlying (partial) differential equation(s) are encoded as physics-informing loss function(s).
Adam
  • 1st derivatives and adaptive momentum
  • Direction guess based on recent average slopes
  • Stochastic/Mini-batch
  • Crosses chaotic, bumpy, or non-convex surfaces.
L-BFGS
  • Gradients AND curvature (approx. 2nd derivatives)
  • Calculates shape of the landscape.
  • Full-batch
  • Gets easily stuck in local traps, but closes in on smooth basins fast.
Crucial Implementation Considerations
  • Smooth activations: ReLU has zero second derivative almost everywhere. Prefer Tanh, SiLU, or GELU when the PDE needs higher-order derivatives.
  • Loss weighting: Balance PDE, initial-condition, and boundary-condition losses. Poor weights can produce a physically plausible—but invalid—solution.
1. Inputs & Coordinate Sampling
Collocation Points (Interior Domain $\Omega$) • Boundary ($\partial\Omega$) & Initial Conditions
Coordinates: \((x, y, z, t)\)
2. Deep Neural Network (Surrogate Model)
Fully Connected Layers / ResNet Layers • Trainable Parameters: Weights (\(W\)) & Biases (\(b\))
Predicted Output: \(\hat{u}(x, y, z, t)\)
derivatives w.r.t inputs via AD, e.g. \(\dfrac{\partial \hat u}{\partial t}, \dfrac{\partial \hat u}{\partial x}, \dfrac{\partial^2 \hat u}{\partial x^2}\)
evaluate PINN at boundary/initial coordinates/states
3A. Governing Physics Residuals
Plugs AD derivatives into differential equations
Physics Loss: \(\mathcal{L}_p = f\!\left(\hat u, \dfrac{\partial \hat u}{\partial t}, \nabla \hat u\right) = 0\)
3B. Constraint Residuals
Evaluates mismatch with known boundaries, initials, or data
Loss Components: \(\mathcal{L}_{bc}, \mathcal{L}_{ic}, \mathcal{L}_{data}\)
4. Composite Loss Multi-Objective Function
\(\mathcal L = \lambda_{\text{phys}}\,\mathcal L_{\text{phys}} + \lambda_{\text{BC/IC}}\,\mathcal L_{\text{BC/IC}} + \underbrace{\lambda_{\text{data}}\,\mathcal L_{\text{data}}}_{\text{optional}}.\)
5. HYBRID TWO-PHASE OPTIMIZATION
Complex, stiff, difficult to optimize loss landscape.
Phase 1: Explore landscape, avoid local traps with Adam
Phase 2: Locate the exact valley bottom, satisfy strict PDE physics with L-BFGS
Update parameters $(W,b)$
Comparison to classic ODE solver
Classic ODE solvers recquire conversion of higher-order differential equations into a set of first-order differential equations known as state-space form: $$ \begin{cases} \displaystyle \frac{dx}{dt}=\frac{dv}{dt},\\[1.0em] \displaystyle \frac{dv}{dt}=\frac{\mu}{m} \frac{dx}{dt} + \frac{k}{m}x, \end{cases} $$
The solution $x(t)$ is trained using a fully-connected MLP with one input and one output, and nn.Tanh() activation:

                  import torch
                  import torch.nn as nn

                  class PINN(nn.Module):
                    def __init__(self):
                        super().__init__()
                        self.net = nn.Sequential(
                            nn.Linear(1, 32),
                            nn.Tanh(),
                            nn.Linear(32, 32),
                            nn.Tanh(),
                            nn.Linear(32, 1)
                        )
                        
                    def forward(self, t):
                        return self.net(t)
  

A classic ODE system is a damped harmonic oscillator governed by the differential equiation $$ 0 =m \frac{d^2x}{dt^2} + \mu \frac{dx}{dt} + kx, $$ with mass $m$, spring constant $k$, and linear friction coefficient $\mu$.

The physics residual for our PINN is: $$ r_x(t)=m \frac{d^2x}{dt^2} + \mu \frac{dx}{dt} + kx . $$

At interior collocation points $\{t_i\}_{i=1}^{N_f}$, the physics loss is $$ \mathcal L_{\text{phys}} = \frac{1}{N_f}\sum_{i=1}^{N_f} r_x(t_i)^2 $$

Enforcing initial conditions with $$ \mathcal L_{\text{IC}} = \big(x(0)-u_0\big)^2 + \big(v(0)-v_0\big)^2, $$

the total loss becomes: $$ \mathcal L = \lambda_{\text{phys}}\,\mathcal L_{\text{phys}} + \lambda_{\text{BC/IC}}\,\mathcal L_{\text{IC}} $$

Below the harmonic oscillator PINN is solved using PyTorch and plotted against the analytical solution.

The key of the implementation is PyTorch's autograd.grad function with the option create_graph=True.


              import torch
              import torch.nn as nn
              import torch.optim as optim

              # 2. Physics Parameters (Underdamped System)
              m, mu, k = 1.0, 0.5, 4.0
              t_collocation = torch.linspace(0, 5, 500, requires_grad=True).view(-1, 1)
              t_zero = torch.tensor([[0.0]], requires_grad=True)

              model = PINN()

              # 3. Core Loss Logic Function (Needed by LBFGS Closure)
              def compute_loss():
                  # Physics Loss
                  x = model(t_collocation)
                  dx_dt = torch.autograd.grad(x, t_collocation, grad_outputs=torch.ones_like(x), create_graph=True)[0]
                  d2x_dt2 = torch.autograd.grad(dx_dt, t_collocation, grad_outputs=torch.ones_like(dx_dt), create_graph=True)[0]
                  physics_residual = m * d2x_dt2 + mu * dx_dt + k * x
                  loss_physics = torch.mean(physics_residual ** 2)
                  
                  # Initial Condition Loss (t=0, x=1, dx/dt=0)
                  x_zero = model(t_zero)
                  dx_dt_zero = torch.autograd.grad(x_zero, t_zero, grad_outputs=torch.ones_like(x_zero), create_graph=True)[0]
                  loss_IC = (x_zero - 1.0)**2 + (dx_dt_zero - 0.0)**2
                  
                  return loss_physics + 20.0 * loss_IC

              # 4. Phase 1: Adam Pre-training (Gets us close quickly)
              adam_optimizer = optim.Adam(model.parameters(), lr=0.003)

              for epoch in range(1000):
                  adam_optimizer.zero_grad()
                  loss = compute_loss()
                  loss.backward()
                  adam_optimizer.step()

              # 5. Phase 2: LBFGS Fine-tuning (Crushes remaining error)
              lbfgs_optimizer = optim.LBFGS(
                  model.parameters(), 
                  lr=1.0, 
                  max_iter=500,               # Maximum iterations per step
                  history_size=50,            # History size for Hessian approximation
                  line_search_fn="strong_wolfe" # Ensures stable learning rate adjustments
              )

              # LBFGS requires a closure step function
              def closure():
                  lbfgs_optimizer.zero_grad()
                  loss = compute_loss()
                  loss.backward()
                  return loss

              # A single .step() call with LBFGS can run up to max_iter iterations internally
              lbfgs_optimizer.step(closure)
              final_loss = compute_loss()
                  

1) Track Geometry

We represent the track as a parametric curve in the vertical plane: $$ \mathbf r(u)= \begin{bmatrix} x(u)\\ z(u) \end{bmatrix}, \quad u\in[0,1]. $$ Its tangent with respect to $u$ is $$ \mathbf r'(u)=\frac{d\mathbf r}{du} = \begin{bmatrix} x'(u)\\ z'(u) \end{bmatrix}, $$ with norm $$ \left\|\mathbf r'(u)\right\|=\sqrt{x'(u)^2+z'(u)^2}. $$ The unit tangent is $$ \hat{\mathbf T}(u)=\frac{\mathbf r'(u)}{\|\mathbf r'(u)\|}, $$ $$ T_z(u)=\hat{\mathbf T}(u)\cdot \mathbf e_z =\frac{z'(u)}{\|\mathbf r'(u)\|}. $$

2) Kinematics: Linking $u(t)$ and Physical Speed

Let $s$ be arc length along the track: $$ s(u)=\int_0^u \left\|\mathbf r'(\xi)\right\|\,d\xi. $$ Since speed along the track is $v(t)=\frac{ds}{dt}$, chain rule gives $$ \frac{ds}{dt} = \frac{ds}{du}\frac{du}{dt} = \|\mathbf r'(u)\|\frac{du}{dt}. $$ Therefore, $$ \boxed{ \frac{du}{dt}=\frac{v}{\|\mathbf r'(u)\|} }. $$ This equation converts physical speed (m/s) into parameter-speed (1/s).

3) Dynamics Along the Tangent

Using Newton’s second law projected along the track tangent: $$ m\frac{dv}{dt}=F_{\text{gravity,tan}}+F_{\text{drag,lin}}+F_{\text{drag,quad}}. $$ With gravity $\mathbf g=[0,-g]^T$, the tangential gravity contribution is $$ F_{\text{gravity,tan}}=m\,\mathbf g\cdot \hat{\mathbf T}(u) =-m g\,T_z(u). $$ A common resistive model is: $$ F_{\text{drag,lin}}=-k_1 v,\qquad F_{\text{drag,quad}}=-k_2 v|v|. $$ So $$ m\frac{dv}{dt} = -m g\,T_z(u)-k_1 v-k_2 v|v|, $$ and $$ \boxed{ \frac{dv}{dt} = -g\,T_z(u)-\frac{k_1}{m}v-\frac{k_2}{m}v|v| }. $$

Relevant Parameters
The ODE system only needs two geometry-derived quantities:
  1. $\left\lVert \frac{d\mathbf r}{du}\right\rVert$: converts physical speed $v$ to parameter speed $\dot u$,
  2. $T_z(u)$: determines the gravity projection along the track.

The track is built using SciPy's CubicSpline. A custom Track class encapsulates all relevant track parameters. Theoretically, both $\left\lVert \frac{d\mathbf r}{du}\right\rVert$ and $T_z(u)$ can be evaluated directly from SciPy splines, acting as a lookup table. However, this severs the computational graph, preventing the network from learning correctly. When passing the output $u$ into a SciPy/NumPy array to look up a value, PyTorch sees a "black box". The backward pass completely stops at that lookup step. The gradients cannot flow backward through the parameter and into the weights of the main PINN.

1) From spline geometry to learnable surrogate model

To keep the computational graph intact (preserving differentiability through the chain), we train a surrogate neural network predicting both $u$ dependent parameters: $$ u \mapsto \left\lVert \frac{d\mathbf r}{du}\right\rVert, \qquad u \mapsto T_z(u). $$ $$ t \rightarrow u(t) \rightarrow \{\|dr/du\|, T_z\} \rightarrow \text{ODE residuals}. $$

This also keeps the full PINN workflow in PyTorch and allows GPU execution.

2) Architecture and training

The track model is a basic MLP with one input and two outputs, and nn.Tanh() activation. Training inputs are uniformly sampled $u \in [0,1]$. Ground-truth labels come from the spline derivatives:

  • $\|dr/du\|$ from $\sqrt{(dx/du)^2 + (dz/du)^2}$,
  • $T_z(u)$ from $\frac{dz/du}{\|dr/du\|}$.


                import torch
                import torch.nn as nn

                class TrackNN(nn.Module):
                    def __init__(self):
                        super().__init__()
                        self.net = nn.Sequential(
                            nn.Linear(1, 64), nn.Tanh(),
                            nn.Linear(64, 64), nn.Tanh(),
                            nn.Linear(64, 64), nn.Tanh(),
                            nn.Linear(64, 2)
                        )
                    def forward(self, u):
                        return self.net(u)
  

3) Losses and optimization

The MLP is trained using the two losses with mean squared error (MSE): $$ \mathcal L_{drdu} = \lambda_{drdu}\,\frac{1}{N}\sum_i \left(\hat d_i - d_i\right)^2, $$ $$ \mathcal L_{tz} = \lambda_{tz}\,\frac{1}{N}\sum_i \left(\hat t_i - t_i\right)^2. $$ After comparing plots for $\|dr/du\|$ and $T_z(u)$, we choose $\lambda_{drdu} = 1$ and $\lambda_{tz} = 100$. We optimize with torch.optim.Adam.

Custom Track class:

                
                  import numpy as np
                  from scipy.interpolate import CubicSpline

                  class Track():
                      def __init__(self, x_points=None, z_points=None):
                          if x_points is None:
                              self._x_points= np.array([0, 5, 10, 20, 25, 20, 15, 20, 30], dtype=float)
                          else:
                              self.members = np.array(list(x_points),dtype=float) # Creates a copy to prevent side effects
                          if z_points is None:
                              self._z_points= np.array([30, 20, 10, 10, 17.5, 25, 17.5, 10, 10], dtype=float)
                          else:
                              self.members = np.array(list(z_points),dtype=float) # Creates a copy to prevent side effects
                          # Parameter values corresponding to given control points
                          u_points = np.linspace(0.0, 1.0, len(self._x_points))
                          # Cubic splines for x(u), z(u)
                          self._x_spline = CubicSpline(u_points, self._x_points)
                          self._z_spline = CubicSpline(u_points, self._z_points)

                      def x_of_u(self,u):
                          u = np.asarray(u).reshape(-1)
                          return self._x_spline(u)[:, None]

                      def z_of_u(self,u):
                          u = np.asarray(u).reshape(-1)
                          return self._z_spline(u)[:, None]
                      
                      def xz_of_u(self,u):
                          x = self.x_of_u(u)
                          z = self.z_of_u(u)
                          return x, z

                      def dr_du_vec(self,u):
                          u = np.asarray(u).reshape(-1)
                          dx_du = self._x_spline.derivative()(u)
                          dz_du = self._z_spline.derivative()(u)
                          return np.stack((dx_du, dz_du), axis=-1)

                      def dr_du_norm(self,u):
                          deriv = self.dr_du_vec(u)
                          return np.linalg.norm(deriv, axis=-1, keepdims=True)

                      def tangent_unit_vertical(self,u):
                          deriv = self.dr_du_vec(u)
                          norm = np.linalg.norm(deriv, axis=-1, keepdims=True)
                          norm = np.clip(norm, 1e-12, None)  # numerical safety
                          return deriv[:, 1:2] / norm  # T_z
                
              

Tack parameter learning:

                
                  import numpy as np
                  import torch
                  from omegaconf import DictConfig
                  from torch.optim import Adam
                  from torch.nn import MSELoss


                  def run_training():
                      # Build dataset
                      track = Track()
                      u_np = np.linspace(0.0, 1.0, 1000).reshape(-1, 1).astype(np.float32)
                      y_drdu_np = track.dr_du_norm(u_np).astype(np.float32)
                      y_tz_np = track.tangent_unit_vertical(u_np).astype(np.float32)

                      u_all = torch.from_numpy(u_np)
                      y_drdu_all = torch.from_numpy(y_drdu_np)
                      y_tz_all = torch.from_numpy(y_tz_np)

                      # Model
                      track_model = TrackNN()
                      # Optimizers
                      opt = Adam(track_model.parameters(), lr=1e-3)
                      mse = MSELoss()
                      batch_size = 128
                      epochs = 20000
                      n = u_all.shape[0]

                      for epoch in range(epochs):
                          idx = torch.randint(0, n, (batch_size,))
                          u_b = u_all[idx]
                          y_drdu_b = y_drdu_all[idx]
                          y_tz_b = y_tz_all[idx]

                          # 
                          opt.zero_grad()
                          pred_drdu_tz = track_model(u_b)
                          loss = mse(pred_drdu_tz[:,0:1], y_drdu_b) + 100*mse(pred_drdu_tz[:,1:2], y_tz_b)
                          loss.backward()
                          opt.step()
                
              

1) PINN architecture

We train a MLP state model (5 hiddeln layers with each 64 neurons) $$ t \mapsto \big(u(t), v(t)\big) $$ by minimizing a weighted sum of physics residual and initial-condition losses.

                
                import torch
                import torch.nn as nn

                class PINN(nn.Module):
                    def __init__(self):
                        super().__init__()
                        self.net = nn.Sequential(
                            nn.Linear(1, 64), nn.Tanh(),
                            nn.Linear(64, 64), nn.Tanh(),
                            nn.Linear(64, 64), nn.Tanh(),
                            nn.Linear(64, 64), nn.Tanh(),
                            nn.Linear(64, 2)
                        )
                    def forward(self, t):
                        return self.net(t)
                
              

2) Governing physics residuals

During trajectory training, the state network predicts $(u(t), v(t))$. The frozen track network is then queried at the predicted $u(t)$: $$ \hat d(u)\approx \left\|\frac{d\mathbf r}{du}\right\|, \qquad \hat T_z(u)\approx T_z(u), $$ the ODE residuals are $$ r_u(t)=\frac{du}{dt}-\frac{v}{\hat d(u)}, $$ $$ r_v(t)=\frac{dv}{dt}+g\,\hat T_z(u)+\frac{k_1}{m}v+\frac{k_2}{m}v|v|. $$ At interior collocation points $\{t_i\}_{i=1}^{N_f}$, the physics loss is $$ \mathcal L_{\text{phys}} = w_u\cdot \frac{1}{N_f}\sum_{i=1}^{N_f} r_u(t_i)^2 + w_v\cdot \frac{1}{N_f}\sum_{i=1}^{N_f} r_v(t_i)^2. $$

3) Initial-condition loss

We could enforce intitial conditions through a loss function $$ \mathcal L_{\text{IC}} = w_{u0}\,\big(u(0)-u_0\big)^2 + w_{v0}\,\big(v(0)-v_0\big)^2. $$ As an elegant alternative we hard constrain these initial conditions into the network using a HardConstrainedPINN wrapper class. It trains parameters $u_{raw}$ and $v_{raw}$ of the original PINN MLP such that $$ \begin{align} u &= u_0 + (1 - exp(-t))\,u_{raw}\\ v &= v_0 + (1 - exp(-t))\,v_{raw}. \end{align} $$


                # 1. HARD-CONSTRAIN THE NETWORK OUTSIDE THE OPTIMIZATION LOOP
                # This forces the network to mathematically satisfy ICs, eliminating ic_loss entirely.
                class HardConstrainedPINN(torch.nn.Module):
                    def __init__(self, raw_pinn, U0, V0):
                        super().__init__()
                        self.pinn = raw_pinn
                        # Register constants to ensure they stay on the correct device
                        self.register_buffer("u0", torch.tensor([[U0]], dtype=torch.float32))
                        self.register_buffer("v0", torch.tensor([[V0]], dtype=torch.float32))

                    def forward(self, t):
                        uv_raw = self.pinn(t)
                        u_raw, v_raw = uv_raw[:, 0:1], uv_raw[:, 1:2]
                        
                        # At t=0, (1 - exp(-t)) is 0, guaranteeing u = u0 and v = v0
                        # As t grows, the network freely learns the residual dynamics
                        u_constrained = self.u0 + (1.0 - torch.exp(-t)) * u_raw
                        v_constrained = self.v0 + (1.0 - torch.exp(-t)) * v_raw
                        
                        return torch.cat([u_constrained, v_constrained], dim=1)
  

4) Rewrite physics residual and clamp u

Currently, we have $$ r_u(t)=\frac{du}{dt}-\frac{v}{\hat d(u)}, $$ In theory, as we observed $\hat d(u)$ is always positive and does not go to zero, division by $\hat d(u)$ should not result in numerical issues. However, very likely this causes an imbalance between $r_u(t)$ and $r_v(t)$ due to differing magnitude orders. We solve this by changing $r_u(t)$ to: $$ r_u(t)=\hat d(u)\frac{du}{dt}-v, $$ We have trained the track within the interval $u \in [0,1]$. Especially, during the initial training steps, our PINN could predict $u$ significantly outside this region leading the optimization process into an incorrect solution space. To mitigate this, we clamp $u$ to [0,1] before extracting the track parameters.

5) Training strategy

Unlike traditional ODE solvers, PINNs learn the solution across all time collocation points simultaneously. For better convergence into the correct solution space, we split the time domain $t$ into smaller intervals, training the PINN incrementally with these expanding $t$ intervals. Each interval has a linearly rising distribution generated by a custom function rand_linear_rising and is initially trained with torch.optim.Adam and refined with torch.optim.LBFGS.


                  def rand_linear_rising(shape, a, b):
                    # 1. Generate standard uniform random numbers between 0 and 1
                    u = torch.rand(shape)
                    
                    # 2. Apply the inverse CDF for a linearly rising distribution
                    return a + (b - a) * torch.sqrt(u)
  

In contrast to real-world physics, where a cart stopping precisely at the loop's top is improbable, the PINN might frequently predict this outcome as it constitutes a trivial (constant) solution.

Final implementation:

                
                  import os
                  import torch
                  from omegaconf import DictConfig
                  import torch.optim as optim
                  from torch.optim import Adam, lr_scheduler
                  
                  # ---------------- constants ----------------
                  T_MAX = 7
                  MASS = 500.0
                  DRAG_LINEAR = 3.0
                  DRAG_QUADRATIC = 4.0
                  U0 = 0.0
                  V0 = 0.25

                  def load_track_model(ckpt_dir="checkpoints"):
                    track_model = TrackNN()
                    params_path = os.path.join(ckpt_dir, "track_model_output.0.pth")
                    track_model.load_state_dict(torch.load(params_path, map_location="cpu"))
                    return track_model


                  def train():
                      raw_pinn = PINN()
                      pinn = HardConstrainedPINN(raw_pinn, U0, V0)

                      # load frozen track heads
                      track_model = load_track_model()
                      max_epochs = 2000
                      t_int_steps = 12
                      t_max_list = [np.floor(i) for i in np.arange(1,t_int_steps + 1)*T_MAX/t_int_steps]


                      #t_res = torch.linspace(0, T_MAX, 1000).view(-1, 1).requires_grad_(True)
                      track = Track()

                      def compute_loss(current_t_max):
                          # Collocation points where we enforce ODE
                          t_int = rand_linear_rising((256,1), a=0, b=current_t_max)
                          t_res, sort_idx = torch.sort(t_int, dim=0)
                          t_res.requires_grad_(True)

                          # Define custom loss function computation
                          uv = pinn(t_res) 
                          u, v = uv[:, 0:1], uv[:, 1:2]

                          du_dt = torch.autograd.grad(u, t_res, torch.ones_like(u), create_graph=True)[0]
                          dv_dt = torch.autograd.grad(v, t_res, torch.ones_like(v), create_graph=True)[0]

                          # Using clamped u to grab track values safely
                          u_clamped = torch.clamp(u, min=0, max=1)
                          drdu_tz = track_model(u_clamped)
                          drdu, tz = drdu_tz[:, 0:1], drdu_tz[:, 1:2]

                          # ODE residuals
                          res1 = du_dt * drdu - v 
                          res2 = dv_dt + 9.81 * tz + (DRAG_LINEAR / MASS) * v + (DRAG_QUADRATIC / MASS) * v * torch.abs(v)

                          base_res_loss = (1 * (res1**2 + res2**2)).mean()

                          loss = base_res_loss
                          return loss, res1, res2


                      for i, current_t_max in enumerate(t_max_list):
                          print(f"--- t interval {i+1}/{t_int_steps} ---")
                          # --- Phase 1: Adam Pre-training ---
                          print("--- Starting Phase 1: Adam Pre-training ---")
                          opt = Adam(pinn.parameters(), lr=0.001)

                          for epoch in range(max_epochs):
                              opt.zero_grad()
                              loss, res1, res2 = compute_loss(current_t_max)
                              loss.backward()
                              opt.step()
                              
                              if epoch % 500 == 0:
                                  print(f"Adam Step {epoch:05d} | Loss: {loss.item():.2e} | res_u: {(res1**2).mean().item():.2e} | res_v: {(res2**2).mean().item():.2e}")

                          # --- Phase 2: LBFGS Fine-Tuning ---
                          print("--- Starting Phase 2: LBFGS Fine-tuning ---")
                          
                          # Instantiate LBFGS Optimizer with a stable strong_wolfe line search
                          lbfgs_opt = optim.LBFGS(
                              pinn.parameters(),
                              lr=1.0,
                              max_iter=1000,                # Max iterations within the single LBFGS block
                              history_size=100,              # Curvature history size
                              line_search_fn="strong_wolfe" # Modulates learning epoch sizes precisely
                          )
                          # LBFGS requires a self-contained closure function
                          def closure():
                              lbfgs_opt.zero_grad()
                              loss, _, _ = compute_loss(current_t_max)
                              loss.backward()
                              return loss

                          # Execute LBFGS convergence sequence
                          lbfgs_opt.step(closure)
                          
                          # Print the final loss metrics
                          final_loss = closure()
                
              

PINNs avoid many setup challenges of grid-based PDE solvers by learning a solution directly from sampled coordinates. However, this simplicity shifts the challenge to optimization.

Grid-Based Solvers (FEM / FVM)
  • Mesh required: geometry must be discretized carefully.
  • High dimensions: grids grow rapidly in size.
  • Specialized formulation: weak forms, shape functions, and discretization.
  • Inverse problems: often require separate optimization loops.
Physics-Informed Neural Networks (PINNs)
  • Mesh-free: train on sampled domain points.
  • High-dimensional inputs: no exponentially dense grid.
  • Automatic differentiation: derivatives come from Autograd.
  • Inverse problems: learn physical parameters with the network.
The Catch: Easier Setup, Harder Training
  • FEM/FVM produce structured algebraic systems with mature, reliable solvers.
  • PINNs require non-convex optimization and careful loss balancing to converge.