Source code for vectorose.tregenza_sphere

# Copyright (c) 2024-, Benjamin Rudski, Joseph Deering
#
# This code is licensed under the MIT License. See the `LICENSE` file for
# more details about copying.

"""Sphere plotting with (almost) equal-area rectangles.

This module provides the functions necessary to produce an approximately
equal area rectangular-based projection of a sphere, with face colours
corresponding to either the face count or a sum of the magnitudes of
vectors at each orientation. This projection is based on work by 
Beckers & Beckers. [#Beckers]_

References
----------
.. [#Beckers] Beckers, B., & Beckers, P. (2012). A general rule for disk and
   hemisphere partition into equal-area cells. Computational Geometry,
   45(7), 275-283. https://doi.org/10.1016/j.comgeo.2012.01.011

"""
# import enum
from typing import List, Optional, Tuple, Type

import numpy as np
import pandas as pd
import pyvista as pv

from . import util
from .sphere_base import SphereBase


[docs] class TregenzaSphere(SphereBase): """Base class for the Tregenza sphere. The Tregenza sphere provides a discretisation of the sphere based on mostly rectangular patches of almost equal area. This class defines the interface for the Tregenza sphere and includes all the functions necessary to use it for histogram binning and 3D representation. Notes ----- This sphere design is based on the tiling presented by Beckers & Beckers. [#Beckers]_ While this class can be instantiated directly, we **strongly** recommend that you consider one of the derived classes presented below. By using these, you will avoid the tedious task of computing the optimal number of bins per row, as well as computing the almucantar angles for the rings. """ binning_precision: int = 10 """Precision for rounding when computing bin assignments.""" _rings: pd.DataFrame """Tregenza sphere ring definitions.""" _ring_increment: float """Angular increment for regularly-sized rings.""" @property def number_of_rings(self) -> int: """Number of rings in the sphere.""" return len(self._rings) @property def number_of_irregular_rings(self) -> int: """Number of irregular rings in the sphere.""" return len(self._rings[~self._rings.loc[:, "regular"]]) @property def ring_increment(self) -> float: """Angular increment for the regularly-sized rings.""" return self._ring_increment @property def orientation_cols(self) -> List[str]: return ["ring", "bin"] # Define the constructor... def __init__( self, patch_count: np.ndarray, irregular_phi_values: np.ndarray, ring_increment: float, number_of_shells: int = 1, magnitude_range: Optional[Tuple[float, float]] = None, ): rings = self._construct_tregenza_sphere( irregular_phi_values, patch_count, ring_increment ) self._rings = rings self._ring_increment = ring_increment super().__init__( number_of_shells=number_of_shells, magnitude_range=magnitude_range )
[docs] def _construct_tregenza_sphere( self, irregular_phi_values: np.ndarray, patch_count: np.ndarray, ring_increment: float, ) -> pd.DataFrame: """Define a Tregenza sphere. Define a Tregenza sphere with the specified patch count and almucantar angles. Parameters ---------- irregular_phi_values The phi values for the first rings, which are manually defined and of irregular increments. patch_count The number of rectangular patches in each ring. ring_increment The regular phi spacing between adjacent rings. Returns ------- pandas.DataFrame The structure of the produced Tregenza sphere, containing as index the ring number, and as columns: * ``bins`` -- the number of bins in each ring; * ``start`` -- the starting phi value for each ring; * ``end`` -- the ending phi angle of each ring; * ``theta_inc`` -- the theta increment within each ring; * ``face_area`` -- the area of the faces in each ring; * ``weight`` -- the correction weight for the faces in each ring; * ``regular`` -- an indication of whether the ring has a regular almucantar spacing or not. Warnings -------- This method assumes that the Tregenza sphere is symmetric with respect to the equator, i.e., that the northern and southern hemispheres are reflections of each other. """ # Define our data frame which will hold everything rings = pd.DataFrame() rings.index.name = "ring" # Start by getting the number of rings half_number_of_rings = len(patch_count) number_of_rings = 2 * half_number_of_rings # Begin by mirroring the patch count. patch_count_bottom_half = np.flip(patch_count) all_patch_counts = np.hstack([patch_count, patch_count_bottom_half]) # Start by defining the rings based on the patch count. rings["bins"] = pd.Series(all_patch_counts) # And now, for the ring beginning angles number_of_irregular_rings = len(irregular_phi_values) initial_angle = irregular_phi_values[-1] ring_initial_angles = np.arange( initial_angle + ring_increment, 89.999999, ring_increment ) top_half_initial_angles = np.hstack([irregular_phi_values, ring_initial_angles]) # And now, for the initial angles in the bottom half bottom_half_initial_angles = 180 - top_half_initial_angles[1:] bottom_half_initial_angles = np.flip(bottom_half_initial_angles) bottom_half_initial_angles = np.insert(bottom_half_initial_angles, 0, 90.0) # Finally, combine all the initial angles ring_initial_angles = np.hstack( [top_half_initial_angles, bottom_half_initial_angles] ) # And now, put these in the data frame rings["start"] = ring_initial_angles # Compute the bin end angles ring_end_angles = np.roll(ring_initial_angles, -1) ring_end_angles[-1] = 180 rings["end"] = ring_end_angles # Now, we need to compute the theta bin size for each ring theta_bin_size = 360 / all_patch_counts rings["theta_inc"] = theta_bin_size # And now, the face areas and weights face_areas = self._compute_face_areas(ring_initial_angles, all_patch_counts) rings["face_area"] = face_areas weights = face_areas.min() / face_areas rings["weight"] = weights # Indicate the regular and irregular rings regularity = np.array([True] * number_of_rings) regularity[:number_of_irregular_rings] = False regularity[-number_of_irregular_rings:] = False rings["regular"] = regularity return rings
[docs] def to_dataframe(self) -> pd.DataFrame: """Get the data frame representation of the sphere. Returns ------- pandas.DataFrame Representation of the sphere as a table. See **Notes** for details. Notes ----- The :class:`pandas.DataFrame` contains as index the ring number. For each row, the following columns are present: * ``bins`` -- the number of bins in each ring; * ``start`` -- the starting phi value for each ring; * ``end`` -- the ending phi angle of each ring; * ``theta_inc`` -- the theta increment within each ring; * ``face_area`` -- the area of the faces in each ring; * ``weight`` -- the correction weight for the faces in each ring; * ``regular`` -- an indication of whether the ring has a regular almucantar spacing or not. """ return self._rings.copy()
[docs] def get_closest_faces(self, spherical_coordinates: pd.DataFrame) -> pd.DataFrame: """Get the closest faces for a specified spherical positions. Parameters ---------- spherical_coordinates Coordinates containing ``phi`` and ``theta`` in **degrees**. Returns ------- pandas.DataFrame The phi ``ring`` and theta ``bin`` for each vector. Notes -------- To account for floating point errors, when determining the bin index within a ring, there is a rounding step. This step rounds the quotient of the angle and the bin width to a very small precision. This rounding is controlled by :attr:`binning_precision`. """ # First, let's get the phi ring for each vector ring_end_angles = self._rings.loc[:, "end"] ring_end_angles = ring_end_angles.iloc[:-1] phi = spherical_coordinates.loc[:, "phi"] rings = ring_end_angles.searchsorted(phi, side="right") # Now, let's get the theta spacing for each respective ring. theta_increments = self._rings.loc[rings, "theta_inc"] theta = spherical_coordinates.loc[:, "theta"] theta_bin = np.floor( np.round(theta / theta_increments.to_numpy(), self.binning_precision) ).astype(int) closest_faces = pd.DataFrame({"ring": rings, "bin": theta_bin}) return closest_faces
[docs] def _initial_vector_component_preparation( self, vectors: pd.DataFrame ) -> pd.DataFrame: vectors_array = vectors.loc[:, ["vx", "vy", "vz"]].to_numpy() spherical_coordinates = util.compute_spherical_coordinates( vectors_array, use_degrees=True ) spherical_coordinate_data_frame = pd.DataFrame( spherical_coordinates, columns=["phi", "theta", "magnitude"] ) return spherical_coordinate_data_frame
[docs] def _compute_orientation_binning(self, vectors: pd.DataFrame) -> pd.DataFrame: orientation_bins = self.get_closest_faces(vectors) return orientation_bins
[docs] def _construct_orientation_index(self) -> pd.MultiIndex: """Get the orientation index for the current Tregenza sphere. Produce the histogram orientation index for the current Tregenza sphere. Returns ------- pandas.MultiIndex Index containing all possible values of ring and bin in a given shell. """ # Get the ring numbers and bin counts as an array ring_indices = self._rings.index.to_numpy() bin_counts = self._rings.loc[:, "bins"].to_numpy() # Create the indices for the ring number ring_indices = np.repeat(ring_indices, bin_counts) # Create the incremental bin indices bin_indices = np.concatenate([np.arange(i) for i in bin_counts]) multi_index = pd.MultiIndex.from_arrays( [ring_indices, bin_indices], names=["ring", "bin"] ) return multi_index
[docs] def correct_histogram_by_area(self, histogram: pd.Series) -> pd.Series: """Correct histogram by face area. Weight histogram values by face areas to compensate for slight deviations from equal area. Parameters ---------- histogram Histogram values to correct. Returns ------- pandas.Series Corrected histogram values. """ # Compute the weights ring_weights = self._rings.loc[:, "weight"] original_index = histogram.index weighted_face_data = ( histogram.groupby("ring", group_keys=False).apply( lambda x: x, include_groups=False ) * ring_weights ) weighted_face_data = weighted_face_data.reindex(original_index) return weighted_face_data
[docs] def create_mesh(self) -> pv.PolyData: """Create Tregenza sphere mesh. Warnings -------- This mesh construction relies on an approximation that uses a rectangle (or polygon) for each patch. This causes some issues near the sphere poles. In the original work by Beckers and Beckers, [#Beckers]_ the cap at the pole was a circle and all other patches are sectors of the respective rings. In our implementation, the pole is a polygon with the number of vertices corresponding to the number of patches in the next row. The next row contains a small number of patches, which may appear thin or misshapen, leaving holes in the constructed mesh. These holes are only artifacts of the visualisation. In the conceptual calculation of the histogram binning, the holes do not exist. """ # So, here's how we're going to do this... # Construct the inner rings. inner_ring_df = self._rings.iloc[1:-1] inner_ring_meshes = inner_ring_df.apply( self._construct_interior_ring, axis="columns" ).to_list() # Construct the caps top_cap = self._construct_cap(inner_ring_meshes[0], True, 0) bottom_cap = self._construct_cap( inner_ring_meshes[-1], False, self.number_of_rings - 1 ) # Combine all the meshes into a single list inner_ring_meshes.append(bottom_cap) # Merge everything # sphere_mesh = pv.merge([top_cap] + inner_ring_meshes + [bottom_cap]) sphere_mesh = top_cap.append_polydata(*inner_ring_meshes) sphere_mesh = sphere_mesh.clean() return sphere_mesh
[docs] def _construct_interior_ring(self, ring: pd.Series) -> pv.PolyData: """Construct a mesh for a single interior ring. Parameters ---------- ring The parameters associated with the ring to construct, extracted from the :attr:`TregenzaSphere._rings` attribute. Returns ------- pyvista.PolyData A mesh representing the constructed ring. Notes ----- The mesh construction occurs carefully to ensure that the indexes of the vertices and faces are in the same order as the bins in any produced histogram data. """ # First, get the basic information about the ring number_of_faces = ring["bins"] start_phi = ring["start"] end_phi = ring["end"] # Define the theta rings theta = np.linspace(0, 360, number_of_faces, endpoint=False) top_phi = np.tile(start_phi, number_of_faces) bottom_phi = np.tile(end_phi, number_of_faces) top_ring = np.stack([top_phi, theta], axis=-1) bottom_ring = np.stack([bottom_phi, theta], axis=-1) # Convert the rings to Cartesian coordinates top_ring_cartesian = util.convert_spherical_to_cartesian_coordinates( top_ring, use_degrees=True ) bottom_ring_cartesian = util.convert_spherical_to_cartesian_coordinates( bottom_ring, use_degrees=True ) # Combine everything ring_vertices = np.concatenate( [top_ring_cartesian, bottom_ring_cartesian], axis=0 ) # Now, establish the connectivity. vertex_count_column = 4 * np.ones(number_of_faces, dtype=int) top_left_corner = np.arange(number_of_faces) top_right_corner = np.roll(top_left_corner, -1) bottom_left_corner = top_left_corner + number_of_faces bottom_right_corner = top_right_corner + number_of_faces # And now to create a table cells = np.stack( [ vertex_count_column, top_left_corner, top_right_corner, bottom_right_corner, bottom_left_corner, ], axis=-1, ) # Begin building the mesh ring_mesh = pv.PolyData(ring_vertices, cells) # And now, to test, let's add the face indices as scalars face_indices = range(number_of_faces) ring_mesh.cell_data["ring"] = np.tile(ring.name, number_of_faces) ring_mesh.cell_data["bin"] = face_indices return ring_mesh
[docs] def _construct_cap( self, adjacent_ring: pv.PolyData, is_top: bool, index: int ) -> pv.PolyData: """Construct a Tregenza sphere cap. Parameters ---------- adjacent_ring The ring immediately touching the cap. is_top Indicate whether the cap is on top of the provided ring. index The ring index. Returns ------- pyvista.PolyData A single polygon representing the produced cap """ number_of_vertices = adjacent_ring.n_points // 2 # Get the vertices forming the cap. # If the cap is above, the first `number_of_vertices` form the cap, # otherwise the last `number_of_vertices` do. if is_top: vertices = adjacent_ring.points[:number_of_vertices] else: vertices = adjacent_ring.points[number_of_vertices:] cap_cell = np.hstack([number_of_vertices, np.arange(number_of_vertices)]) # Now, create the mesh cap = pv.PolyData(vertices, cap_cell) cap.cell_data["ring"] = [index] cap.cell_data["bin"] = [0] return cap
[docs] @staticmethod def _compute_face_areas( start_angles: np.ndarray, patch_counts: np.ndarray ) -> np.ndarray: """Compute the face areas for each ring. Compute the face area for each ring the Tregenza sphere. Parameters ---------- start_angles The initial angle for each ring. patch_counts The number of patches in each ring. Returns ------- numpy.ndarray Array of shape ``(r,)`` where ``r`` is the number of rings in the sphere. These weights reflect the deviation from equal area. Notes ----- For each ring,the area of the spherical cap up to the bottom of the ring is calculated as .. math:: A_{\\textup{cap}}(r) = 2\\pi (1 - \\cos(\\phi_{r+1})) where :math:`\\phi_{r+1}` is the almucantar angle at the bottom of the ring with index ``r`` in **radians**. The area of the ring is obtained by subtracting the areas of the previous rings from the total cap area. For the first and last rings, which correspond to the spherical caps, the ring area is equal to the cap area. The area of each Tregenza patch is then computed by dividing the total ring area by the number of patches in that ring. To compute weights based on these values, all face areas must be normalised by the smallest face (typically the cap), and then the reciprocal of these values should be taken. These weights represent the ratio of the smallest patch to each patch, and can thus serve as a scaling factor between 0 and 1 to account for effects caused by the slight differences in face area. """ # Determine the cumulative spherical cap areas end_angles = np.roll(start_angles, -1) end_angles[-1] = 180 cumulative_areas = 2 * np.pi * (1 - np.cos(np.radians(end_angles))) preceding_areas = np.roll(cumulative_areas, 1) preceding_areas[0] = 0 ring_areas = cumulative_areas - preceding_areas patch_areas = ring_areas / patch_counts return patch_areas
[docs] def convert_vectors_to_cartesian_array( self, labelled_vectors: pd.DataFrame, create_unit_vectors: bool = False, include_spatial_coordinates: bool = False, ) -> np.ndarray: # Here's how this is going to go... We have spherical coordinates # with the columns `phi`, `theta` and `magnitude`. If we want unit # vectors, we just convert using phi and theta. If we don't, then # we throw in the magnitudes also. angular_coordinates = labelled_vectors[["phi", "theta"]].to_numpy() if create_unit_vectors: magnitudes = 1 else: magnitudes = labelled_vectors["magnitude"].to_numpy() cartesian_coordinates = util.convert_spherical_to_cartesian_coordinates( angular_coordinates, radius=magnitudes, use_degrees=True ) if include_spatial_coordinates: spatial_locations = labelled_vectors[["x", "y", "z"]].to_numpy() cartesian_coordinates = np.concatenate( [spatial_locations, cartesian_coordinates], axis=-1 ) return cartesian_coordinates
[docs] def _get_cell_index(self, orientation_bin: pd.Series) -> int: """Get the cell index for a single orientation bin. Parameters ---------- orientation_bin Series containing index keys ``ring`` and ``bin`` indicating the desired orientation to be studied. Returns ------- int The cell index for the desired orientation. Notes ----- The cell index is found by adding the number of bins in all prior rings, and then adding the number of bins. """ ring_index = orientation_bin["ring"] bin_index = orientation_bin["bin"] cell_index = self._rings.loc[:ring_index - 1, "bins"].sum() + bin_index return cell_index
[docs] def get_cell_indices(self, bins: pd.DataFrame) -> pd.Series: # Here's the idea: add the number of bins in all rings below, plus # whatever bin you're on. For example, in the top, it's zero. # The next row starts at index 1, etc. return bins.apply(self._get_cell_index, axis="columns")
[docs] class CoarseTregenzaSphere(TregenzaSphere): """Coarse representation of the Tregenza sphere. This sphere is constructed with 18 rings (9 in each hemisphere). It contains a total of 520 patches. Notes ----- This sphere design is based on the tiling presented by Beckers & Beckers. [#Beckers]_ The almucantar spacing for the regular rings is 11.25 degrees. The first non-cap ring has a start almucantar angle of 5 degrees and an end angle of 11.25 degrees. """ def __init__( self, number_of_shells: int = 1, magnitude_range: Optional[Tuple[float, float]] = None, ): # Define the almucantar angles (phi rings) irregular_phi_values = np.array([0.00, 5.0, 11.25]) ring_increment = 11.25 # Define the patch count patch_count = np.array( [ 1, 4, 15, 24, 32, 39, 45, 49, 51, ] ) super().__init__( patch_count, irregular_phi_values, ring_increment, number_of_shells=number_of_shells, magnitude_range=magnitude_range, )
[docs] class FineTregenzaSphere(TregenzaSphere): """Fine representation of the Tregenza sphere. This sphere is constructed with 54 rings (27 in each hemisphere). It contains a total of 5806 patches. Notes ----- This sphere design is based on the tiling presented by Beckers & Beckers. [#Beckers]_ The almucantar spacing for the regular rings is 3.44 degrees. The first non-cap ring has a starting almucantar angle of 1.50 degrees and an ending angle of 4.00 degrees. """ def __init__( self, number_of_shells: int = 1, magnitude_range: Optional[Tuple[float, float]] = None, ): # Define the almucantar angles (phi rings) irregular_phi_values = np.array([0.00, 1.50, 4.00]) ring_increment = 3.44 # Define the patch count patch_count = np.array( [ 1, 6, 17, 27, 38, 48, 58, 68, 77, 87, 96, 104, 112, 120, 128, 135, 141, 147, 152, 157, 161, 165, 168, 171, 173, 173, 173, ] ) super().__init__( patch_count, irregular_phi_values, ring_increment, number_of_shells=number_of_shells, magnitude_range=magnitude_range, )
[docs] class UltraFineTregenzaSphere(TregenzaSphere): """Ultra-fine representation of the Tregenza sphere. This sphere is constructed with 124 rings (62 in each hemisphere). It contains a total of 36956 patches. Notes ----- This sphere design is based on the tiling presented by Beckers & Beckers. [#Beckers]_ The almucantar spacing for the regular rings is 1.47 degrees. The first non-cap ring has a starting almucantar angle of 0.60 degrees and an ending angle of 1.80 degrees. """ def __init__( self, number_of_shells: int = 1, magnitude_range: Optional[Tuple[float, float]] = None, ): # Define the almucantar angles (phi rings) irregular_phi_values = np.array([0.00, 0.60, 1.80]) ring_increment = 1.47 # Define the patch count patch_count = np.array( [ 1, 8, 21, 33, 45, 57, 69, 81, 93, 105, 117, 128, 140, 152, 163, 175, 186, 197, 208, 219, 230, 240, 251, 261, 271, 281, 291, 300, 309, 319, 327, 336, 345, 353, 361, 369, 376, 384, 391, 397, 404, 410, 416, 422, 427, 432, 437, 442, 446, 450, 454, 457, 460, 463, 466, 468, 470, 471, 472, 473, 474, 474, ] ) super().__init__( patch_count, irregular_phi_values, ring_increment, number_of_shells=number_of_shells, magnitude_range=magnitude_range, )
# def run_tregenza_histogram_pipeline( # vectors: np.ndarray, # sphere: TregenzaSphere, # weight_by_magnitude: bool = False, # is_axial: bool = False, # remove_zero_vectors: bool = True, # correct_area_weighting: bool = True, # ) -> List[np.ndarray]: # """Run the complete histogram construction for the Tregenza sphere. # # Construct a spherical histogram based on a provided Tregenza sphere for # the supplied vectors. # # Parameters # ---------- # vectors # NumPy array containing the 3D vector components in the order # ``(x, y, z)``. This array should have shape ``(n, 3)`` where ``n`` # is the number of vectors. # sphere # Tregenza sphere to use for histogram construction. # weight_by_magnitude # Indicate whether to weight the histogram by 3D magnitude. If # `False`, then the histogram will be weighted by count. # is_axial # Indicate whether the vectors are axial. If `True`, symmetric # vectors will be created based on the dataset. # remove_zero_vectors # Indicate whether to remove zero-vectors. This parameter should be # `True` unless the vector list contains no zero-vectors. # correct_area_weighting # Indicate whether the histogram values should be corrected using the # area weights. # # Returns # ------- # list[numpy.ndarray] # List of histogram counts for each ring. The list has the same # length as the number of rings in the provided Tregenza sphere, # and the length of each list entry corresponds to the respective # patch count. # # """ # # # Perform vector pre-processing # if remove_zero_vectors: # vectors = util.remove_zero_vectors(vectors) # # if is_axial: # vectors = util.convert_vectors_to_axes(vectors) # vectors = util.create_symmetric_vectors_from_axes(vectors) # # angular_coordinates = util.compute_vector_orientation_angles( # vectors, use_degrees=True # ) # # if weight_by_magnitude: # _, magnitudes = util.normalise_vectors(vectors) # else: # magnitudes = None # # histogram = sphere.construct_spherical_histogram( # spherical_coordinates=angular_coordinates, magnitudes=magnitudes # ) # # if correct_area_weighting: # histogram = sphere.correct_histogram_by_area(histogram) # # return histogram # # # class TregenzaSphereDetailLevel(enum.Enum): # """Detail level for Tregenza Sphere.""" # # FINE = "Fine" # """Fine-detail Tregenza sphere, with 27 rings per hemisphere, # represented by :class:`FineTregenzaSphere`.""" # # ULTRA_FINE = "Ultra-fine" # """Ultra-fine-detail Tregenza sphere, with 62 rings per hemisphere, # represented by :class:`UltraFineTregenzaSphere`.""" # # def get_tregenza_class(self) -> Type[TregenzaSphere]: # """Get the class for the specified level of detail. # # Returns # ------- # Type[TregenzaSphere] # Class inheriting from :class:`TregenzaSphere` which # captures the correct level of detail. # # Warnings # -------- # This method returns a **type**, not an object. The returned type # can be used to instantiate a Tregenza sphere object. To instantiate # an object directly, call :meth:`create_tregenza_sphere`. # """ # # if self == TregenzaSphereDetailLevel.FINE: # return FineTregenzaSphere # elif self == TregenzaSphereDetailLevel.ULTRA_FINE: # return UltraFineTregenzaSphere # # def create_tregenza_sphere(self) -> TregenzaSphere: # """Create a Tregenza sphere for the specified detail level. # # Returns # ------- # TregenzaSphere # Object of the correct subclass of :class:`TregenzaSphere` # representing the desired level of detail. # """ # # tregenza_sphere_type = self.get_tregenza_class() # # tregenza_sphere: TregenzaSphere = tregenza_sphere_type() # # return tregenza_sphere