"""Definition of PIHNN networks."""
import torch
import math, warnings
import pihnn.utils as utils
torch.set_default_dtype(torch.float64) # This also implies default complex128. It applies to all scripts of the library
[docs]
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') # Shared with other scripts of the library
def custom_format_warning(message, category, filename, lineno, line=None): # Customize output of warnings
"""
Custom formatting for warnings.
"""
return f"{filename}:{lineno}: {category.__name__}: {message}\n"
warnings.formatwarning = custom_format_warning
def derivative(f, z, holom=False, conjugate=False):
"""
Compute the derivative :math:`\\frac{df}{dz}` through PyTorch automatic differentiation.
The method requires that 'f' is obtained from 'z' and that 'z.requires_grad' was set to True.
If 'z' is complex-valued, the Wirtinger derivative is computed instead:
:math:`\\frac{\partial f}{\partial z}:= \\frac{1}{2}\left(\\frac{\partial f}{\partial x} - i\\frac{\partial f}{\partial x}\\right)`.
:param f: Function to derivatate.
:type f: :class:`torch.tensor`
:param z: Variable against which to derive.
:type z: :class:`torch.tensor`
:param holom: If True, the complex derivative is computed by assuming 'f' to be holomorphic (leading to faster calculation). Meaningful only if 'z' is complex.
:type holom: bool
:param conjugate: If True, the second Wirtinger derivative :math:`\\frac{\partial f}{\partial \overline{z}}` is computed instead. Meaningful only if 'z' is complex.
:returns: **derivative** (:class:`torch.tensor`) - Derivative of 'f' with respect to 'z'.
"""
if torch.is_floating_point(f):
f = f + 0.j
if torch.is_floating_point(z):
return torch.autograd.grad(torch.real(f), z, grad_outputs=torch.ones_like(z), create_graph = True)[0] + \
1j*torch.autograd.grad(torch.imag(f), z, grad_outputs=torch.ones_like(z), create_graph = True)[0]
elif torch.is_complex(z):
if(holom): # df/dz = 2d(Re(f))/dz when f is holomorphic => faster calculation
dfdz = torch.autograd.grad(torch.real(f), z, grad_outputs=torch.ones_like(z.real), create_graph = True)[0]
if not conjugate:
dfdz = torch.conj(dfdz) # For some reason, the torch derivative is with respect to z conj
else:
dudz = torch.autograd.grad(torch.real(f), z, grad_outputs=torch.ones_like(z.real), create_graph = True)[0]
dvdz = torch.autograd.grad(torch.imag(f), z, grad_outputs=torch.ones_like(z.real), create_graph = True)[0]
if not conjugate:
dudz = torch.conj(dudz)
dvdz = torch.conj(dvdz)
dfdz = 0.5*dudz + 0.5j*dvdz # For some reason, the torch derivative is twice the right value
return dfdz
class ComplexLinear(torch.nn.Module):
"""
Extension of :class:`torch.nn.Linear` to complex values.
So, a complex linear layer performs the operation
.. math::
y = Wx + b,
where the input :math:`x` is a complex vector of dimension in_features, the output :math:`y` and the bias :math:`b`
are complex vectors of dimension out_features, and the complex-valued weight tensor :math:`W` has dimensions (in_features,out_features).
"""
def __init__(self, stack_features, in_features, out_features, n_domains=None, has_bias=True):
"""
:param in_features: Number of input units at the current layer.
:type in_features: int
:param out_features: Number of output units at the current layer.
:type out_features: int
:param n_domains: Number of domains in the domain-decomposition problem. If None, the layer is not a DD-PIHNN layer.
:type n_domains: int or None
:param has_bias: True if the current layer includes the bias term.
:type has_bias: bool
"""
super(ComplexLinear, self).__init__()
self.is_dd = (n_domains is not None)
self.has_bias = has_bias
self.in_features = in_features
if(self.is_dd):
self.W = torch.nn.Parameter(torch.empty((stack_features, n_domains, out_features, in_features), dtype=torch.complex128, device=device))
else:
self.W = torch.nn.Parameter(torch.empty((stack_features, out_features, in_features), dtype=torch.complex128, device=device))
if(self.has_bias):
if(self.is_dd):
self.B = torch.nn.Parameter(torch.empty((stack_features, n_domains, out_features, 1), dtype=torch.complex128, device=device))
else:
self.B = torch.nn.Parameter(torch.empty((stack_features, out_features, 1), dtype=torch.complex128, device=device))
def init(self, scaling):
"""
Re-initialization of weights and bias.
Initialization is defined as a scaled complex-valued He initialization (`Trabelsi et al. [2018] <https://arxiv.org/abs/1705.09792>`_):
.. math::
\\text{Re}(w),\\text{Im}(w) &\sim \mathcal{N}\left(0,\\frac{\\texttt{scaling}}{2 \\texttt{in_features}}\\right), \\\\
bias &=0.
This allows us to easily include the initialization strategy from `Calafà et al. [2024] <https://doi.org/10.1016/j.cma.2024.117406>`_, Section 3.2.4.
:param scaling: Scaling in the He initialization.
:type scaling: float
"""
torch.nn.init.normal_(self.W, 0., math.sqrt(scaling)/math.sqrt(2*self.in_features))
if (self.has_bias):
torch.nn.init.constant_(self.B, 0.)
def weight(self):
"""
:returns: **weight** (:class:`torch.tensor`) - Tensor with weights.
"""
return self.W
def bias(self):
"""
:returns: **bias** (:class:`torch.tensor`) - Bias vector.
"""
return self.B
def forward(self, input):
"""
Forward step :math:`y=Wx+b`.
:param input: The input vector :math:`x`.
:type input: :class:`torch.tensor`
:returns: **output** (:class:`torch.tensor`) - The output vector :math:`y`.
"""
if len(input.shape)==1 and not self.is_dd and self.W.shape[2]==1: # First layer in PIHNN networks
if(self.has_bias):
return torch.einsum('abc,d->abd', self.W, input) + self.B
else:
return torch.einsum('abc,d->abd', self.W, input)
elif len(input.shape)==2 and self.is_dd and self.W.shape[3]==1: # First layer in DD_PIHNN network
if(self.has_bias):
return torch.einsum('abcd,be->abce', self.W, input) + self.B
else:
return torch.einsum('abcd,be->abce', self.W, input)
elif len(input.shape)==3 and not self.is_dd: # All layers in PIHNN networks
if(self.has_bias):
return torch.einsum('abc,acd->abd', self.W, input) + self.B
else:
return torch.einsum('abc,acd->abd', self.W, input)
elif len(input.shape)==4 and self.is_dd: # All layers in DD_PIHNN networks
if(self.has_bias):
return torch.einsum('abcd,abde->abce', self.W, input) + self.B
else:
return torch.einsum('abcd,abde->abce', self.W, input)
else:
raise ValueError("Sizes of input and weight tensors do not coincide.")
class ComplexParameter(torch.nn.Module):
"""
Extension of :class:`torch.nn.Parameter` to complex values.
"""
def __init__(self, minvalue=-1-1j, maxvalue=1+1j, in_features=1, out_features=1):
"""
:param minvalue: The minimum value in the parameter initialization. Specifically, parameters are initialized to :math:`p \sim \mathcal{U}(-\\texttt{minvalue},\\texttt{maxvalue})`.
:type minvalue: complex
:param maxvalue: The minimum value in the parameter initialization. See above.
:type maxvalue: complex
:param in_features: First size of parameter tensor.
:type in_features: int
:param out_features: Second size of parameter tensor.
:type out_features: int
"""
super(ComplexParameter, self).__init__()
self.p_r = torch.nn.Parameter((maxvalue.real-minvalue.real) * torch.rand(in_features, out_features) + minvalue.real)
self.p_i = torch.nn.Parameter((maxvalue.imag-minvalue.imag) * torch.rand(in_features, out_features) + minvalue.imag)
def forward(self):
"""
It returns the parameter tensor.
:returns: **parameter** (:class:`torch.tensor`) - The tensor of size (in_features,out_features) with the parameters.
"""
return self.p_r + 1.j * self.p_i
class PIHNN(torch.nn.Module):
"""
Main class for the employment of physics-informed holomorphic neural networks (PIHNNs) from `Calafà et al. [2024] <https://doi.org/10.1016/j.cma.2024.117406>`_.
PIHNNs are able to solve 4 types of problems, where :math:`\\varphi,\psi` denote the holomorphic output(s) of the network:
* 2D Laplace problem ('laplace'):
.. math::
\\nabla^2u=0 \\Leftrightarrow u=\\text{Re}(\\varphi).
* 2D biharmonic problem with Goursat representation ('biharmonic'):
.. math::
\\nabla^4u=0 \\Leftrightarrow u=\\text{Re}((x-iy)\\varphi + \psi).
* 2D linear elasticity with Kolosov-Muskhelishvili representation ('km'):
:math:`\sigma_{xx},\sigma_{yy},\sigma_{xy},u_x,u_y` solve the 2D linear elasticity problem :math:`\\Leftrightarrow`
.. math::
\\begin{cases}
\sigma_{xx} + \sigma_{yy} = 4 \\text{Re}(\\varphi'), \\\\
\sigma_{yy} - \sigma_{xx} + 2i\sigma_{xy} = (\overline{z}\\varphi''+\psi'), \\\\
2\mu(u_x + iu_y) = \gamma \\varphi - z \overline{\\varphi'} - \overline{\psi},
\end{cases} \\\\
where :math:`\\mu` is the shear modulus and :math:`\gamma` is the Kolosov constant.
* 2D linear elasticity with Kolosov-Muskhelishvili representation, stress-only ('km-so'):
:math:`\sigma_{xx},\sigma_{yy},\sigma_{xy}` solve the 2D linear elasticity problem :math:`\\Leftrightarrow`
.. math::
\\begin{cases}
\sigma_{xx} + \sigma_{yy} = 4 \\text{Re}(\\varphi), \\\\
\sigma_{yy} - \sigma_{xx} + 2i\sigma_{xy} = (\overline{z}\\varphi'+\psi).
\end{cases} \\\\
The output of the network is therefore the scalar function :math:`\\varphi_{NN}\\approx\\varphi` in the Laplace problem.
Instead, for the other problems the PIHNN is composed by 2 stacked networks :math:`\\varphi_{NN},\psi_{NN}\\approx\\varphi,\psi`.
"""
_first_init = True
def __init__(self, PDE, units, material={"lambda": 1, "mu": 1}, activation=torch.exp, has_bias=True, rhs_solution=None):
"""
:param PDE: Problem to solve, either 'laplace', 'biharmonic', 'km' or 'km-so'.
:type PDE: str
:param units: List containing number of units at each layer, e.g., [1,10,10,1].
:type units: list of int
:param material: Coefficients in the PDE problem. For linear elasticity, it contains 'lambda' (first Lamé coefficient), 'mu' (second Lamé coefficient).
:type material: dict
:param activation: Activation function, by default the complex exponential.
:type activation: callable
:param has_bias: True if the linear layers include bias vectors.
:type has_bias: bool
:param rhs_solution: Particular solution to the non-homogeneous problem. E.g., :math:`x^2+y^2` for :math:`\\nabla^2u=4`.
:type rhs_solution: callable
"""
super(PIHNN, self).__init__()
if PIHNN._first_init:
print("Device: ", device)
PIHNN._first_init = False
self.PDE = PDE
if PDE in ['laplace']:
self.n_outputs = 1
elif PDE in ['biharmonic', 'km', 'km-so']:
self.n_outputs = 2
else:
raise ValueError("'PDE' must be either 'laplace', 'biharmonic', 'km' or 'km-so'.")
self.n_layers = len(units) - 1
if PDE in ['km', 'km-so']:
self.material = material
self.material["poisson"] = self.material["lambda"]/(2*(self.material["lambda"]+self.material["mu"]))
self.material["young"] = self.material["mu"]*(3*self.material["lambda"]+2*self.material["mu"])/(self.material["lambda"]+self.material["mu"])
self.material["bulk"] = (3*self.material["lambda"]+2*self.material["mu"])/3
self.material["km_gamma"] = (self.material["lambda"]+3*self.material["mu"])/(self.material["lambda"] + self.material["mu"])
self.material["km_eta"] = -(1-2*self.material["poisson"])/(2*(1-self.material["poisson"]))
self.material["km_theta"] = -1/(1-2*self.material["poisson"])
self.activation = activation
self.rhs_solution = lambda z: torch.real(utils.get_complex_function(rhs_solution)(z) if rhs_solution is not None else 0*z.real)
self.layers = torch.nn.ModuleList()
if (PDE == 'km-so'):
warnings.warn("You are using the 'stress-only' configuration. Please ensure that boundary conditions are only of stress type.")
for i in range(self.n_layers):
self.layers.append(ComplexLinear(self.n_outputs, units[i], units[i+1], has_bias=has_bias))
self.mean = torch.nn.Parameter(torch.tensor(0.j)).requires_grad_(False) # Used for PIHKAN initialization
self.std = torch.nn.Parameter(torch.tensor(1.)).requires_grad_(False)
def forward(self, z, real_output=False):
"""
Forward step, i.e., compute:
.. math::
\mathcal{L}_{L,t} \circ \phi \circ \mathcal{L}_{L-1,t} \circ \phi \dots \circ \mathcal{L}_{1,t} (z)
where :math:`z` is the input, :math:`\phi` the activation function and :math:`\{\mathcal{L}_{m,t}\}` the complex linear layers (:class:`pihnn.nn.ComplexLinear`) for each layer :math:`l=1,\dots,L` and stacked network :math:`t=1,\dots,T`.
:param z: Input of the network, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor`
:param real_output: Whether to provide the output in the real-valued representation.
:type real_output: bool
:returns: **phi** (:class:`torch.tensor`) - Output of the network.
"""
phi = self.layers[0](z)
for i in range(1, self.n_layers):
phi = self.layers[i](self.activation(phi))
if real_output:
return self.apply_real_transformation(z, phi.squeeze(1)) + self.rhs_solution(z)
else:
return phi.squeeze(1)
def initialize_weights(self, method, beta=0.5, sample=None, gauss=None):
"""
Initialization of PIHNNs. Implemented methods:
* Complex-valued He initialization (`Trabelsi et al. [2018] <https://arxiv.org/abs/1705.09792>`_):
:math:`\\text{Re}(w),\\text{Im}(w)\sim \mathcal{N}\left(0,\\frac{1}{2 \\texttt{in_features}}\\right), \hspace{3mm} bias=0`.
* Scaled complex-valued He initialization:
:math:`\\text{Re}(w),\\text{Im}(w)\sim \mathcal{N}\left(0,\\frac{\\texttt{scaling}}{2 \\texttt{in_features}}\\right), \hspace{3mm} bias=0`.
* PIHNNs ad-hoc initialization with exponential activations:
See `Calafà et al. [2024] <https://doi.org/10.1016/j.cma.2024.117406>`_, Section 3.2.4.
* PIHKAN ad-hoc initialization with monomial activations:
See `Calafà et al. [2026] <https://doi.org/10.1007/s44379-026-00094-8>`_, Section 2.7.
:param method: Either 'he', 'he_scaled', 'exp', 'kan' see description above.
:type method: str
:param beta: Scaling coefficient in the scaled He initialization, :math:`\\beta` coefficient in the Calafà initialization, not used in He initialization.
:type beta: float
:param sample: Initial sample :math:`x_0` in the Calafà initialization, not used in the other methods.
:type sample: :class:`torch.tensor`
:param gauss: :math:`M_e` coefficient in the Calafà initialization, not used in the other methods.
:type gauss: int
"""
if (method=='kan'):
self.mean = torch.nn.Parameter(torch.mean(sample)).requires_grad_(False)
self.std = torch.nn.Parameter(torch.std(sample)).requires_grad_(False)
return
if (method=='exp'):
if(gauss==None):
gauss = self.n_layers
for i in range(self.n_layers):
if (i<gauss):
scaling = beta/torch.mean(torch.pow(torch.abs(sample),2)).detach()
else:
scaling = beta/math.exp(beta)
self.layers[i].init(scaling)
y = self.layers[i](sample)
sample = self.activation(y)
return
if(method=='he'):
scaling = 1
elif(method=='he_scaled'):
scaling = beta
else:
raise ValueError("'method' must be either 'exp','he','he_scaled' or 'kan'.")
for i in range(self.n_layers):
self.layers[i].init(scaling)
def apply_real_transformation(self, z, phi):
"""
Based on the type of PDE, this method returns the real-valued output from the holomorphic potentials.
We address to the documentation of :class:`pihnn.nn.PIHNN` for the review of the 4 types of problems and their associated representation.
For PDE = 'laplace' and 'biharmonic', :math:`u` is evaluated at :math:`z`.
For PDE = 'km' and 'km-so', :math:`\sigma_{xx},\sigma_{yy},\sigma_{xy},u_x,u_y` are stacked in a single tensor.
Finally, in 'km-so', :math:`u_x,u_y` are identically zero.
:params z: Input of the model, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor`
:param phi: Complex-valued output of the network.
:type phi: :class:`torch.tensor`
:returns: **vars** (:class:`torch.tensor`) - Tensor containing the real-valued variable(s) evaluated at :math:`z`.
"""
match self.PDE:
case 'laplace':
return torch.real(phi)[0]
case 'biharmonic':
return torch.real(torch.conj(z)*phi[0] + phi[1]) # Goursat representation of biharmonic functions
case 'km' | 'km-so':
if (self.PDE=='km'): # Normal configuration
if phi.shape[0] == 2:
phi, psi = phi # The original "phi" actually includes both potentials
phi_z = derivative(phi,z,holom=True)
elif phi.shape[0] == 3: # When Rice transformation is used
phi, psi, phi_z = phi
psi_z = derivative(psi,z,holom=True)
phi_zz = derivative(phi_z,z,holom=True)
tmp = self.material["km_gamma"] * phi - z * torch.conj(phi_z) - torch.conj(psi)
u_x = torch.real(tmp) / (2*self.material["mu"])
u_y = torch.imag(tmp) / (2*self.material["mu"])
elif (self.PDE=='km-so'): # Stress-only configuration
if phi.shape[0] == 2:
phi_z, psi_z = phi # The original "phi" actually includes both potentials
phi_zz = derivative(phi_z,z,holom=True)
elif phi.shape[0] == 3: # When Rice transformation is used
phi_z, psi_z, phi_zz = phi
u_x = 0.*torch.abs(phi_z)
u_y = 0.*torch.abs(psi_z)
tmp1 = 2*torch.real(phi_z)
tmp2 = torch.conj(z)*phi_zz + psi_z
sig_xx = tmp1 - torch.real(tmp2)
sig_yy = tmp1 + torch.real(tmp2)
sig_xy = torch.imag(tmp2)
return torch.stack([sig_xx,sig_yy,sig_xy,u_x,u_y],0)
case _:
raise ValueError("model.PDE must be either 'laplace', 'biharmonic', 'km', 'km-so'.")
class DD_PIHNN(PIHNN):
"""
Domain-decomposition physics-informed holomorphic neural networks (DD-PIHNNs).
DD-PIHNNs have been introduced in `Calafà et al. [2024] <https://doi.org/10.1016/j.cma.2024.117406>`_, Section 4.3, to solve problems on multiply-connected domains.
The structure is similar to :class:`pihnn.nn.PIHNN` but includes multiple stacked networks, each one corresponding to each function :math:`\\varphi,\psi` and each domain.
"""
def __init__(self, PDE, units, boundary, material={"lambda": 1, "mu": 1}, activation=torch.exp, has_bias=True, rhs_solution=None):
"""
:param PDE: Problem to solve, either 'laplace', 'biharmonic', 'km' or 'km-so'.
:type PDE: str
:param units: List containing number of units at each layer, e.g., [1,10,10,1].
:type units: list of int
:param material: Properties of the material, dictionary with 'lambda' (first Lamé coefficient), 'mu' (second Lamé coefficient).
:param boundary: Geometry of the domain. Needed for information regarding DD partition.
:type boundary: :class:`pihnn.geometries.boundary`
:type material: dict
:param activation: Activation function, by default the complex exponential.
:type activation: callable
:param has_bias: True if the linear layers include bias vectors.
:type has_bias: bool
:param rhs_solution: Particular solution to the non-homogeneous problem. E.g., :math:`x^2+y^2` for :math:`\\nabla^2u=4`.
:type rhs_solution: callable
"""
super(DD_PIHNN, self).__init__(PDE, units, material, activation, has_bias, rhs_solution)
if boundary.dd_partition is None:
raise ValueError("Boundary must be initialized with well-defined 'dd_partition' in order to create DD-PIHNNs.")
self.dd_partition = boundary.dd_partition
self.n_domains = boundary.n_domains
self.layers = torch.nn.ModuleList()
for i in range(self.n_layers):
self.layers.append(ComplexLinear(self.n_outputs, units[i], units[i+1], self.n_domains, has_bias=has_bias))
if (PDE == 'km-so') and self.n_domains > 1:
raise ValueError("'stress-only' configuration cannot be applied to DD-PIHNNs.")
def unflatten(self, z_flat, domains):
"""
Internal operation to transform a 1D batch of coordinates of dimension :math:`N` to the 2D matrix of dimension :math:`[D,N_D]`,
where :math:`D` is the number of subdomains and :math:`N_D` is approximately the number of points per subdomain.
This operation is needed to increase the efficiency of the network. See :func:`pihnn.geometries.boundary.extract_points_dd` for further details.
:param z_flat: 1D input vector.
:type z_flat: :class:`torch.tensor`
:param domains: A 2D tensor such that :math:`T_{i,j}=1` if and only if the :math:`j`-th point belongs to the :math:`i`-th domain, :math:`0` otherwise.
:type domains: :class:`torch.tensor`
:returns: **z** (:class:`torch.tensor`) - 2D output vector.
"""
max_size = torch.max(domains.sum(1))
z = torch.empty(z_flat.shape[:-1]+(self.n_domains, max_size), dtype=z_flat.dtype, device=device)
for d in range(self.n_domains): # We do similarly to boundary.extract_points_dd
z[...,d,:domains[d,:].sum()] = z_flat[...,domains[d,:]]
return z
def flatten(self, z, domains):
"""
Inverse operation of :func:`nn.pihnn.DD_PIHNN.unflatten`.
:param z: 2D input vector.
:type z: :class:`torch.tensor`
:param domains: A 2D tensor such that :math:`T_{i,j}=1` if and only if the :math:`j`-th point belongs to the :math:`i`-th domain, :math:`0` otherwise.
:type domains: :class:`torch.tensor`
:returns: **z_flat** (:class:`torch.tensor`) - 1D output vector.
"""
z_flat = torch.empty(z.shape[:-2]+domains.shape[1:], dtype=z.dtype, device=device)
for d in range(self.n_domains): # We do the inverse of the previous operation
z_flat[...,domains[d,:]] = z[...,d,:domains[d,:].sum()]
return z_flat
def forward(self, z, flat_output=True, real_output=False):
"""
Forward step, i.e., compute:
.. math::
\mathcal{L}_{L,t,d} \circ \phi \circ \mathcal{L}_{L-1,t,d} \circ \phi \dots \circ \mathcal{L}_{1,t,d} (z)
where :math:`z` is the input, :math:`\phi` the activation function, :math:`d=1,\dots,D` the domain to which :math:`z` belongs and :math:`\{\mathcal{L}_{l,t,d}\}` the complex linear layers (:class:`pihnn.nn.ComplexLinear`) for each layer :math:`l=1,\dots,L` and stacked network :math:`(t,d)`.
:param z: Input of the network, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor`
:param flat_output: If True, the output of the network is a 1D/flat vector. Otherwise, the output is a 2D tensor where the first dimension is the number of domains and the second dimension
is the number of points per domain. The second option is necessary for the training of the network while one can simply consider a flat output in other circumstances.
Notice that the output is flat only if the input is also flat.
:type flat_output: bool
:param real_output: Whether to provide the output in the real-valued representation.
:type real_output: bool
:returns: **phi** (:class:`torch.tensor`) - Output of the network.
"""
if (len(z.shape)==1):
domains = self.dd_partition(z)
z_dd = self.unflatten(z, domains)
else:
z_dd = z
phi = self.layers[0](z_dd)
for i in range(1, self.n_layers):
phi = self.layers[i](self.activation(phi))
phi = phi.squeeze(2)
if (flat_output and len(z.shape)==1):
phi = self.flatten(phi, domains)
if real_output:
return self.apply_real_transformation(z, phi) + self.rhs_solution(z)
else:
return phi
def initialize_weights(self, method, beta=0.5, sample=None, gauss=None):
"""
Equivalent to :func:`pihnn.nn.PIHNN.init`.
:param method: Either 'he', 'he_scaled', 'exp', 'kan', see description in :func:`pihnn.nn.PIHNN.initialize_weights`.
:type method: str
:param beta: Scaling coefficient in the scaled He initialization, :math:`\\beta` coefficient in the Calafà initialization, not used in He initialization.
:type beta: float
:param sample: Initial sample :math:`x_0` in the Calafà initialization, not used in the other methods.
:type sample: :class:`torch.tensor`
:param gauss: :math:`M_e` coefficient in the Calafà initialization, not used in the other methods.
:type gauss: int
"""
if (method in ['he', 'he_scaled', 'kan']):
super(DD_PIHNN, self).initialize_weights(method, beta, sample, gauss)
return
if (method=='exp'):
if(gauss==None):
gauss = self.n_layers
x = sample.clone()
domains = self.dd_partition(x)
max_size = torch.max(domains.sum(1))
x = torch.nan*torch.empty(self.n_domains, max_size, dtype=sample.dtype, device=device)
for d in range(self.n_domains):
x[d,:domains[d,:].sum()] = sample[domains[d,:]]
for i in range(self.n_layers):
if (i<gauss):
scaling = beta/torch.nanmean(torch.pow(torch.abs(x),2)).detach()
else:
scaling = beta/math.exp(beta)
self.layers[i].init(scaling)
y = self.layers[i](x)
x = self.activation(y)
return
raise ValueError("'method' must be either 'exp','he','he_scaled' or 'kan'.")
class enriched_PIHNN(DD_PIHNN):
"""
PIHNN with enrichment for cracks, as introduced in `Calafà et al. [2025] <https://doi.org/10.1016/j.engfracmech.2025.111133>`_.
It is well-known that traditional solvers and NNs face some difficulties to capture the stress field singularities at the cracks.
This class hence employs two strategies:
* Enrichment with **Williams approximation** (Williams, M., “On the stress distribution at the base of a stationary crack”, 1957).
* Enrichment with **Rice formula** (Rice, J. R., “Mathematical Analysis in the Mechanics of Fracture”, 1968).
"""
def __init__(self, PDE, units, boundary, material={"lambda": 1, "mu": 1}, activation=torch.exp, has_bias=True):
"""
:param PDE: Problem to solve, either 'km' or 'km-so'.
:type PDE: str
:param units: Number of units at each layer for :math:`NN_0`, e.g., [1,10,10,1].
:type units: list of int
:param boundary: Geometry of the domain. Needed for information regarding cracks locations and DD partition.
:type boundary: :class:`pihnn.geometries.boundary`
:param material: Properties of the material, dictionary with 'lambda' (first Lamé coefficient), 'mu' (second Lamé coefficient).
:type material: dict
:param activation: Activation function, by default the complex exponential.
:type activation: callable
:param has_bias: True if the linear layers include bias vectors.
:type has_bias: bool
"""
super(enriched_PIHNN, self).__init__(PDE, units, boundary, material, activation, has_bias)
if PDE not in ['km','km-so']:
raise ValueError("Enriched PIHNNs can be used only for linear elasticity problems.")
if boundary.dd_partition is None:
raise ValueError("Enriched PIHNNs can be used only with DD partitioning.")
self.enrichment = boundary.enrichment
self.cracks = boundary.cracks
sif = []
for crack in self.cracks:
for tip in crack.tips:
sif.append(tip.initial_sif)
if self.enrichment == "williams":
self.sif = torch.nn.Parameter(torch.tensor(sif, device=device))
elif self.enrichment == "rice":
self.sif = 0*torch.tensor(sif, device=device)
self.has_crack = torch.zeros([self.n_domains], dtype=torch.bool, device=device)
self.crack_is_internal = torch.zeros_like(self.has_crack, device=device)
self.crack_coords = torch.zeros([self.n_domains,1], dtype=torch.complex128, device=device)
self.crack_angle = torch.zeros([self.n_domains,1], dtype=torch.double, device=device)
self.crack_a = torch.zeros_like(self.crack_angle, device=device)
for crack in self.cracks:
d = crack.rice["domain"]
self.has_crack[d] = 1
self.crack_is_internal[d] = crack.rice["is_internal"]
self.crack_coords[d] = crack.rice["coords"]
self.crack_angle[d] = crack.rice["angle"]
self.crack_a[d] = crack.length/2
def forward(self, z, flat_output=True, real_output=False, force_williams=False):
"""
Evaluation of the neural network.
This function calls either :func:`pihnn.nn.enriched_PIHNN.apply_williams` or :func:`pihnn.nn.enriched_PIHNN.apply_rice` based on the setting in :class:`pihnn.geometries.boundary`.
:param z: Input of the network, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor`
:param flat_output: If True, the output of the network is a 1D/flat vector. Otherwise, the output is a 2D tensor where the first dimension is the number of domains and the second dimension
is the number of points per domain. The second option is necessary for the training of the network while one can simply consider a flat output in other circumstances.
Notice that the output is flat only if the input is also flat.
:type flat_output: bool
:param real_output: Whether to provide the output in the real-valued representation.
:type real_output: bool
:param force_williams: Intended for internal use, it forces the Williams enrichment on the top of the Rice enrichment.
:type output_mode: bool
:returns: **phi** (:class:`torch.tensor`) - Output of the network.
"""
if (len(z.shape)==1):
domains = self.dd_partition(z)
z_dd = self.unflatten(z, domains)
else:
z_dd = z
if self.enrichment == "williams":
phi = super(enriched_PIHNN, self).forward(z_dd)
phi = phi + self.apply_williams(z_dd)
elif self.enrichment == "rice":
phi = self.apply_rice(z_dd)
if force_williams:
phi = phi[:2] + self.apply_williams(z_dd)
if (flat_output and 'domains' in locals()):
phi = self.flatten(phi, domains)
if real_output:
return self.apply_real_transformation(z, phi)
else:
return phi
def apply_williams(self, z):
"""
Application of the Williams approximation.
Namely, the stress field close to a horizontal crack tip at the origin is described by:
.. math::
\\begin{cases}
\\varphi_W(z)= \overline{K}\sqrt{z}, \\\\
\psi_W(z)= \left(K - \dfrac{\overline{K}}{2}\\right)\sqrt{z},
\\end{cases}
where :math:`K=K_I+iK_{II}` is the complex number that combines together the stress intensity factors (SIFs) from mixed mode I-II.
Here, :math:`K` is a trainable parameter and :math:`\\varphi_W,\\psi_W` are suitably transformed in order to take into account crack tips in any position and direction.
:param z: Input of the network, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor`
:returns: **phi** (:class:`torch.tensor`) - Output of the network.
"""
phi = torch.zeros((2,)+z.shape,device=device)*1j
i = 0
for crack in self.cracks:
for t in crack.tips:
d = t.domains
bcr = t.branch_cut_rotation
c1 = torch.conj(self.sif[i])
c2 = (self.sif[i] - torch.conj(self.sif[i])/2)
rot_sqrt = torch.sqrt(torch.exp(-1j*(bcr+t.angle))*(z[d]-t.coords))
phi_williams = c1 * torch.exp(1j*(t.angle+bcr/2)) * rot_sqrt
psi_williams = c2 * torch.exp(1j*(-t.angle+bcr/2)) * rot_sqrt - 0.5 * c1 * torch.conj(t.coords) * torch.exp(-1j*bcr/2) / rot_sqrt
phi[0,d,:] = phi[0,d,:] + phi_williams/math.sqrt(2*math.pi)
phi[1,d,:] = phi[1,d,:] + psi_williams/math.sqrt(2*math.pi)
i+=1
return phi
def apply_rice(self, z):
"""
Application of the Rice formula.
Namely, a stress-free crack is described by the potentials
.. math::
\\begin{cases}
\\varphi(z)=\sqrt{z-a}\sqrt{z+a}f(z) + g(z), \\\\
\\omega(z)= \sqrt{z-a}\sqrt{z+a}\hat{f}(z) - \hat{g}(z),
\\end{cases}
where :math:`\hat{f}(z):=\overline{f(\overline{z})}`, :math:`\omega(z):=z\\varphi'(z) + \psi(z)`, :math:`f,g` are two holomorphic functions and
.. math::
\sigma(z) =
\\begin{cases}
\sqrt{z-a}\sqrt{z+a}, & \\text{ for an internal crack at } \{x\in[-a,a], y=0\}, \\\\
\sqrt{z}, & \\text{ for an open crack at } \{x\in[-\infty,0], y=0\}.
\\end{cases}
The method applies the above representation and applies roto-translations.
:param z: Input of the network, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor`
:returns: **phi** (:class:`torch.tensor`) - Output of the network.
"""
t = torch.exp(-1j*self.crack_angle)*(z-self.crack_coords)
phi = super(enriched_PIHNN, self).forward(t) # phi = [f,g]
phi_h = torch.conj(super(enriched_PIHNN, self).forward(torch.conj(t))) # phi_h = [\check{f},\check{g}]
sigma = torch.sqrt(t)*(~self.crack_is_internal) + torch.sqrt(t-self.crack_a)*torch.sqrt(t+self.crack_a)*self.crack_is_internal
if self.PDE == 'km':
varphi_0 = (phi[0]*sigma+phi[1]) * self.has_crack + phi[0] * (~self.has_crack)
omega_0 = (phi_h[0]*sigma-phi_h[1]) * self.has_crack + phi[1] * (~self.has_crack)
varphi_t_0 = derivative(varphi_0, t, holom=True)
psi_0 = omega_0 - t * varphi_t_0
# Roto-translations
varphi = torch.exp(1j*self.crack_angle)*varphi_0
psi = torch.exp(-1j*self.crack_angle)*psi_0 - torch.conj(self.crack_coords)*varphi_t_0
varphi_z = varphi_t_0
return torch.stack([varphi,psi,varphi_z],0) # We keep varphi_z in order to save some computation time
elif self.PDE == 'km-so':
varphi_t_0 = (phi[0]/sigma+phi[1]) * self.has_crack + phi[0] * (~self.has_crack)
omega_t_0 = (phi_h[0]/sigma-phi_h[1]) * self.has_crack + phi[1] * (~self.has_crack)
varphi_tt_0 = derivative(varphi_t_0, t, holom=True)
psi_t_0 = omega_t_0 - t * varphi_tt_0 - varphi_t_0
# Roto-translations
varphi_z = varphi_t_0
psi_z = torch.exp(-2j*self.crack_angle)*psi_t_0 - torch.conj(self.crack_coords)*torch.exp(-1j*self.crack_angle)*varphi_tt_0
varphi_zz = torch.exp(-1j*self.crack_angle)*varphi_tt_0
return torch.stack([varphi_z,psi_z,varphi_zz],0) # We keep varphi_zz in order to save some computation time
class L_PIHNN(PIHNN):
"""
Laurent physics-informed holomorphic neural networks (L-PIHNNs) from `Calafà et al. [2026] <https://doi.org/10.1007/s44379-026-00094-8>`_.
L-PIHNNs are able to extend PIHNNs to multiply-connected domains without using domain decomposition by approximating the holomorphic solution at the annulus through a Laurent series.
The idea is to consider multiple network branches for each domain hole and evaluate
.. math::
NN(z)=NN_0(z) + \sum_{s=1}^{S}NN_s\left(\\frac{1}{z-z_s}\\right) + p_s \ln(z-z_s),
where :math:`\{NN_s\}_{s=0}^S` are standard PIHNNs, :math:`S\in\mathbb{N}` is the number of holes in the domain and :math:`\{z_s\}_{s=1}^S \subset \mathbb{C}` are coordinates of points inside each hole.
In addition, :math:`p_s\in\mathbb{C}` are trainable parameters, needed to take into account the multi-valued nature of harmonic functions on multiply-connected domains.
"""
def __init__(self, PDE, units, singularities, material={"lambda": 1, "mu": 1}, activation=torch.exp, has_bias=True, rhs_solution=None):
"""
:param PDE: Problem to solve, either 'laplace', 'biharmonic', 'km' or 'km-so'.
:type PDE: str
:param units: Number of units at each layer for :math:`NN_0`, e.g., [1,10,10,1].
:type units: list of int
:param singularities: List with the information about singularities. Each element of the list (:math:`s=1,\dots,S`) is a dictionary containing :math:`z_s` ("location") and units of :math:`NN_s` ("units").
:type singularities: List of dictionaries
:param material: Properties of the material, dictionary with 'lambda' (first Lamé coefficient), 'mu' (second Lamé coefficient).
:type material: dict
:param activation: Activation function, by default the complex exponential.
:type activation: callable
:param has_bias: True if the linear layers include bias vectors.
:type has_bias: bool
:param rhs_solution: Particular solution to the non-homogeneous problem. E.g., :math:`x^2+y^2` for :math:`\\nabla^2u=4`.
:type rhs_solution: callable
"""
super(L_PIHNN, self).__init__(PDE, units, material, activation, has_bias, rhs_solution)
self.layers = torch.nn.ModuleList()
self.analytical_net = PIHNN(PDE, units, material, activation, has_bias)
self.singularity_nets = torch.nn.ModuleList()
self.locations_s = []
for singularity in singularities:
location_s = singularity["location"]
if isinstance(location_s, (list,tuple)):
if len(location_s) == 2:
self.locations_s.append(location_s[0]+1.j*location_s[1])
else:
raise ValueError("Values in 'location' must be complex numbers or lists/tuples with 2 real numbers.")
elif isinstance(location_s, (int, float, complex)):
self.locations_s.append(complex(location_s))
self.singularity_nets.append(PIHNN(PDE, singularity["units"], material, activation, has_bias))
self.log_coeff = torch.nn.Parameter(torch.zeros([len(singularities)]) + 0.j)
def forward(self, z, real_output=False):
"""
Forward step, i.e., compute:
.. math::
\mathcal{L}_{L,t,0} \circ \phi \circ \dots \circ \phi \circ \mathcal{L}_{1,t,0} (z) + \sum_{s=1}^S \mathcal{L}_{L,t,s} \circ \phi \dots \phi \circ \mathcal{L}_{1,t,s} \left(\\frac{1}{z-z_s}\\right) + p_s \ln(z-z_s)
where :math:`z` is the input, :math:`\phi` the activation function and :math:`\{\mathcal{L}_{l,t,s}\}` the complex linear layers (:class:`pihnn.nn.ComplexLinear`) for each layer :math:`l=1,\dots,L`, stacked network :math:`t=1,\dots,T` and singularity :math:`s=1,\dots,S`.
:param z: Input of the network, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor(dtype=torch.complex128)`
:param real_output: Whether to provide the output in the real-valued representation.
:type real_output: bool
:returns: **phi** (:class:`torch.tensor(dtype=torch.complex128)`) - Output of the network.
"""
phi = self.analytical_net(z)
for s in range(len(self.locations_s)):
phi += self.singularity_nets[s](1./(z-self.locations_s[s])) + self.log_coeff[s]*torch.log(z-self.locations_s[s])
if real_output:
return self.apply_real_transformation(z,phi) + self.rhs_solution(z)
else:
return phi
def initialize_weights(self, method, beta=0.5, sample=None, gauss=None, include_analytical_part=True, include_principal_part=True):
"""
Equivalent to :func:`pihnn.nn.PIHNN.init` but applies also to :math:`NN_s,s\ge 1`.
:param method: Either 'he', 'he_scaled', 'exp', 'kan' see description above.
:type method: str
:param beta: Scaling coefficient in the scaled He initialization, :math:`\\beta` coefficient in the Calafà initialization, not used in He initialization.
:type beta: float
:param sample: Initial sample :math:`x_0` in the Calafà initialization, not used in the other methods.
:type sample: :class:`torch.tensor(dtype=torch.complex128)`
:param gauss: :math:`M_e` coefficient in the Calafà initialization, not used in the other methods.
:type gauss: int
:param include_analytical_part: If the initialization is applied to the analytical part (:math:`s=0`).
:type include_analytical_part: bool
:param include_principal_part: If the initialization is applied to the principal part (:math:`s=1,\dots,S`).
:type include_analytical_part: bool
"""
if include_analytical_part:
self.analytical_net.initialize_weights(method, beta, sample, gauss)
if include_principal_part:
for j in range(len(self.locations_s)):
self.singularity_nets[j].initialize_weights(method, beta, 1/(sample-self.locations_s[j]), gauss)
class PIHKANLayer(torch.nn.Module):
"""
Single layer for :class:`pihnn.nn.PIHKAN`.
The following operation is performed (`Calafà et al. [2026] <https://doi.org/10.1007/s44379-026-00094-8>`_):
.. math::
\mathbf{Y}_{t,b,o} = \sum_{p=0}^{P-1}\sum_{i=1}^{I} \mathbf{w}_{t,o,i,p} \left(\mathbf{Z}_{t,b,i}\\right)^p,
where :math:`i=1,\dots,I` is the input dimension, :math:`o=1,\dots,O` is the output dimension, :math:`b=1,\dots,B` is the batch size,
:math:`p=1,\dots,P` is the KAN polynomial order, :math:`\mathbf{w} \in \mathbb{R}^{T,O,I,P}` are the trainable weights.
Specifically, the weights are initialized as follows:
.. math::
\mathbf{w}_{t,o,i,p} \sim \mathcal{N}\left(0,\\frac{2}{(I+pO)(p!)P}\\right).
"""
def __init__(self, stack_dim, input_dim, output_dim, degree, n_domains=None, has_bias=True):
"""
:param stack_dim: Number of complex potentials :math:`T` (e.g., 1 for Laplace and 2 for linear elasticity).
:type stack_dim: int
:param input_dim: Dimension of input :math:`I`.
:type input_dim: int
:param output_dim: Dimension of output :math:`O`.
:type output_dim: int
:param degree: KAN polynomial degree :math:`P`.
:type degree: int
:param n_domains: Number of domains in the domain-decomposition problem. If None, the layer is not a DD-PIHNN layer.
:type n_domains: int or None
:param has_bias: Whether to include the bias vector.
:type has_bias: bool
"""
super(PIHKANLayer, self).__init__()
self.stackdim = stack_dim
self.inputdim = input_dim
self.outdim = output_dim
self.degree = degree
self.has_bias = has_bias
self.is_dd = (n_domains is not None)
if self.is_dd:
if has_bias:
self.bias = torch.nn.Parameter(torch.zeros(stack_dim, n_domains, 1, output_dim, device=device)+0.j)
self.weights = torch.empty(stack_dim, n_domains, output_dim, input_dim, degree, device=device)+0.j
else:
if has_bias:
self.bias = torch.nn.Parameter(torch.zeros(stack_dim, 1, output_dim, device=device)+0.j)
self.weights = torch.empty(stack_dim, output_dim, input_dim, degree, device=device)+0.j
torch.nn.init.normal_(self.weights.real, mean=0.0, std=1 / math.sqrt(degree))
torch.nn.init.normal_(self.weights.imag, mean=0.0, std=1 / math.sqrt(degree))
self.pp = torch.tensor([i for i in range(1,degree+1)], device=device)
pf = torch.tensor([math.factorial(i) for i in range(1,degree+1)], device=device)
N = (input_dim + self.pp*output_dim)*pf
# TO DO: only for PIHKANs with linear elasticity
#le = torch.max(self.pp-1,torch.ones(self.pp.shape, device=device))
#N = self.pp*(input_dim + le*output_dim)*pf
self.weights = torch.nn.Parameter(self.weights / torch.sqrt(N))
def forward(self, z):
"""
Perform the forward pass, see above.
:param z: Input of dimension :math:`T\\times B \\times I`, where :math:`B` is the batch size.
:type z: :class:`torch.tensor`
:returns: **Y** (:class:`torch.tensor`) - Output of the network of size :math:`T\\times B \\times O`.
"""
if device.type == "cuda":
T = z.unsqueeze(-1) ** self.pp
else:
T = torch.linalg.vander(z, N=self.degree)*z.unsqueeze(-1) # For some reasons, vander is faster than the manual method only for device=cpu
if self.is_dd:
if self.has_bias:
y = torch.einsum("tdbip,tdoip->tdbo", T, self.weights) + self.bias # shape = (stacked_dim, n_domains, batch_size, outdim)
else:
y = torch.einsum("tdbip,tdoip->tdbo", T, self.weights)
else:
if self.has_bias:
y = torch.einsum("tbip,toip->tbo", T, self.weights) + self.bias # shape = (stacked_dim batch_size, outdim)
else:
y = torch.einsum("tbip,toip->tbo", T, self.weights)
return y
class PIHKAN(PIHNN):
"""
Physics-informed holomorphic Kolmogorov-Arnold network (PIHKAN).
In `Calafà et al. [2026] <https://doi.org/10.1007/s44379-026-00094-8>`_, we introduce PIHKANs as the physics-informed and holomorphic variant of KANs (`Liu et al. [2024] <https://arxiv.org/abs/2404.19756>`_).
In contrast with the original KANs, the trainable activation functions are defined as the complex-valued monomials :math:`1,z,z^2,\dots`
PIHKAN is defined as a sequence of :class:`pihnn.nn.PIHKANLayer`.
"""
def __init__(self, PDE, units, degree, material={"lambda": 1, "mu": 1}, has_bias=True, rhs_solution=None):
"""
:param PDE: Problem to solve, either 'laplace', 'biharmonic', 'km' or 'km-so'.
:type PDE: str
:param units: List containing number of units at each layer, e.g., [1,10,10,1].
:type units: list of int
:param degree: KAN polynomial degree :math:`D`.
:type degree: int
:param material: Properties of the material, dictionary with 'lambda' (first Lamé coefficient), 'mu' (second Lamé coefficient).
:type material: dict
:param has_bias: Whether to include the bias vector.
:type has_bias: bool
:param rhs_solution: Particular solution to the non-homogeneous problem. E.g., :math:`x^2+y^2` for :math:`\\nabla^2u=4`.
:type rhs_solution: callable
"""
super(PIHKAN, self).__init__(PDE, units, material, rhs_solution=rhs_solution)
self.degree = degree
self.n_layers = len(units) - 1
self.layers = torch.nn.ModuleList()
for i in range(self.n_layers):
self.layers.append(PIHKANLayer(self.n_outputs, units[i], units[i+1], degree, has_bias=has_bias))
def forward(self, z, real_output=False):
"""
Perform the forward pass as a concatenation of PIHKAN layers:
.. math::
\mathcal{L}_{L,t} \circ \dots \circ \mathcal{L}_{1,t} (z),
where :math:`\mathcal{L}_{l,t}` is the :math:`l`-th :class:`PIHKANLayer` for the :math:`t`-th complex potential.
:param z: Input of the network, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor(dtype=torch.complex128)`
:param real_output: Whether to provide the output in the real-valued representation.
:type real_output: bool
:returns: **phi** (:class:`torch.tensor(dtype=torch.complex128)`) - Output of the network.
"""
phi = (z.reshape([1,-1,1]).expand(self.n_outputs,-1,-1) - self.mean) / self.std
for i in range(self.n_layers):
phi = self.layers[i](phi)
phi = phi.squeeze(-1)
if real_output:
return self.apply_real_transformation(z,phi) + self.rhs_solution(z)
else:
return phi
class DD_PIHKAN(DD_PIHNN):
"""
Physics-informed holomorphic Kolmogorov-Arnold networks (PIHKANs) with domain decomposition.
The class is equivalent to :class:`pihnn.nn.DD_PIHNN` except that the stacked networks are PIHKANs.
"""
def __init__(self, PDE, units, degree, boundary, material={"lambda": 1, "mu": 1}, has_bias=True, rhs_solution=None):
"""
:param PDE: Problem to solve, either 'laplace', 'biharmonic', 'km' or 'km-so'.
:type PDE: str
:param units: List containing number of units at each layer, e.g., [1,10,10,1].
:type units: list of int
:param degree: KAN polynomial degree :math:`D`.
:type degree: int
:param boundary: Geometry of the domain. Needed for information regarding DD partition.
:type boundary: :class:`pihnn.geometries.boundary`
:type material: dict
:param activation: Activation function, by default the complex exponential.
:type activation: callable
:param has_bias: Whether to include the bias vector.
:type has_bias: bool
:param rhs_solution: Particular solution to the non-homogeneous problem. E.g., :math:`x^2+y^2` for :math:`\\nabla^2u=4`.
:type rhs_solution: callable
"""
super(DD_PIHKAN, self).__init__(PDE, units, boundary, material, rhs_solution=rhs_solution)
self.n_layers = len(units) - 1
self.layers = torch.nn.ModuleList()
for i in range(self.n_layers):
self.layers.append(PIHKANLayer(self.n_outputs, units[i], units[i+1], degree, self.n_domains, has_bias=has_bias))
self.mean = torch.nn.Parameter(torch.tensor(0.j)).requires_grad_(False)
self.std = torch.nn.Parameter(torch.tensor(1.)).requires_grad_(False)
def forward(self, z, flat_output=True, real_output=False):
"""
Forward step, i.e., compute:
.. math::
\mathcal{L}_{L,t,d} \circ \mathcal{L}_{L-1,t,d} \circ \dots \circ \mathcal{L}_{1,t,d} (z)
where :math:`\mathcal{L}_{l,t,d}` is the :math:`l`-th :class:`PIHKANLayer` for the :math:`t`-th complex potential and :math:`d`-th domain.
:param z: Input of the network, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor`
:param flat_output: If True, the output of the network is a 1D/flat vector. Otherwise, the output is a 2D tensor where the first dimension is the number of domains and the second dimension
is the number of points per domain. The second option is necessary for the training of the network while one can simply consider a flat output in other circumstances.
Notice that the output is flat only if the input is also flat.
:type flat_output: bool
:param real_output: Whether to provide the output in the real-valued representation.
:type real_output: bool
:returns: **phi** (:class:`torch.tensor`) - Output of the network.
"""
if (len(z.shape)==1):
domains = self.dd_partition(z)
z_dd = self.unflatten(z, domains)
else:
z_dd = z
z_dd = (z_dd.reshape([1,self.n_domains,-1,1]).expand(self.n_outputs,-1,-1,-1) - self.mean) / self.std
phi = self.layers[0](z_dd)
for i in range(1, self.n_layers):
phi = self.layers[i](phi)
phi = phi.squeeze(2)
phi = phi.squeeze(-1)
if (flat_output and len(z.shape)==1):
phi = self.flatten(phi, domains)
if real_output:
return self.apply_real_transformation(z, phi) + self.rhs_solution(z)
else:
return phi
class enriched_PIHKAN(DD_PIHKAN):
"""
Physics-informed holomorphic Kolmogorov-Arnold networks (PIHKANs) with enrichment for cracks.
The class is equivalent to :class:`pihnn.nn.enriched_PIHNN` except that the stacked networks are PIHKANs.
"""
def __init__(self, PDE, units, degree, boundary, material={"lambda": 1, "mu": 1}, has_bias=True):
"""
:param PDE: Problem to solve, either 'km' or 'km-so'.
:type PDE: str
:param units: Number of units at each layer for :math:`NN_0`, e.g., [1,10,10,1].
:type units: list of int
:param degree: KAN polynomial degree :math:`D`.
:type degree: int
:param boundary: Geometry of the domain. Needed for information regarding cracks locations and DD partition.
:type boundary: :class:`pihnn.geometries.boundary`
:param material: Properties of the material, dictionary with 'lambda' (first Lamé coefficient), 'mu' (second Lamé coefficient).
:type material: dict
:param has_bias: True if the linear layers include bias vectors.
:type has_bias: bool
"""
super(enriched_PIHKAN, self).__init__(PDE, units, degree, boundary, material, has_bias)
if PDE not in ['km','km-so']:
raise ValueError("Enriched PIHKANs can be used only for linear elasticity problems.")
if boundary.dd_partition is None:
raise ValueError("Enriched PIHKANs can be used only with DD partitioning.")
self.enrichment = boundary.enrichment
self.cracks = boundary.cracks
sif = []
for crack in self.cracks:
for tip in crack.tips:
sif.append(tip.initial_sif)
if self.enrichment == "williams":
self.sif = torch.nn.Parameter(torch.tensor(sif, device=device))
elif self.enrichment == "rice":
self.sif = 0*torch.tensor(sif, device=device)
self.has_crack = torch.zeros([self.n_domains], dtype=torch.bool, device=device)
self.crack_is_internal = torch.zeros_like(self.has_crack, device=device)
self.crack_coords = torch.zeros([self.n_domains,1], dtype=torch.complex128, device=device)
self.crack_angle = torch.zeros([self.n_domains,1], dtype=torch.double, device=device)
self.crack_a = torch.zeros_like(self.crack_angle, device=device)
for crack in self.cracks:
d = crack.rice["domain"]
self.has_crack[d] = 1
self.crack_is_internal[d] = crack.rice["is_internal"]
self.crack_coords[d] = crack.rice["coords"]
self.crack_angle[d] = crack.rice["angle"]
self.crack_a[d] = crack.length/2
def forward(self, z, flat_output=True, real_output=False, force_williams=False):
"""
Evaluation of the neural network.
This function calls either :func:`pihnn.nn.enriched_PIHKAN.apply_williams` or :func:`pihnn.nn.enriched_PIHKAN.apply_rice` based on the setting in :class:`pihnn.geometries.boundary`.
:param z: Input of the network, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor`
:param flat_output: If True, the output of the network is a 1D/flat vector. Otherwise, the output is a 2D tensor where the first dimension is the number of domains and the second dimension
is the number of points per domain. The second option is necessary for the training of the network while one can simply consider a flat output in other circumstances.
Notice that the output is flat only if the input is also flat.
:type flat_output: bool
:param real_output: Whether to provide the output in the real-valued representation.
:type real_output: bool
:param force_williams: Intended for internal use, it forces the Williams enrichment on the top of the Rice enrichment.
:type output_mode: bool
:returns: **phi** (:class:`torch.tensor`) - Output of the network.
"""
if (len(z.shape)==1):
domains = self.dd_partition(z)
z_dd = self.unflatten(z, domains)
else:
z_dd = z
if self.enrichment == "williams":
phi = super(enriched_PIHKAN, self).forward(z_dd)
phi = phi + self.apply_williams(z_dd)
elif self.enrichment == "rice":
phi = self.apply_rice(z_dd)
if force_williams:
phi = phi[:2] + self.apply_williams(z_dd)
if (flat_output and 'domains' in locals()):
phi = self.flatten(phi, domains)
if real_output:
return self.apply_real_transformation(z, phi)
else:
return phi
def apply_williams(self, z):
"""
Application of the Williams approximation.
Namely, the stress field close to a horizontal crack tip at the origin is described by:
.. math::
\\begin{cases}
\\varphi_W(z)= \overline{K}\sqrt{z}, \\\\
\psi_W(z)= \left(K - \dfrac{\overline{K}}{2}\\right)\sqrt{z},
\\end{cases}
where :math:`K=K_I+iK_{II}` is the complex number that combines together the stress intensity factors (SIFs) from mixed mode I-II.
Here, :math:`K` is a trainable parameter and :math:`\\varphi_W,\\psi_W` are suitably transformed in order to take into account crack tips in any position and direction.
:param z: Input of the network, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor`
:returns: **phi** (:class:`torch.tensor`) - Output of the network.
"""
phi = torch.zeros((2,)+z.shape,device=device)*1j
i = 0
for crack in self.cracks:
for t in crack.tips:
d = t.domains
bcr = t.branch_cut_rotation
c1 = torch.conj(self.sif[i])
c2 = (self.sif[i] - torch.conj(self.sif[i])/2)
rot_sqrt = torch.sqrt(torch.exp(-1j*(bcr+t.angle))*(z[d]-t.coords))
phi_williams = c1 * torch.exp(1j*(t.angle+bcr/2)) * rot_sqrt
psi_williams = c2 * torch.exp(1j*(-t.angle+bcr/2)) * rot_sqrt - 0.5 * c1 * torch.conj(t.coords) * torch.exp(-1j*bcr/2) / rot_sqrt
phi[0,d,:] = phi[0,d,:] + phi_williams/math.sqrt(2*math.pi)
phi[1,d,:] = phi[1,d,:] + psi_williams/math.sqrt(2*math.pi)
i+=1
return phi
def apply_rice(self, z):
"""
Application of the Rice formula.
Namely, a stress-free crack is described by the potentials
.. math::
\\begin{cases}
\\varphi(z)=\sqrt{z-a}\sqrt{z+a}f(z) + g(z), \\\\
\\omega(z)= \sqrt{z-a}\sqrt{z+a}\hat{f}(z) - \hat{g}(z),
\\end{cases}
where :math:`\hat{f}(z):=\overline{f(\overline{z})}`, :math:`\omega(z):=z\\varphi'(z) + \psi(z)`, :math:`f,g` are two holomorphic functions and
.. math::
\sigma(z) =
\\begin{cases}
\sqrt{z-a}\sqrt{z+a}, & \\text{ for an internal crack at } \{x\in[-a,a], y=0\}, \\\\
\sqrt{z}, & \\text{ for an open crack at } \{x\in[-\infty,0], y=0\}.
\\end{cases}
The method applies the above representation and applies roto-translations.
:param z: Input of the network, typically a batch of coordinates from the domain boundary.
:type z: :class:`torch.tensor`
:returns: **phi** (:class:`torch.tensor`) - Output of the network.
"""
t = torch.exp(-1j*self.crack_angle)*(z-self.crack_coords)
phi = super(enriched_PIHKAN, self).forward(t) # phi = [f,g]
phi_h = torch.conj(super(enriched_PIHKAN, self).forward(torch.conj(t))) # phi_h = [\check{f},\check{g}]
sigma = torch.sqrt(t)*(~self.crack_is_internal) + torch.sqrt(t-self.crack_a)*torch.sqrt(t+self.crack_a)*self.crack_is_internal
if self.PDE == 'km':
varphi_0 = (phi[0]*sigma+phi[1]) * self.has_crack + phi[0] * (~self.has_crack)
omega_0 = (phi_h[0]*sigma-phi_h[1]) * self.has_crack + phi[1] * (~self.has_crack)
varphi_t_0 = derivative(varphi_0, t, holom=True)
psi_0 = omega_0 - t * varphi_t_0
# Roto-translations
varphi = torch.exp(1j*self.crack_angle)*varphi_0
psi = torch.exp(-1j*self.crack_angle)*psi_0 - torch.conj(self.crack_coords)*varphi_t_0
varphi_z = varphi_t_0
return torch.stack([varphi,psi,varphi_z],0) # We keep varphi_z in order to save some computation time
elif self.PDE == 'km-so':
varphi_t_0 = (phi[0]/sigma+phi[1]) * self.has_crack + phi[0] * (~self.has_crack)
omega_t_0 = (phi_h[0]/sigma-phi_h[1]) * self.has_crack + phi[1] * (~self.has_crack)
varphi_tt_0 = derivative(varphi_t_0, t, holom=True)
psi_t_0 = omega_t_0 - t * varphi_tt_0 - varphi_t_0
# Roto-translations
varphi_z = varphi_t_0
psi_z = torch.exp(-2j*self.crack_angle)*psi_t_0 - torch.conj(self.crack_coords)*torch.exp(-1j*self.crack_angle)*varphi_tt_0
varphi_zz = torch.exp(-1j*self.crack_angle)*varphi_tt_0
return torch.stack([varphi_z,psi_z,varphi_zz],0) # We keep varphi_zz in order to save some computation time
class L_PIHKAN(L_PIHNN):
"""
Laurent physics-informed holomorphic Kolmogorov-Arnold network (L-PIHKAN).
Combination of :class:`pihnn.nn.PIHKAN` and :class:`pihnn.nn.L_PIHNN`, in order to extend the applicability of PIHKANs to multiply-connected domains.
"""
def __init__(self, PDE, units, degree, singularities, material={"lambda": 1, "mu": 1}, has_bias=True, rhs_solution=None):
"""
:param PDE: Problem to solve, either 'laplace', 'biharmonic', 'km' or 'km-so'.
:type PDE: str
:param units: List containing number of units at each layer, e.g., [1,10,10,1].
:type units: list of int
:param degree: KAN polynomial degree :math:`D`.
:type degree: int
:param singularities: List with the information about singularities. Each element of the list (:math:`s=1,\dots,S`) is a dictionary containing :math:`z_s` ("location") and units of :math:`NN_s` ("units").
:type singularities: List of dictionaries
:param material: Properties of the material, dictionary with 'lambda' (first Lamé coefficient), 'mu' (second Lamé coefficient).
:type material: dict
:param has_bias: Whether to include the bias vector.
:type has_bias: bool
:param rhs_solution: Particular solution to the non-homogeneous problem. E.g., :math:`x^2+y^2` for :math:`\\nabla^2u=4`.
:type rhs_solution: callable
"""
super(L_PIHKAN, self).__init__(PDE, units, singularities, material, has_bias, rhs_solution)
self.analytical_net = PIHKAN(PDE, units, degree, material, has_bias)
self.singularity_nets = torch.nn.ModuleList()
for singularity in singularities:
self.singularity_nets.append(PIHKAN(PDE, singularity["units"], singularity["degree"], material, has_bias))