/opt/conda/envs/dxtr/lib/python3.12/site-packages/pyvista/plotting/utilities/xvfb.py:48: PyVistaDeprecationWarning: This function is deprecated and will be removed in future version of PyVista. Use vtk-osmesa instead. warnings.warn(
Exterior derivative operator in dxtr¶
Abstract:
The exterior_derivative() operator is the corner stone of any DEC implementation. In this notebook we assess the precision of its implementation in the Dxtr library.
Note: You can download this notebook and play with it directly on your local machine.
Presentation¶
Given a k-form ($\omega\in\Lambda^k(\mathcal{M})$) defined on a n-dimentional manifold ($\mathcal{M}$), the external derivative is defined as: $$ \omega = \omega_{i_1\dots i_k}dx^{i_1}\wedge\dots\wedge dx^{i_k} \quad \Rightarrow \quad d(\omega) = \frac{\partial\omega_{1\dots k}}{\partial x^j}dx^j\wedge dx^{i_1}\wedge\dots\wedge dx^{i_k}, $$ where the Einstein summation convention is used.
In the specific cases of 0-forms defined on a 2D domain we have: $$ \omega^{(0)} = \omega(\xi^0, \xi^1) \Rightarrow d(\omega^{(0)}) = \frac{\partial \omega}{\partial\xi^0}d\xi^0 + \frac{\partial \omega}{\partial\xi^1}d\xi^1 $$
To assess the precision of our implementation of the exterior derivative, we will consider simple differential forms defined on the 2D sphere of unit radius.
Dependencies¶
from __future__ import annotations
from typing import Optional
import copy as cp
import numpy as np
import numpy.linalg as lng
import pandas as pd
import pyvista as pv
import plotly.express as px
from dxtr import Cochain, SimplicialManifold
from dxtr.operators import d
from dxtr.complexes import sphere, disk
from dxtr.visu import visualize
from dxtr.complexes.simplicialmanifold import edge_vectors
Domains definition¶
We will consider two domains of interest:
- A disk of unit radius.
- A sphere of unit radius.
While the disk is flat and feature boundaries, the sphere is curved and closed. Investigating the exterior derivative on both complexes will help us understand the influence of both boundaries and curvature on its precision.
Sphere¶
sph = sphere(manifold=True)
sph.name = 'Sphere'
We will need to measure the position of simplices with respect to the north pole of this sphere. To that end, we define a function to compute this curvilinear abscissae.
def curvilinear_abscissae(manifold:SimplicialManifold,
k:int, dual:bool=False) -> np.ndarray[float]:
"""Computes the arclength between the k-simplex circumcenters and the north
pole of a given n-simplicial complex.
Notes
-----
* We add a special case for the dual edges because the circumcenters
of the primal edges do not necessarily corresponds to the ones of the
dual edges. And this could impact the precision estimation.
"""
if dual & (k==1):
cfids = manifold.cofaces(1)
x = manifold[k+1].circumcenters[cfids].mean(axis=-2)
else:
x = cp.deepcopy(manifold[k].circumcenters)
z = x[:,-1]
r_xy = lng.norm(x[:,:2], axis=-1)
theta = np.arctan2(r_xy, z)
r = lng.norm(x, axis=-1)
print(f'Checking radius precision:{r.mean():.3e}+-{r.std():.2e}')
return r * theta
We now compute the curvilinear positions for the 0-simplices and the 1-simplices of the primal complex as well as for the 0-cells and the 1-cells of the dual one:
complex_names = ['primal', 'dual']
curv_abs = {name: curvilinear_abscissae(sph, 2*k)
for k, name in enumerate(complex_names)}
# A variable that we will use a lot:
# the curvilinear abscissae of the 1-simplices circumcenters
curv_abs_edges = {name: curvilinear_abscissae(sph, 1, name=='dual')
for name in complex_names}
Checking radius precision:1.000e+00+-5.51e-17 Checking radius precision:9.985e-01+-1.63e-04 Checking radius precision:9.988e-01+-1.71e-04 Checking radius precision:9.981e-01+-1.81e-04
Remark: Note that the precision of the circumcenters of the simplices of degrees >0 is significantly worst that the precision on the vertices. This might have an impact of the precision of our implementation.
def colatitude_vector(manifold:SimplicialManifold, k:int,
dual:bool=False) -> np.ndarray[float]:
"""Computes the unit vector field tangent to k simplices along the
curvilinear abscissae.
"""
Nk = manifold[k].size
# if k==1:
# cfids = manifold.cofaces(k)
# pos = manifold[k+1].circumcenters[cfids].mean(axis=-2)
# else:
pos = cp.deepcopy(manifold[k].circumcenters)
ez =np.repeat(np.array([[0,0,1]]), Nk, axis=0)
ephi = np.cross(ez, pos, axis=-1)
etheta = np.cross(ephi, pos, axis=-1)
etheta /= lng.norm(etheta, axis=-1).reshape((Nk,1))
return etheta
Disk¶
dsk = disk(manifold=True)
dsk.name = 'Disk'
Let's get the radial positions of 0-simplices and 0-cells:
radius = {name: lng.norm(dsk[2*k].circumcenters, axis=-1)
for k, name in enumerate(['primal', 'dual'])}
radius_edges = {name: lng.norm(dsk[1].circumcenters, axis=-1)
for name in complex_names}
Note: 1-simplices and 1-cells share the same circumcenters on the disk. That is why we do not distinguish 'primal' and 'dual' cases in the computation above.
We also define a function to compute the radial direction on every simplex circumcenter:
def radial_vector(manifold:SimplicialManifold, k:int,
dual:bool=False) -> np.ndarray[float]:
"""Computes the unit vector field tangent to k simplices along the
radial direction on the disk.
"""
Nk = manifold.shape[k]
pos = cp.deepcopy(manifold[k].circumcenters)
return pos / lng.norm(pos, axis=-1).reshape(Nk, 1)
Finally, we define a boolean flag to mark the border of the disk on the 1-simplices.
is_border = np.isin(np.arange(dsk[1].size), dsk.border()[1])
Defining the test function and the expected results¶
To test our implementation of the exterior derivative, we propose to compute the exterior derivative of a simple 0-form: $$f(s) = \cos(\omega s), \tag{1}$$ where $s$ depicts the curvilinear abscissae with respect to a reference position ($\boldsymbol{x}_0$) defined on the considered domain, $\Omega$.
- In the case of the sphere, we choose as a reference position the "north pole" ($\boldsymbol{x}_0 = [0, 0, 1]$) and consequently, the curvilinear abscissae has the usual expression: $s = R\theta$, where $R=1$ in our case and $\theta\in [0, \pi[$ stands for the colatitude angle.
- In the case of the disk, the reference position is its center ($\boldsymbol{x}_0 = [0, 0, 0]$) and the curvilinear abscissae simply corresponds to the radial distance $s = r\in [0,1]$.
We define the test function to use, given by $\text{eq.}(1)$:
def f(s:np.ndarray[float], w:float=1) -> np.ndarray[float]:
"""The function to derive."""
return np.cos(w*s)
As the considered 0-form $f$ only depends on the curvilinear abscissae $s$, its exterior derivative will verify: $$ df = \frac{\partial f}{\partial s} \boldsymbol{ds}, $$ with $\boldsymbol{ds}= \boldsymbol{e}_s^\flat$ the 1-form dual of the vector field $\boldsymbol{e}_s$, tangent to the considered manifold, along the isolines $s=cste$.
Deriving $\text{eq.}(1)$ yields: $$ \frac{\partial f}{\partial s} = -\omega\sin(\omega s), \tag{2} $$ we define the corresponding partial derivative:
def dfds(s:np.ndarray[float], w:float=1) -> np.ndarray[float]:
"""The approximated derivative"""
return - w * np.sin(w*s)
As mentioned previously in the documentation, in DEC, k-forms are approximated as k-cochains, whose coefficients correspond to the integration of the of the considered k-form on k-simplices (k-cells in the dual case).
In the present case, it means that the coefficients of the exterior derivative we seek verify: $$ c_i = \int_{\sigma^{(1)}_i}df, $$ where the $\sigma^{(1)}_i$ correspond to the 1-simplices (resp. 1-cells) of the considered complex.
Assuming the partial derivative, $\text{eq.}(2)$, to be constant on each 1-simplex, the sought coefficients can be approximated as: $$ c_i \approx \left.\frac{\partial f}{\partial s}\right\vert_{\boldsymbol{x}_i} w_i, \quad\text{with:}\quad w_i = \int_{\sigma^{(1)}_i}\boldsymbol{e}_s^\flat. $$ This is the formulae to estimate at every 1-simplex (1-cell) at to compare to the computed values. It is composed of two elements:
- The derivative of the considered function $f$ with respect to curvilinear abscissae, estimated at the center of the 1-simplices.
- A "weight" coefficient ($w_i$), to compute for each 1-simplex, corresponding to the circulation of the base vector $\boldsymbol{e}_s$ along the 1-simplex.
Computing these "weights" in the k=1 case, yeilds: $$ w_i = \int_{|\boldsymbol{e}_i|}ds\:\hat{\boldsymbol{e}}_s\cdot\hat{\boldsymbol{e}}_i \approx l_i\cos(\theta_i), \tag{3} $$
Note: In the dual case, the integration is performed on the 1-cells dual of the 1-simplices. The lengths ($l_i$) and angles ($\theta_i$) must be updated acordingly.
def weights(manifold:SimplicialManifold,
dual:bool) -> Optional[np.ndarray[float]]:
"""Esitmates the weight of each 1-simplex as the projection of its edge
vector along the radial direction.
Notes
-----
* depending on the estimation we want (primal/dual), we compute the
radial vectors from the circumcenters of 0-simplices or 2-simplices,
because we realized that for a significant number of 1-simplices we get
slight errors if we simply took their circumcenters. Notably close to
the border.
"""
edges = edge_vectors(manifold, dual=dual)
if manifold.name == 'Sphere':
direction = colatitude_vector(manifold, 1)
elif manifold.name == 'Disk':
direction = radial_vector(manifold, 1)
else:
print('The input manifold is not supported')
return None
return np.abs(np.einsum('ij,ij->i', direction, edges))
Computing the expectation, in the sphere case:
xpct_ext_deri_sph = {duality:
Cochain(sph, 1, dfds(s) * weights(sph, duality=='dual'), duality=='dual')
for duality, s in curv_abs_edges.items()}
Expectation, in the disk case:
w = np.pi/radius_edges['primal'].max()
xpct_ext_deri_dsk = {duality:
Cochain(dsk, 1, dfds(r,w)*weights(dsk, duality=='dual'), duality=='dual')
for duality, r in radius_edges.items()}
Computation of the exterior derivatives¶
On the sphere¶
We instanciate the primal and dual 0-cochain to test:
signal_sph = {name: Cochain(sph, 0, f(s), dual=name=='dual')
for name, s in curv_abs.items()}
We derive these cochains using the d() function to call the exterior_derivative() function:
ext_deri_sph = {name: d(c) for name, c in signal_sph.items()}
Then, we define an error() function to estimate the discrepency between the computed & expected values.
def error(x:np.ndarray[float], y:np.ndarray[float],
threshold:float=1e-9) -> np.ndarray[float]:
"""Computes the relative error between two arrays.
Notes
-----
* We added a threshold to handle points where the expected
value vanishes and avoid dividing by almost zero.
"""
x, y = cp.deepcopy(x), cp.deepcopy(y)
zero_indices = np.where(y<threshold)
y[zero_indices] = x[zero_indices] = 1
return x/y - 1
We gather the computed & expected values as well as the relative error between them in a pandas.DataFrame structure. This will allow us to generate quantitative visualization in the next section.
data_sph = pd.DataFrame()
for duality, xder in ext_deri_sph.items():
comp = np.abs(xder.values)
xpct = np.abs(xpct_ext_deri_sph[duality].values)
s1 = curv_abs_edges[duality]
r1 = lng.norm(sph[1].circumcenters,axis=-1)
df = pd.DataFrame({'Complex': duality,
'Simplex id': np.arange(xder.complex[1].size),
'Curvilinear abscissae': s1,
'Colatitude': 180/np.pi * s1/r1,
'Computed values': comp,
'Expected values': xpct,
'Relative error': error(comp, xpct)})
data_sph = pd.concat((data_sph, df), ignore_index=True)
On the disk¶
signal_dsk = {name: Cochain(dsk, 0, f(r,w=np.pi/r.max()), dual=name=='dual')
for name, r in radius.items()}
We derive these cochains using the d() function as before on the sphere:
ext_deri_dsk = {name: d(c) for name, c in signal_dsk.items()}
We gather the computed values, the expected ones, as well as the relative error in a DataFrame for visualization purposes.
data_dsk = pd.DataFrame()
for (duality, xder), (_, xpct) in zip(ext_deri_dsk.items(),
xpct_ext_deri_dsk.items()):
comp = np.abs(xder.values)
xpct = np.abs(xpct.values)
# Since the computation is not valid for 1-cells on the border, we put those values to Nan:
if duality == 'dual':
borders = np.where(is_border)
comp[borders] = np.nan
xpct[borders] = np.nan
df = pd.DataFrame({'Complex': duality,
'Simplex id': np.arange(dsk[1].size),
'Computed values': comp,
'Expected values': xpct,
'Relative error': error(comp, xpct),
'Borders': is_border,
'Radius': radius_edges[duality]})
data_dsk = pd.concat((data_dsk, df), ignore_index=True)
Results analysis on the sphere¶
We can display the results of these computations with the visualize()
function from the dxtr.visu module.
fig = pv.Plotter(shape=(2,2), border=False)
for i, (name, s) in enumerate(signal_sph.items()):
fig.subplot(0,i)
visualize(s, fig=fig, display=False,
layout_parameters={'title': f'{name} 0-cochain to derive.'})
for i, (name, c) in enumerate(ext_deri_sph.items()):
fig.subplot(1,i)
visualize(c, fig=fig, display=False, scaling_factor=10,
layout_parameters={'title': f'Ext. deri. of a {name} 0-cochain.'})
fig.link_views()
fig.show()
We can also visualize the correlation between expected and computed values of the exterior derivative in a more quantitative manner:
fig = px.scatter(data_sph,
x='Computed values',
y='Expected values',
color= 'Colatitude',
facet_col='Complex',
trendline='ols',
hover_data=('Simplex id', 'Colatitude'),
title='<b>Exterior derivative</b> | expectation vs computation.')
fig.show()
# Extracting the regression coefficients:
print('Correlation between expected & computed values:')
for subplot in px.get_trendline_results(fig).iterrows():
row = subplot[1]
residual, slope = row.px_fit_results.params
print(f" - {row['Complex'][0].upper()}{row['Complex'][1:]} complex: {slope:.2%} (residual: {residual:.2e})")