Source code for pyforce.offline.sensors

# Offline Phase: sensors classes
# Author: Stefano Riva, PhD Student, NRG, Politecnico di Milano
# Latest Code Update: 07 October 2025
# Latest Doc  Update: 07 October 2025

import numpy as np
from ..tools.functions_list import FunctionsList
from ..tools.backends import IntegralCalculator, LoopProgress
import pyvista as pv

from abc import ABC, abstractmethod

[docs] class SensorLibraryBase(ABC): r""" Abstract base class for sensors, mathematically modelled as linear functionals: .. math:: v(u; \boldsymbol{\xi}) = \int_{\Omega} u(\mathbf{x}) \mathcal{K}(\mathbf{x}, \boldsymbol{\xi}) d\mathbf{x} where :math:`\mathcal{K}(\mathbf{x}, \boldsymbol{\xi})` is the kernel function and :math:`\boldsymbol{\xi}` are the sensor parameters (e.g., centre of mass and variance for Gaussian sensors). """ @property def nodes(self): """Return the nodes where the sensors are placed.""" return self._nodes @property def library(self): """Return the library of sensors.""" return self._library def __len__(self): """Return the number of sensors in the library.""" if self._library is None: return 0 else: return len(self._library) @abstractmethod def _define(self, **kwargs): """Abstract method to define a sensor, given its parameters.""" pass
[docs] @abstractmethod def create_library(self, **kwargs): """Abstract method to create a library of sensors.""" pass
def __call__(self, func: FunctionsList | np.ndarray, **kwargs): """Compute the action of the sensor on a given function.""" if isinstance(func, FunctionsList): _output = list() for f in func: _output.append(self.action(f, **kwargs)) return np.array(_output).T # shape (Nsensors, Ns) elif isinstance(func, np.ndarray): return self.action(func, **kwargs).reshape(-1, 1) # shape (Nsensors, 1) else: raise TypeError("Input must be a FunctionsList or a numpy array.")
[docs] def set_library(self, library: FunctionsList): r""" Set the sensor library to a given FunctionsList. Parameters ---------- library: FunctionsList The library of sensors to be set. """ self._library = FunctionsList(dofs = len(self.nodes)) self._library._list = library._list.copy()
[docs] def add_sensor(self, kernel: np.ndarray): r""" Add a sensor to the library. Parameters ---------- kernel: np.ndarray The kernel function of the sensor to be added. """ if self._library is None: self._library = FunctionsList(dofs = len(self.nodes)) self._library.append(kernel)
def _action_single(self, func: np.ndarray, idx_sens: int): r""" Compute the action of a single sensor on a given function. Parameters ---------- func: np.ndarray The function to be sensed. idx_sens: int The index of the sensor in the library. Returns ------- action: float The action of the sensor on the function. """ if self._library is None: raise ValueError("Sensor library is not created. Please call 'create_library' or `set_library` methods first.") return self.calculator.L2_inner_product(func, self._library[idx_sens])
[docs] def action(self, func: FunctionsList | np.ndarray, M: int = None): r""" Compute the action of the sensor library on a given function (both single or matrix) or a list of functions. Parameters ---------- func: FunctionsList | np.ndarray The function or list of functions to be sensed. M: int, optional (default=None) If provided, only the first M sensors in the library are used. Returns ------- actions: np.ndarray The actions of the sensor library on the function(s). Shape (Nsensors, Ns). """ if self._library is None: raise ValueError("Sensor library is not created. Please call 'create_library' or `set_library` methods first.") if M is None: M = len(self._library) else: assert M <= len(self._library), "M cannot be larger than the number of sensors in the library." if isinstance(func, FunctionsList): _measurements = list() for f in func: _measurements.append( np.array([self._action_single(f, i) for i in range(M)]) ) return np.array(_measurements).T # shape (Nsensors, Ns) elif isinstance(func, np.ndarray): if func.ndim == 1: func = np.atleast_2d(func).T # shape (N, 1) _measurements = list() for ii in range(func.shape[1]): _measurements.append( np.array([self._action_single(func[:, ii], i) for i in range(M)]) ) return np.array(_measurements).T # shape (Nsensors, Ns)
[docs] class GaussianSensorLibrary(SensorLibraryBase): r""" A class implementing a library of Gaussian sensors. A Gaussian sensor is mathematically modelled as a linear functional, with a Gaussian kernel function with two parameters: centre of mass and variance: .. math:: v(u(\mathbf{x}); \mathbf{x}_m, s) = C\cdot \int_{\Omega} u(\mathbf{x}) \exp\left(-\frac{||\mathbf{x} - \mathbf{x}_m||^2}{2s^2}\right) d\mathbf{x} where :math:`\mathbf{x}_m` is the centre of mass, :math:`s` is the standard deviation (variance), and :math:`C` is a normalization constant, such that :math:`v(1; \mathbf{x}_m, s) = 1` or equivalently that the :math:`L^1` norm of the kernel is equal to one. Parameters ---------- grid: pyvista.UnstructuredGrid The computational grid. use_centroids: bool, optional (default=True) If True, the sensors are placed at the centroids of the grid cells. If False, the sensors are placed at the grid points. gdim: int, optional (default=3) The geometric dimension of the problem. Default is 3. """ def __init__(self, grid: pv.UnstructuredGrid, use_centroids: bool = True, gdim: int = 3): self.grid = grid self.gdim = gdim self.calculator = IntegralCalculator(grid, gdim) self._library = None if use_centroids: self._nodes = grid.cell_centers().points else: self._nodes = grid.points def _define(self, xm: np.ndarray, s: float): r""" Define a Gaussian sensor given its parameters. Parameters ---------- xm: np.ndarray The centre of mass of the Gaussian sensor. s: float The standard deviation (variance) of the Gaussian sensor. Returns ------- kernel: function The kernel function of the Gaussian sensor. """ def kernel(x): return np.exp(-np.linalg.norm(x - xm, axis=1)**2 / (2 * s**2)) _kernel = kernel(self.nodes) # Normalization constant C = 1.0 / self.calculator.L1_norm(_kernel) return _kernel * C
[docs] def create_library(self, s: float, xm_list: np.ndarray = None, verbose: bool = False): r""" Create a library of Gaussian sensors, given a variance and a list of centres of mass (if provided). Parameters ---------- s: float The standard deviation (variance) of the Gaussian sensors. xm_list: np.ndarray, optional (default=None) A list of centres of mass for the Gaussian sensors. If None, the sensors are placed at the grid nodes. verbose: bool, optional (default=False) If True, print progress information. """ if xm_list is None: xm_list = self.nodes.tolist() self.xm_list = xm_list self._library = FunctionsList(dofs = len(self.nodes)) if verbose: progress = LoopProgress(msg="Creating Gaussian Sensor Library", final=len(xm_list)) for xm in xm_list: # Define and append the sensor to the library self._library.append( self._define(xm=xm, s=s) ) # Update progress bar if verbose: progress.update(1, percentage=True)
[docs] class ExponentialSensorLibrary(SensorLibraryBase): r""" A class implementing a library of Exponential sensors. An Exponential sensor is mathematically modelled as a linear functional, with an Exponential kernel function with two parameters: centre of mass and variance: .. math:: v(u(\mathbf{x}); \mathbf{x}_m, s) = C\cdot \int_{\Omega} u(\mathbf{x}) \exp\left(-\frac{||\mathbf{x} - \mathbf{x}_m||}{s}\right) d\mathbf{x} where :math:`\mathbf{x}_m` is the centre of mass, :math:`s` is the standard deviation (variance), and :math:`C` is a normalization constant, such that :math:`v(1; \mathbf{x}_m, s) = 1` or equivalently that the :math:`L^1` norm of the kernel is equal to one. Parameters ---------- grid: pyvista.UnstructuredGrid The computational grid. use_centroids: bool, optional (default=True) If True, the sensors are placed at the centroids of the grid cells. If False, the sensors are placed at the grid points. gdim: int, optional (default=3) The geometric dimension of the problem. Default is 3. """ def __init__(self, grid: pv.UnstructuredGrid, use_centroids: bool = True, gdim: int = 3): self.grid = grid self.gdim = gdim self.calculator = IntegralCalculator(grid, gdim) self._library = None if use_centroids: self._nodes = grid.cell_centers().points else: self._nodes = grid.points def _define(self, xm: np.ndarray, s: float): r""" Define an Exponential sensor given its parameters. Parameters ---------- xm: np.ndarray The centre of mass of the Exponential sensor. s: float The standard deviation (variance) of the Exponential sensor. Returns ------- kernel: function The kernel function of the Exponential sensor. """ def kernel(x): return np.exp(-np.linalg.norm(x - xm, axis=1) / s) _kernel = kernel(self.nodes) # Normalization constant C = 1.0 / self.calculator.L1_norm(_kernel) return _kernel * C
[docs] def create_library(self, s: float, xm_list: np.ndarray = None, verbose: bool = False): r""" Create a library of Exponential sensors, given a variance and a list of centres of mass (if provided). Parameters ---------- s: float The standard deviation (variance) of the Exponential sensors. xm_list: np.ndarray, optional (default=None) A list of centres of mass for the Exponential sensors. If None, the sensors are placed at the grid nodes. verbose: bool, optional (default=False) If True, print progress information. """ if xm_list is None: xm_list = self.nodes.tolist() self.xm_list = xm_list self._library = FunctionsList(dofs = len(self.nodes)) if verbose: progress = LoopProgress(msg="Creating Exponential Sensor Library", final=len(xm_list)) for xm in xm_list: # Define and append the sensor to the library self._library.append( self._define(xm=xm, s=s) ) # Update progress bar if verbose: progress.update(1, percentage=True)
[docs] class IndicatorFunctionSensorLibrary(SensorLibraryBase): r""" A class implementing a library of Indicator Function sensors. An Indicator Function sensor is mathematically modelled as a linear functional, with an Indicator Function kernel: .. math:: v(u(\mathbf{x}); \mathbf{x}_m, r) = \int_{\Omega} u(\mathbf{x}) \mathcal{I}(\mathbf{x}, \mathbf{x}_m, r) d\mathbf{x} where :math:`\mathcal{I}(\mathbf{x}, \mathbf{x}_m, r)` is the indicator function defined as: .. math:: \mathcal{I}(\mathbf{x}, \mathbf{x}_m, r) = \begin{cases} 1 & \text{if } ||\mathbf{x} - \mathbf{x}_m|| \leq r \\ 0 & \text{otherwise} \end{cases} where :math:`\mathbf{x}_m` is the centre of mass and :math:`r` is the radius. Parameters ---------- grid: pyvista.UnstructuredGrid The computational grid. use_centroids: bool, optional (default=True) If True, the sensors are placed at the centroids of the grid cells. If False, the sensors are placed at the grid points. gdim: int, optional (default=3) The geometric dimension of the problem. Default is 3. """ def __init__(self, grid: pv.UnstructuredGrid, use_centroids: bool = True, gdim: int = 3): self.grid = grid self.gdim = gdim self.calculator = IntegralCalculator(grid, gdim) self._library = None if use_centroids: self._nodes = grid.cell_centers().points else: self._nodes = grid.points def _define(self, xm: np.ndarray, r: float): r""" Define an Indicator Function sensor given its parameters. Parameters ---------- xm: np.ndarray The centre of mass of the Indicator Function sensor. r: float The radius of the Indicator Function sensor. Returns ------- kernel: function The kernel function of the Indicator Function sensor. """ def kernel(x): return np.where(np.linalg.norm(x - xm, axis=1) <= r, 1.0, 0.0) return kernel(self._nodes)
[docs] def create_library(self, r: float, xm_list: np.ndarray = None, verbose: bool = False): r""" Create a library of Indicator Function sensors, given a radius and a list of centres of mass (if provided). Parameters ---------- r: float The radius of the Indicator Function sensors. xm_list: np.ndarray, optional (default=None) A list of centres of mass for the Indicator Function sensors. If None, the sensors are placed at the grid nodes. verbose: bool, optional (default=False) If True, print progress information. """ if xm_list is None: xm_list = self._nodes.tolist() self.xm_list = xm_list self._library = FunctionsList(dofs = len(self._nodes)) if verbose: progress = LoopProgress(msg="Creating Indicator Function Sensor Library", final=len(xm_list)) for xm in xm_list: # Define and append the sensor to the library self._library.append( self._define(xm=xm, r=r) ) # Update progress bar if verbose: progress.update(1, percentage=True)