From 9674fd8401c2bfabee928313f596e03e6a7de86f Mon Sep 17 00:00:00 2001 From: yemeen Date: Sun, 12 Jan 2025 21:04:58 -0500 Subject: [PATCH 01/35] Refactor vectorized ECT calculation for efficiency --- ect/ect/ect_graph.py | 49 +++++++++++++++++++-------------------- ect/ect/embed_graph.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 25 deletions(-) diff --git a/ect/ect/ect_graph.py b/ect/ect/ect_graph.py index 897eb58..4aedeef 100644 --- a/ect/ect/ect_graph.py +++ b/ect/ect/ect_graph.py @@ -118,13 +118,13 @@ def get_SECT(self): def calculateECC(self, G, theta, bound_radius=None, return_counts=False): """ Function to compute the Euler Characteristic Curve (ECC) of an `EmbeddedGraph`. - + Parameters: G (nx.Graph): The graph to compute the ECC for. theta (float): The angle (in radians) for the direction function. bound_radius (float, optional): Radius for threshold range. Default is None. return_counts (bool, optional): Whether to return vertex, edge, and face counts. Default is False. - + Returns: numpy.ndarray: ECC values at each threshold. (Optional) Tuple of counts: (ecc, vertex_count, edge_count, face_count) @@ -149,7 +149,8 @@ def calculateECC(self, G, theta, bound_radius=None, return_counts=False): f_list, g_f = G.sort_faces(theta, return_g=True) g_f_list = np.array([g_f[f] for f in f_list]) sorted_g_f_list = np.sort(g_f_list) - face_count = np.searchsorted(sorted_g_f_list, r_threshes, side='right') + face_count = np.searchsorted( + sorted_g_f_list, r_threshes, side='right') else: face_count = np.zeros_like(r_threshes, dtype=np.int32) @@ -160,39 +161,37 @@ def calculateECC(self, G, theta, bound_radius=None, return_counts=False): else: return ecc - def calculateECT(self, graph, bound_radius=None, compute_SECT=True): - """ - Calculates the ECT from an input either `EmbeddedGraph` or `EmbeddedCW`. The entry ``M[i,j]`` is :math:`\\chi(K_{a_j})` for the direction :math:`\omega_i` where :math:`a_j` is the jth entry in ``self.threshes``, and :math:`\omega_i` is the ith entry in ``self.thetas``. + def calculateECT(self, graph, bound_radius=None, compute_SECT=False): + """Vectorized ECT calculation""" + r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) - Parameters: - graph (EmbeddedGraph/EmbeddedCW): - The input graph to calculate the ECT from. - bound_radius (float): - If None, uses the following in order: (i) the bounding radius stored in the class; or if not available (ii) the bounding radius of the given graph. Otherwise, should be a postive float :math:`R` where the ECC will be computed at thresholds in :math:`[-R,R]`. Default is None. - compute_SECT (bool): - Whether to compute the SECT after the ECT is computed. Default is True. Sets the SECT_matrix attribute, but doesn't return it. Can be returned with the get_SECT method. + # get g-values for all directions at once + g_values = graph.g_omega_vectorized(self.thetas) + g_edge_values = graph.g_omega_edges_vectorized(self.thetas) - Returns: - np.array - The matrix representing the ECT of size (num_dirs,num_thresh). - """ + # [num_vertices, num_dirs] + vertex_projs = np.array([g_values[v] for v in graph.nodes()]) + edge_projs = np.array([g_edge_values[e] + for e in graph.edges()]) # [num_edges, num_dirs] - r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) + M = np.zeros((self.num_dirs, self.num_thresh)) - # Note... this overwrites the self.threshes if it's not set. - self.set_bounding_radius(r) + thresholds = r_threshes.reshape(1, -1) # [1, num_thresh] - M = np.zeros((self.num_dirs, self.num_thresh)) + # Count vertices and edges below each threshold for each direction + vertices_below = (vertex_projs[:, :, None] <= thresholds).sum( + axis=0) # [num_dirs, num_thresh] + edges_below = (edge_projs[:, :, None] <= thresholds).sum( + axis=0) # [num_dirs, num_thresh] - for i, theta in enumerate(self.thetas): - M[i] = self.calculateECC(graph, theta, r) + # Compute ECT + M = vertices_below - edges_below self.ECT_matrix = M - if compute_SECT: self.SECT_matrix = self.calculateSECT() - return self.ECT_matrix + return M def calculateSECT(self): """ diff --git a/ect/ect/embed_graph.py b/ect/ect/embed_graph.py index 1768eb3..07f96a7 100644 --- a/ect/ect/embed_graph.py +++ b/ect/ect/embed_graph.py @@ -543,6 +543,58 @@ def rescale_to_unit_disk(self, preserve_center=True, center_type='origin'): return self + def g_omega_vectorized(self, thetas): + """ + Vectorized computation of g-values for multiple directions. + + Args: + thetas: numpy array of angles in radians + + Returns: + Dictionary mapping vertex indices to arrays of g-values + """ + # Get all coordinates as a numpy array + vertices = list(self.nodes()) + coords = np.array([self.coordinates[v] for v in vertices]) + + # Create direction vectors for all angles + directions = np.stack( + [np.cos(thetas), np.sin(thetas)], axis=1) # [num_dirs, 2] + + # Project all points onto all directions at once + projections = np.matmul(coords, directions.T) # [num_points, num_dirs] + + # Create dictionary mapping vertex indices to their projection arrays + g_values = {v: projections[i] for i, v in enumerate(vertices)} + + return g_values + + def g_omega_edges_vectorized(self, thetas): + """ + Vectorized computation of edge g-values for multiple directions. + + Args: + thetas: numpy array of angles in radians + + Returns: + Dictionary mapping edge tuples to arrays of g-values + """ + # Get vertex projections first + vertex_g_values = self.g_omega_vectorized(thetas) + + # Pre-compute edge g-values for all directions at once + edge_g_values = {} + for u, v in self.edges(): + # Get projections for both endpoints + u_proj = vertex_g_values[u] + v_proj = vertex_g_values[v] + + # Maximum projection for each direction + edge_g_values[(u, v)] = np.maximum(u_proj, v_proj) + edge_g_values[(v, u)] = edge_g_values[(u, v)] # symmetric + + return edge_g_values + def create_example_graph(centered=True, center_type='min_max'): """ From ccfca93bbe27ce6acde42de2c1f5941e83e9b5bb Mon Sep 17 00:00:00 2001 From: yemeen Date: Mon, 13 Jan 2025 16:39:57 -0500 Subject: [PATCH 02/35] Add optimized numba parallel threshold computation --- ect/ect/ect_graph.py | 87 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/ect/ect/ect_graph.py b/ect/ect/ect_graph.py index 4aedeef..2954271 100644 --- a/ect/ect/ect_graph.py +++ b/ect/ect/ect_graph.py @@ -4,6 +4,7 @@ import matplotlib.pyplot as plt from ect.embed_cw import EmbeddedCW import time +from numba import jit, prange class ECT: @@ -161,31 +162,83 @@ def calculateECC(self, G, theta, bound_radius=None, return_counts=False): else: return ecc + @staticmethod + @jit(nopython=True, parallel=True) + def fast_threshold_comp(projections, edge_maxes, thresholds): + """Calculate the euler characteristic for each direction in parallel + + Parameters: + projections (np.array): + The projections of the vertices. + edge_maxes (np.array): + The projections of the edges. + thresholds (np.array): + The thresholds to compute the ECT at. + + Returns: + np.array: + The ECT matrix of size (num_dirs, num_thresh). + """ + num_vertices, num_dir = projections.shape + num_edges = edge_maxes.shape[0] + num_thresh = len(thresholds) + result = np.empty((num_dir, num_thresh), dtype=np.int32) + + # parallelize over directions + for i in prange(num_dir): + for j in range(num_thresh): + thresh = thresholds[j] + vert_count = 0 + edge_count = 0 + + # Use SIMD-friendly loops + for v in range(num_vertices): + if projections[v, i] <= thresh: + vert_count += 1 + for e in range(num_edges): + if edge_maxes[e, i] <= thresh: + edge_count += 1 + + result[i, j] = vert_count - edge_count + + return result + def calculateECT(self, graph, bound_radius=None, compute_SECT=False): - """Vectorized ECT calculation""" + """Vectorized ECT calculation using optimized numpy operations + + Parameters: + graph (EmbeddedGraph/EmbeddedCW): + The input graph or CW complex. + bound_radius (float): + If None, uses the following in order: (i) the bounding radius stored in the class; or if not available (ii) the bounding radius of the given graph. Otherwise, should be a postive float :math:`R` where the ECC will be computed at thresholds in :math:`[-R,R]`. Default is None. + compute_SECT (bool): + Whether to compute the SECT. Default is False. + + Returns: + np.array: + The ECT matrix of size (num_dirs, num_thresh). + """ r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) - # get g-values for all directions at once - g_values = graph.g_omega_vectorized(self.thetas) - g_edge_values = graph.g_omega_edges_vectorized(self.thetas) + coords = np.array([graph.coordinates[v] for v in graph.nodes()]) - # [num_vertices, num_dirs] - vertex_projs = np.array([g_values[v] for v in graph.nodes()]) - edge_projs = np.array([g_edge_values[e] - for e in graph.edges()]) # [num_edges, num_dirs] + # create vertex index mapping and convert edges + vertex_to_idx = {v: i for i, v in enumerate(graph.nodes())} + edges = np.array([[vertex_to_idx[u], vertex_to_idx[v]] + for u, v in graph.edges()]) - M = np.zeros((self.num_dirs, self.num_thresh)) + directions = np.empty((self.num_dirs, 2), order='F') + np.stack([np.cos(self.thetas), np.sin(self.thetas)], + axis=1, out=directions) - thresholds = r_threshes.reshape(1, -1) # [1, num_thresh] + projections = np.empty((len(coords), self.num_dirs), order='F') + np.matmul(coords, directions.T, out=projections) - # Count vertices and edges below each threshold for each direction - vertices_below = (vertex_projs[:, :, None] <= thresholds).sum( - axis=0) # [num_dirs, num_thresh] - edges_below = (edge_projs[:, :, None] <= thresholds).sum( - axis=0) # [num_dirs, num_thresh] + edge_maxes = np.maximum( + projections[edges[:, 0]], projections[edges[:, 1]]) - # Compute ECT - M = vertices_below - edges_below + # use numba-optimized threshold computation + M = self.fast_threshold_comp(projections, edge_maxes, r_threshes) self.ECT_matrix = M if compute_SECT: From e513518067ac6150c68a68636975754e1af0cee1 Mon Sep 17 00:00:00 2001 From: yemeen Date: Wed, 15 Jan 2025 18:10:38 -0500 Subject: [PATCH 03/35] Update method names and variables to follow snake_case and remove compute_SECT from main ECT function --- ect/ect/ect_graph.py | 78 +++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 41 deletions(-) diff --git a/ect/ect/ect_graph.py b/ect/ect/ect_graph.py index 2954271..4731e27 100644 --- a/ect/ect/ect_graph.py +++ b/ect/ect/ect_graph.py @@ -1,11 +1,9 @@ -import numpy as np -from itertools import compress, combinations -from numba import jit import matplotlib.pyplot as plt -from ect.embed_cw import EmbeddedCW -import time +import numpy as np from numba import jit, prange +from ect.embed_cw import EmbeddedCW + class ECT: """ @@ -20,9 +18,9 @@ class ECT: The number of thresholds to consider in the matrix. bound_radius (int): Either ``None``, or a positive radius of the bounding circle. - ECT_matrix (np.array): + ect_matrix (np.array): The matrix to store the ECT. - SECT_matrix (np.array): + sect_matrix (np.array): The matrix to store the SECT. """ @@ -83,7 +81,7 @@ def get_radius_and_thresh(self, G, bound_radius): """ # Either use the global radius and the set self.threshes; or use the tight bounding box and calculate # the thresholds from that. - if bound_radius == None: + if bound_radius is None: # First try to get the internally stored bounding radius if self.bound_radius is not None: r = self.bound_radius @@ -104,19 +102,19 @@ def get_radius_and_thresh(self, G, bound_radius): return r, r_threshes - def get_ECT(self): + def get_ect(self): """ Returns the ECT matrix. """ - return self.ECT_matrix + return self.ect_matrix - def get_SECT(self): + def get_sect(self): """ Returns the SECT matrix. """ - return self.SECT_matrix + return self.sect_matrix - def calculateECC(self, G, theta, bound_radius=None, return_counts=False): + def calculate_ecc(self, G, theta, bound_radius=None, return_counts=False): """ Function to compute the Euler Characteristic Curve (ECC) of an `EmbeddedGraph`. @@ -203,7 +201,7 @@ def fast_threshold_comp(projections, edge_maxes, thresholds): return result - def calculateECT(self, graph, bound_radius=None, compute_SECT=False): + def calculate_ect(self, graph, bound_radius=None,): """Vectorized ECT calculation using optimized numpy operations Parameters: @@ -211,8 +209,6 @@ def calculateECT(self, graph, bound_radius=None, compute_SECT=False): The input graph or CW complex. bound_radius (float): If None, uses the following in order: (i) the bounding radius stored in the class; or if not available (ii) the bounding radius of the given graph. Otherwise, should be a postive float :math:`R` where the ECC will be computed at thresholds in :math:`[-R,R]`. Default is None. - compute_SECT (bool): - Whether to compute the SECT. Default is False. Returns: np.array: @@ -238,15 +234,14 @@ def calculateECT(self, graph, bound_radius=None, compute_SECT=False): projections[edges[:, 0]], projections[edges[:, 1]]) # use numba-optimized threshold computation - M = self.fast_threshold_comp(projections, edge_maxes, r_threshes) + ect_matrix = self.fast_threshold_comp( + projections, edge_maxes, r_threshes) - self.ECT_matrix = M - if compute_SECT: - self.SECT_matrix = self.calculateSECT() + self.ect_matrix = ect_matrix - return M + return ect_matrix - def calculateSECT(self): + def calculate_sect(self): """ Function to calculate the Smooth Euler Characteristic Transform (SECT) from the ECT matrix. @@ -265,11 +260,11 @@ def calculateSECT(self): M_normalized = M - A[:, np.newaxis] # Take the cumulative sum of each row to get the SECT - M_SECT = np.cumsum(M_normalized, axis=1) + M_sect = np.cumsum(M_normalized, axis=1) - return M_SECT + return M_sect - def plotECC(self, graph, theta, bound_radius=None, draw_counts=False): + def plot_ecc(self, graph, theta, bound_radius=None, draw_counts=False): """ Function to plot the Euler Characteristic Curve (ECC) for a specific direction theta. Note that this calculates the ECC for the input graph and then plots it. @@ -286,9 +281,9 @@ def plotECC(self, graph, theta, bound_radius=None, draw_counts=False): r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) if not draw_counts: - ECC = self.calculateECC(graph, theta, r) + ECC = self.calculate_ecc(graph, theta, r) else: - ECC, vertex_count, edge_count, face_count = self.calculateECC( + ECC, vertex_count, edge_count, face_count = self.calculate_ecc( graph, theta, r, return_counts=True) # if self.threshes is None: @@ -307,7 +302,7 @@ def plotECC(self, graph, theta, bound_radius=None, draw_counts=False): plt.xlabel('$a$') plt.ylabel(r'$\chi(K_a)$') - def plotECT(self): + def plot_ect(self): """ Function to plot the Euler Characteristic Transform (ECT) matrix. Note that the ECT matrix must be calculated before calling this function. @@ -330,16 +325,17 @@ def plotECT(self): ax = plt.gca() ax.set_xticks(np.linspace(0, 2*np.pi, 9)) - labels = [r'$0$', - r'$\frac{\pi}{4}$', - r'$\frac{\pi}{2}$', - r'$\frac{3\pi}{4}$', - r'$\pi$', - r'$\frac{5\pi}{4}$', - r'$\frac{3\pi}{2}$', - r'$\frac{7\pi}{4}$', - r'$2\pi$', - ] + labels = [ + r'$0$', + r'$\frac{\pi}{4}$', + r'$\frac{\pi}{2}$', + r'$\frac{3\pi}{4}$', + r'$\pi$', + r'$\frac{5\pi}{4}$', + r'$\frac{3\pi}{2}$', + r'$\frac{7\pi}{4}$', + r'$2\pi$', + ] ax.set_xticklabels(labels) @@ -348,7 +344,7 @@ def plotECT(self): plt.title(r'ECT of Input Graph') - def plotSECT(self): + def plot_sect(self): """ Function to plot the Smooth Euler Characteristic Transform (SECT) matrix. Note that the SECT matrix must be calculated before calling this function. @@ -399,8 +395,8 @@ def plot(self, plot_type): """ if plot_type == 'ECT': - self.plotECT() + self.plot_ect() elif plot_type == 'SECT': - self.plotSECT() + self.plot_sect() else: raise ValueError('plot_type must be either "ECT" or "SECT".') From 68a52263b9dae70f52ed2b16953bc363d2f35dcb Mon Sep 17 00:00:00 2001 From: yemeen Date: Tue, 21 Jan 2025 16:13:15 -0500 Subject: [PATCH 04/35] Refactor graph embedding calculations for efficiency --- ect/ect/embed_graph.py | 84 +++++++++++------------------------------- 1 file changed, 22 insertions(+), 62 deletions(-) diff --git a/ect/ect/embed_graph.py b/ect/ect/embed_graph.py index 07f96a7..13d92bc 100644 --- a/ect/ect/embed_graph.py +++ b/ect/ect/embed_graph.py @@ -344,12 +344,20 @@ def g_omega(self, theta): """ - omega = (np.cos(theta), np.sin(theta)) + vertices = list(self.nodes()) + coords = np.array([self.coordinates[v] for v in vertices]) + + # Create direction vectors for all angles + directions = np.stack( + [np.cos(theta), np.sin(theta)], axis=1) # [num_dirs, 2] + + # Project all points onto all directions at once + projections = np.matmul(coords, directions.T) # [num_points, num_dirs] - g = {} - for v in self.nodes: - g[v] = np.dot(self.coordinates[v], omega) - return g + # Create dictionary mapping vertex indices to their projection arrays + g_values = {v: projections[i] for i, v in enumerate(vertices)} + + return g_values def g_omega_edges(self, theta): """ @@ -364,13 +372,17 @@ def g_omega_edges(self, theta): dict A dictionary of the function values of the edges. """ - g = self.g_omega(theta) + vertex_g_values = self.g_omega(theta) + + edge_g_values = {} + for u, v in self.edges(): + u_proj = vertex_g_values[u] + v_proj = vertex_g_values[v] - g_edges = {} - for e in self.edges: - g_edges[e] = max(g[e[0]], g[e[1]]) + edge_g_values[(u, v)] = np.maximum(u_proj, v_proj) + edge_g_values[(v, u)] = edge_g_values[(u, v)] # symmetric - return g_edges + return edge_g_values def sort_vertices(self, theta, return_g=False): """ @@ -543,58 +555,6 @@ def rescale_to_unit_disk(self, preserve_center=True, center_type='origin'): return self - def g_omega_vectorized(self, thetas): - """ - Vectorized computation of g-values for multiple directions. - - Args: - thetas: numpy array of angles in radians - - Returns: - Dictionary mapping vertex indices to arrays of g-values - """ - # Get all coordinates as a numpy array - vertices = list(self.nodes()) - coords = np.array([self.coordinates[v] for v in vertices]) - - # Create direction vectors for all angles - directions = np.stack( - [np.cos(thetas), np.sin(thetas)], axis=1) # [num_dirs, 2] - - # Project all points onto all directions at once - projections = np.matmul(coords, directions.T) # [num_points, num_dirs] - - # Create dictionary mapping vertex indices to their projection arrays - g_values = {v: projections[i] for i, v in enumerate(vertices)} - - return g_values - - def g_omega_edges_vectorized(self, thetas): - """ - Vectorized computation of edge g-values for multiple directions. - - Args: - thetas: numpy array of angles in radians - - Returns: - Dictionary mapping edge tuples to arrays of g-values - """ - # Get vertex projections first - vertex_g_values = self.g_omega_vectorized(thetas) - - # Pre-compute edge g-values for all directions at once - edge_g_values = {} - for u, v in self.edges(): - # Get projections for both endpoints - u_proj = vertex_g_values[u] - v_proj = vertex_g_values[v] - - # Maximum projection for each direction - edge_g_values[(u, v)] = np.maximum(u_proj, v_proj) - edge_g_values[(v, u)] = edge_g_values[(u, v)] # symmetric - - return edge_g_values - def create_example_graph(centered=True, center_type='min_max'): """ From 4277039adf9925a243af505769d3b6e562130c52 Mon Sep 17 00:00:00 2001 From: yemeen Date: Tue, 21 Jan 2025 22:08:07 -0500 Subject: [PATCH 05/35] Add class for managing ECT direction vectors --- ect/ect/directions.py | 103 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 ect/ect/directions.py diff --git a/ect/ect/directions.py b/ect/ect/directions.py new file mode 100644 index 0000000..91f1426 --- /dev/null +++ b/ect/ect/directions.py @@ -0,0 +1,103 @@ +import numpy as np +from typing import Union, Optional, List, Sequence +from enum import Enum + + +class SamplingStrategy(Enum): + UNIFORM = "uniform" # Evenly spaced angles + RANDOM = "random" # Random angles + CUSTOM = "custom" # User-provided angles + + +class Directions: + """ + Manages direction vectors for ECT calculations. + Supports uniform, random, or custom sampling of directions. + + Example: + # Uniform sampling + dirs = Directions(num_dirs=8) + + # Random sampling + dirs = Directions.random(num_dirs=10, seed=42) + + # Custom angles + dirs = Directions.from_angles([0, np.pi/4, np.pi/2]) + + # Custom vectors + dirs = Directions.from_vectors([(1,0), (1,1), (0,1)]) + """ + + def __init__(self, + num_dirs: int = 360, + strategy: SamplingStrategy = SamplingStrategy.UNIFORM, + endpoint: bool = False, + seed: Optional[int] = None): + + self.num_dirs = num_dirs + self.strategy = strategy + self.endpoint = endpoint + + if seed is not None: + np.random.seed(seed) + + self._thetas = None + self._vectors = None + self._initialize_directions() + + def _initialize_directions(self): + """Initialize direction angles based on strategy""" + if self.strategy == SamplingStrategy.UNIFORM: + self._thetas = np.linspace(0, 2*np.pi, + self.num_dirs, + endpoint=self.endpoint) + elif self.strategy == SamplingStrategy.RANDOM: + self._thetas = np.random.uniform(0, 2*np.pi, self.num_dirs) + self._thetas.sort() # Sort for consistency + + @classmethod + def random(cls, num_dirs: int = 360, seed: Optional[int] = None) -> 'Directions': + """Create instance with random direction sampling""" + return cls(num_dirs, SamplingStrategy.RANDOM, seed=seed) + + @classmethod + def from_angles(cls, angles: Sequence[float]) -> 'Directions': + """Create instance from custom angles""" + instance = cls(len(angles), SamplingStrategy.CUSTOM) + instance._thetas = np.array(angles) + return instance + + @classmethod + def from_vectors(cls, vectors: Sequence[tuple]) -> 'Directions': + """Create instance from custom direction vectors""" + vectors = np.array(vectors) + # Normalize vectors + norms = np.linalg.norm(vectors, axis=1, keepdims=True) + normalized = vectors / norms + + instance = cls(len(vectors), SamplingStrategy.CUSTOM) + instance._vectors = normalized + instance._thetas = np.arctan2(normalized[:, 1], normalized[:, 0]) + return instance + + @property + def thetas(self) -> np.ndarray: + """Get angles for all directions""" + return self._thetas + + @property + def vectors(self) -> np.ndarray: + """Get unit vectors for all directions""" + if self._vectors is None: + self._vectors = np.column_stack([ + np.cos(self._thetas), + np.sin(self._thetas) + ]) + return self._vectors + + def __len__(self) -> int: + return self.num_dirs + + def __getitem__(self, idx) -> np.ndarray: + """Get direction vector at index""" + return self.vectors[idx] From 5b6bcad245117ecefe19e10822ed4473ea63a71f Mon Sep 17 00:00:00 2001 From: yemeen Date: Thu, 23 Jan 2025 21:20:31 -0500 Subject: [PATCH 06/35] Start 3d mods to embed --- ect/ect/directions.py | 1 - ect/ect/embed_graph.py | 277 +++++++++++++++-------------------------- 2 files changed, 101 insertions(+), 177 deletions(-) diff --git a/ect/ect/directions.py b/ect/ect/directions.py index 91f1426..1cf07a1 100644 --- a/ect/ect/directions.py +++ b/ect/ect/directions.py @@ -71,7 +71,6 @@ def from_angles(cls, angles: Sequence[float]) -> 'Directions': def from_vectors(cls, vectors: Sequence[tuple]) -> 'Directions': """Create instance from custom direction vectors""" vectors = np.array(vectors) - # Normalize vectors norms = np.linalg.norm(vectors, axis=1, keepdims=True) normalized = vectors / norms diff --git a/ect/ect/embed_graph.py b/ect/ect/embed_graph.py index 13d92bc..ca4a699 100644 --- a/ect/ect/embed_graph.py +++ b/ect/ect/embed_graph.py @@ -191,29 +191,16 @@ def get_bounding_box(self): x_coords, y_coords = zip(*self.coordinates.values()) return [(min(x_coords), max(x_coords)), (min(y_coords), max(y_coords))] - def get_center(self, type='origin'): - """ - Calculate and return the center of the graph. This can be done by either returning the average of the coordiantes (`mean`), the center of the bounding box (`min_max`), or the origin (`origin`). - - Parameters: - type (str): The type of center to calculate. Options are 'mean', 'min_max', or 'origin'. - - Returns: - numpy.ndarray: The (x, y) coordinates of the center. - """ - if not self.coordinates: - return np.array([0.0, 0.0]) - - if type == 'origin': - return np.array([0.0, 0.0]) - elif type == 'mean': - coords = np.array(list(self.coordinates.values())) + def get_center(self, method: str = 'mean') -> np.ndarray: + """Calculate center of coordinates""" + coords = self.get_coords_array() + if method == 'mean': return np.mean(coords, axis=0) - elif type == 'min_max': - x_coords, y_coords = zip(*self.coordinates.values()) - min_x, max_x = min(x_coords), max(x_coords) - min_y, max_y = min(y_coords), max(y_coords) - return np.array([(max_x+min_x)/2, (max_y+min_y)/2]) + elif method == 'min_max': + return (np.max(coords, axis=0) + np.min(coords, axis=0)) / 2 + elif method == 'origin': + return np.zeros(self.dim) + raise ValueError(f"Unknown center method: {method}") def get_bounding_radius(self, type='origin'): """ @@ -329,138 +316,135 @@ def set_PCA_coordinates(self, center_type=None, scale_radius=None): if scale_radius: self.set_scaled_coordinates(radius=scale_radius) - def g_omega(self, theta): - """ - Function to compute the function :math:`g_\omega(v)` for all vertices :math:`v` in the graph in the direction of :math:`\\theta \in [0,2\pi]` . This function is defined by :math:`g_\omega(v) = \langle \\texttt{pos}(v), \omega \\rangle` . + # def g_omega(self, theta): + # """ + # Function to compute the function :math:`g_\omega(v)` for all vertices :math:`v` in the graph in the direction of :math:`\\theta \in [0,2\pi]` . This function is defined by :math:`g_\omega(v) = \langle \\texttt{pos}(v), \omega \\rangle` . - Parameters: + # Parameters: - theta (float): - The angle in :math:`[0,2\pi]` for the direction to compute the :math:`g(v)` values. + # theta (float): + # The angle in :math:`[0,2\pi]` for the direction to compute the :math:`g(v)` values. - Returns: + # Returns: - dict: A dictionary mapping vertices to their :math:`g(v)` values. + # dict: A dictionary mapping vertices to their :math:`g(v)` values. - """ + # """ - vertices = list(self.nodes()) - coords = np.array([self.coordinates[v] for v in vertices]) + # vertices = list(self.nodes()) + # coords = np.array([self.coordinates[v] for v in vertices]) - # Create direction vectors for all angles - directions = np.stack( - [np.cos(theta), np.sin(theta)], axis=1) # [num_dirs, 2] + # directions = np.stack( + # [np.cos(theta), np.sin(theta)], axis=1) # [num_dirs, 2] - # Project all points onto all directions at once - projections = np.matmul(coords, directions.T) # [num_points, num_dirs] + # projections = np.matmul(coords, directions.T) # [num_points, num_dirs] - # Create dictionary mapping vertex indices to their projection arrays - g_values = {v: projections[i] for i, v in enumerate(vertices)} + # g_values = {v: projections[i] for i, v in enumerate(vertices)} - return g_values + # return g_values - def g_omega_edges(self, theta): - """ - Calculates the function value of the edges of the graph by making the value equal to the max vertex value + # def g_omega_edges(self, theta): + # """ + # Calculates the function value of the edges of the graph by making the value equal to the max vertex value - Parameters: + # Parameters: - theta (float): - The direction of the function to be calculated. + # theta (float): + # The direction of the function to be calculated. - Returns: - dict - A dictionary of the function values of the edges. - """ - vertex_g_values = self.g_omega(theta) + # Returns: + # dict + # A dictionary of the function values of the edges. + # """ + # vertex_g_values = self.g_omega(theta) - edge_g_values = {} - for u, v in self.edges(): - u_proj = vertex_g_values[u] - v_proj = vertex_g_values[v] + # edge_g_values = {} + # for u, v in self.edges(): + # u_proj = vertex_g_values[u] + # v_proj = vertex_g_values[v] - edge_g_values[(u, v)] = np.maximum(u_proj, v_proj) - edge_g_values[(v, u)] = edge_g_values[(u, v)] # symmetric + # edge_g_values[(u, v)] = np.maximum(u_proj, v_proj) + # edge_g_values[(v, u)] = edge_g_values[(u, v)] # symmetric - return edge_g_values + # return edge_g_values - def sort_vertices(self, theta, return_g=False): - """ - Function to sort the vertices of the graph according to the function g_omega(v) in the direction of theta \in [0,2*np.pi]. + # def sort_vertices(self, theta, return_g=False): + # """ + # Function to sort the vertices of the graph according to the function g_omega(v) in the direction of theta \in [0,2*np.pi]. - TODO: eventually, do we want this to return a sorted list of g values as well? Since we're already doing the sorting work, it might be helpful. + # TODO: eventually, do we want this to return a sorted list of g values as well? Since we're already doing the sorting work, it might be helpful. - Parameters: - theta (float): - The angle in [0,2*np.pi] for the direction to sort the vertices. - return_g (bool): - Whether to return the g(v) values along with the sorted vertices. + # Parameters: + # theta (float): + # The angle in [0,2*np.pi] for the direction to sort the vertices. + # return_g (bool): + # Whether to return the g(v) values along with the sorted vertices. - Returns: - list - A list of vertices sorted in increasing order of the :math:`g(v)` values. - If ``return_g`` is True, also returns the ``g`` dictionary with the function values ``g[vertex_name]=func_value``. + # Returns: + # list + # A list of vertices sorted in increasing order of the :math:`g(v)` values. + # If ``return_g`` is True, also returns the ``g`` dictionary with the function values ``g[vertex_name]=func_value``. - """ - g = self.g_omega(theta) + # """ + # g = self.g_omega(theta) - v_list = sorted(self.nodes, key=lambda v: g[v]) + # v_list = sorted(self.nodes, key=lambda v: g[v]) - if return_g: - # g_sorted = [g[v] for v in v_list] - return v_list, g - else: - return v_list + # if return_g: + # # g_sorted = [g[v] for v in v_list] + # return v_list, g + # else: + # return v_list - def sort_edges(self, theta, return_g=False): - """ - Function to sort the edges of the graph according to the function + # def sort_edges(self, theta, return_g=False): + # """ + # Function to sort the edges of the graph according to the function - .. math :: + # .. math :: - g_\omega(e) = \max \{ g_\omega(v) \mid v \in e \} + # g_\omega(e) = \max \{ g_\omega(v) \mid v \in e \} - in the direction of :math:`\\theta \in [0,2\pi]` . + # in the direction of :math:`\\theta \in [0,2\pi]` . - Parameters: - theta (float): - The angle in :math:`[0,2\pi]` for the direction to sort the edges. - return_g (bool): - Whether to return the :math:`g(v)` values along with the sorted edges. + # Parameters: + # theta (float): + # The angle in :math:`[0,2\pi]` for the direction to sort the edges. + # return_g (bool): + # Whether to return the :math:`g(v)` values along with the sorted edges. - Returns: - A list of edges sorted in increasing order of the :math:`g(v)` values. - If ``return_g`` is True, also returns the ``g`` dictionary with the function values ``g[vertex_name]=func_value``. + # Returns: + # A list of edges sorted in increasing order of the :math:`g(v)` values. + # If ``return_g`` is True, also returns the ``g`` dictionary with the function values ``g[vertex_name]=func_value``. - """ - g_e = self.g_omega_edges(theta) + # """ + # g_e = self.g_omega_edges(theta) - e_list = sorted(self.edges, key=lambda e: g_e[e]) + # e_list = sorted(self.edges, key=lambda e: g_e[e]) - if return_g: - # g_sorted = [g[v] for v in v_list] - return e_list, g_e - else: - return e_list + # if return_g: + # # g_sorted = [g[v] for v in v_list] + # return e_list, g_e + # else: + # return e_list - def lower_edges(self, v, omega): - """ - Function to compute the number of lower edges of a vertex v for a specific direction (included by the use of sorted v_list). + # def lower_edges(self, v, omega): + # """ + # Function to compute the number of lower edges of a vertex v for a specific direction (included by the use of sorted v_list). - Parameters: - v (str): - The vertex to compute the number of lower edges for. - omega (tuple): - The direction vector to consider given as an angle in [0, 2pi]. + # Parameters: + # v (str): + # The vertex to compute the number of lower edges for. + # omega (tuple): + # The direction vector to consider given as an angle in [0, 2pi]. - Returns: - int: The number of lower edges of the vertex v. + # Returns: + # int: The number of lower edges of the vertex v. - """ - L = [n for n in self.neighbors(v)] - gv = np.dot(self.coordinates[v], omega) - Lg = [np.dot(self.coordinates[v], omega) for v in L] - return sum(n >= gv for n in Lg) # includes possible duplicate counts + # """ + # L = [n for n in self.neighbors(v)] + # gv = np.dot(self.coordinates[v], omega) + # Lg = [np.dot(self.coordinates[v], omega) for v in L] + # return sum(n >= gv for n in Lg) # includes possible duplicate counts def plot(self, bounding_circle=False, @@ -554,62 +538,3 @@ def rescale_to_unit_disk(self, preserve_center=True, center_type='origin'): self.coordinates[vertex] = tuple(new_coord) return self - - -def create_example_graph(centered=True, center_type='min_max'): - """ - Function to create an example ``EmbeddedGraph`` object. Helpful for testing. If ``centered`` is True, the coordinates are centered using the center type given by ``center_type``, either ``mean`` or ``min_max``. - - Returns: - EmbeddedGraph: An example ``EmbeddedGraph`` object. - - """ - graph = EmbeddedGraph() - - graph.add_node('A', 1, 2) - graph.add_node('B', 3, 4) - graph.add_node('C', 5, 7) - graph.add_node('D', 3, 6) - graph.add_node('E', 4, 3) - graph.add_node('F', 4, 5) - - graph.add_edge('A', 'B') - graph.add_edge('B', 'C') - graph.add_edge('B', 'D') - graph.add_edge('B', 'E') - graph.add_edge('C', 'D') - graph.add_edge('E', 'F') - - if centered: - graph.set_centered_coordinates(center_type) - - return graph - - -if __name__ == "__main__": - # Example usage of the EmbeddedGraph class - - # Create an instance of the EmbeddedGraph class - graph = EmbeddedGraph() - - # Add vertices with their coordinates - graph.add_node('A', 1, 2) - graph.add_node('B', 3, 4) - graph.add_node('C', 5, 6) - - # Add edges between vertices - graph.add_edge('A', 'B') - graph.add_edge('B', 'C') - - # Get coordinates of a vertex - coords = graph.get_coordinates('A') - print(f'Coordinates of A: {coords}') - - # Set new coordinates for a vertex - graph.set_coordinates('A', 7, 8) - coords = graph.get_coordinates('A') - print(f'New coordinates of A: {coords}') - - # Get the bounding box of the vertex coordinates - bbox = graph.get_bounding_box() - print(f'Bounding box: {bbox}') From 01e419a39386941ec1a539bf5f6239f0c38a2859 Mon Sep 17 00:00:00 2001 From: yemeen Date: Mon, 27 Jan 2025 19:07:00 -0500 Subject: [PATCH 07/35] Refactor EmbeddedGraph class for better structure --- ect/ect/embed_graph.py | 686 ++++++++++++----------------------------- 1 file changed, 191 insertions(+), 495 deletions(-) diff --git a/ect/ect/embed_graph.py b/ect/ect/embed_graph.py index ca4a699..c4d464f 100644 --- a/ect/ect/embed_graph.py +++ b/ect/ect/embed_graph.py @@ -1,109 +1,195 @@ import networkx as nx import numpy as np import matplotlib.pyplot as plt -from sklearn.decomposition import PCA # for PCA for normalization +from sklearn.decomposition import PCA +from utils import next_vert_name class EmbeddedGraph(nx.Graph): - """ - A class to represent a graph with 2D embedded coordinates for each vertex. - - Attributes - graph : nx.Graph - a NetworkX graph object - coordinates : dict - a dictionary mapping vertices to their (x, y) coordinates - - """ - def __init__(self): - """ - Initializes an empty EmbeddedGraph object. - - """ super().__init__() - self.coordinates = {} + self._node_order = [] + self._node_to_index = {} + self._coord_matrix = np.empty((0, 0)) + self.dim = 0 + + # ====================================== + # Core Node Management + # ====================================== + def add_node(self, name, coordinates): + if name in self._node_to_index: + raise ValueError(f"Node {name} already exists.") + + coords = np.array(coordinates, dtype=float) + if coords.ndim != 1: + raise ValueError("Coordinates must be a 1D array.") + + if len(self._node_order) == 0: + self.dim = coords.size + self._coord_matrix = np.empty((0, self.dim)) + + if coords.size != self.dim: + raise ValueError(f"Coordinates must have dimension {self.dim}.") + + self._node_order.append(name) + self._node_to_index[name] = len(self._node_order) - 1 + self._coord_matrix = np.vstack([self._coord_matrix, coords]) + super().add_node(name) + + def add_nodes_from(self, nodes_with_coords): + for name, coords in nodes_with_coords: + self.add_node(name, coords) + + # ====================================== + # Coordinate Access + # ====================================== + + @property + def coord_matrix(self): + """Return the N x D coordinate matrix.""" + return self._coord_matrix + + def get_coordinates(self, node_id): + """Return the coordinates of a node""" + return self._coord_matrix[self._node_to_index[node_id]].copy() + + def set_coordinates(self, node_id, new_coords): + """Set the coordinates of a node""" + if node_id not in self._node_to_index: + raise ValueError(f"Node {node_id} does not exist.") + if new_coords.shape != (self.dim,): + raise ValueError(f"Coordinates must be {self.dim}-dimensional") + + idx = self._node_to_index[node_id] + self._coord_matrix[idx] = new_coords + + @property + def node_names(self): + """Return ordered list of node names.""" + return self._node_order + + # ====================================== + # Graph Operations + # ====================================== - def add_node(self, vertex, x, y): - """Add a vertex to the graph. - If the vertex name is given as None, it will be assigned via the next_vert_name method. + def add_cycle(self, coord_matrix): + """Add nodes in a cyclic pattern from coordinate matrix""" + n = coord_matrix.shape[0] + new_names = self.next_vert_name( + self._node_order[-1] if self._node_order else 0, n) + self.add_nodes_from(zip(new_names, coord_matrix)) + self.add_edges_from([(new_names[i], new_names[(i+1) % n]) + for i in range(n)]) + + # ====================================== + # Geometric Calculations + # ====================================== - Parameters: - vertex (hashable like int or str, or None) : The name of the vertex to add. - x, y (floats) : The function value of the vertex being added. - reset_pos (bool, optional) - If True, will reset the positions of the nodes based on the function values. - """ - if vertex in self.nodes: - raise ValueError( - f'The vertex name {vertex} is already used in the graph.') + def get_center(self, method: str = 'mean') -> np.ndarray: + """Calculate center of coordinates""" - if vertex is None: - if len(self.nodes) == 0: - vertex = 0 - else: - vertex = self.next_vert_name(max(self.nodes)) + coords = self.get_coords_array() + if method == 'mean': + return np.mean(coords, axis=0) + elif method == 'min_max': + return (np.max(coords, axis=0) + np.min(coords, axis=0)) / 2 + elif method == 'origin': + return np.zeros(self.dim) + raise ValueError(f"Unknown center method: {method}") - super().add_node(vertex) - self.coordinates[vertex] = (x, y) + def get_bounding_box(self): + """Get (min, max) for each dimension""" + return [(dim.min(), dim.max()) for dim in self._coord_matrix.T] - def add_nodes_from(self, nodes, coordinates): - """ - Adds multiple vertices to the graph and assigns them the given coordinates. + def get_bounding_radius(self, center_type: str = 'mean') -> float: + """Get radius of minimal bounding sphere""" + center = self.get_center(center_type) + coords = self.get_coords_array() + return np.max(np.linalg.norm(coords - center, axis=1)) - Parameters: - nodes (list): - A list of vertices to be added. - coordinates (dict): - A dictionary mapping vertices to their coordinates. + def get_critical_angles(self, edges_only=False, decimals=6): + """Get angles where edge order changes (2D only)""" + if self.dim != 2: + raise ValueError("Angle calculations require 2D coordinates") - """ - super().add_nodes_from(nodes) - self.coordinates.update(coordinates) + angles = {} + coords = self._coord_matrix - def next_vert_name(self, s, num_verts=1): - """ - Making a simple name generator for vertices. - If you're using integers, it will just up the count by one. - Letters will be incremented in the alphabet. If you reach 'Z', it will return 'AA'. If you reach 'ZZ', it will return 'AAA', etc. + # vectorized angle calculation + vecs = coords[:, None, :] - coords[None, :, :] + norms = np.linalg.norm(vecs, axis=2) + valid = ~np.isclose(norms, 0) - Parameters: - s (str or int): The name of the vertex to increment. + # Calculate angles in bulk + with np.errstate(divide='ignore', invalid='ignore'): + angles_rad = np.arctan2(vecs[:, :, 0], -vecs[:, :, 1]) % (2*np.pi) + angles_rad[~valid] = np.nan - Returns: - str or int - The next name in the sequence. - """ + # Process pairs + for i in range(coords.shape[0]): + for j in range(i+1, coords.shape[0]): + if edges_only and not self.has_edge(self._node_order[i], self._node_order[j]): + continue - if type(s) == int: - if num_verts > 1: - return [s+1+i for i in range(num_verts)] - else: - return s+1 - elif type(s) == str and len(s) == 1: - if not s == 'Z': - if num_verts > 1: - return [chr(ord(s)+1+i) for i in range(num_verts)] - else: - return chr(ord(s)+1) - else: - if num_verts > 1: - return [chr(ord('AA')+1+i) for i in range(num_verts)] - else: - return 'AA' - elif type(s) == str and len(s) > 1: - if s[-1] == 'Z': - if num_verts > 1: - return [s[:-1] + chr(ord('A')+1+i) for i in range(num_verts)] - else: - return (len(s)+1) * 'A' - else: - if num_verts > 1: - return [s[:-1] + chr(ord(s[-1])+1+i) for i in range(num_verts)] + angle = np.round(angles_rad[i, j], decimals) + pair = (self._node_order[i], self._node_order[j]) + + if angle in angles: + angles[angle].append(pair) else: - return len(s) * chr(ord(s[-1])+1+1) + angles[angle] = [pair] + + return angles + + # ============================ + # Coordinate transformations + # ============================ + + def center_coordinates(self, center_type="mean"): + if center_type == "mean": + center = self._coord_matrix.mean(axis=0) + elif center_type == "min_max": + center = (self._coord_matrix.max(axis=0) + + self._coord_matrix.min(axis=0)) / 2 + elif center_type == "origin": + center = np.zeros(self.dim) else: - raise ValueError('Input must be a string or an integer') + raise ValueError(f"Unknown center method: {center_type}") + self._coord_matrix -= center + + def scale_coordinates(self, radius=1): + """Scale coordinates to fit within given radius""" + current_max = np.linalg.norm(self._coord_matrix, axis=1).max() + if current_max > 0: + self._coord_matrix *= (radius / current_max) + + def apply_pca(self, target_dim=2): + """Dimensionality reduction using PCA""" + if self.dim <= target_dim: + return + + pca = PCA(n_components=target_dim) + self._coord_matrix = pca.fit_transform(self._coord_matrix) + self.dim = target_dim + + def set_centered_coordinates(self, method: str = 'mean'): + """Center coordinates using specified method""" + center = self.get_center(method) + for v in self.nodes(): + self.nodes[v]['coords'] = self.nodes[v]['coords'] - center + + def add_cycle(self, coords: np.ndarray): + """Add nodes in a cycle from coordinates""" + n = len(coords) + node_ids = [str(i) for i in range(n)] + + # Add nodes + for node_id, coord in zip(node_ids, coords): + self.add_node(node_id, coords=coord) + + # Add edges + edges = [(node_ids[i], node_ids[(i+1) % n]) for i in range(n)] + self.add_edges_from(edges) def add_edge(self, u, v): """ @@ -121,420 +207,30 @@ def add_edge(self, u, v): else: super().add_edge(u, v) - def add_cycle(self, coord_matrix): - """ - Add nodes and edges from a cycle of coordinates. - Specifically, will add a node for each row and the edges connecting the nodes in the order they appear in the matrix as a closed cycle. - - Parameters: - coord_matrix : numpy array - An (n x 2) matrix of coordinates. - """ - n = len(coord_matrix) - if len(self.nodes) == 0: - last_name = 0 - else: - last_name = max(self.nodes) - - nodes = self.next_vert_name(last_name, num_verts=n) - coords = {nodes[i]: coord_matrix[i] for i in range(n)} - self.add_nodes_from(nodes, coords) - edges = [(nodes[i], nodes[(i+1) % n]) for i in range(n)] - self.add_edges_from(edges) - - def get_coordinates(self, vertex): - """ - Returns the coordinates of the given vertex. - - Parameters: - vertex (str): - The vertex whose coordinates are to be returned. - - Returns: - tuple: The coordinates of the vertex. - - """ - return self.coordinates.get(vertex) - - def set_coordinates(self, vertex, x, y): - """ - Sets the coordinates of the given vertex. - - Parameters: - vertex (str): - The vertex whose coordinates are to be set. - x (float): - The new x-coordinate of the vertex. - y (float): - The new y-coordinate of the vertex. - - Raises: - ValueError: If the vertex does not exist in the graph. - - """ - if vertex in self.coordinates: - self.coordinates[vertex] = (x, y) - else: - raise ValueError("Vertex does not exist in the graph.") - def get_bounding_box(self): - """ - Method to find a bounding box of the vertex coordinates in the graph. - - Returns: - list: A list of tuples representing the minimum and maximum x and y coordinates. - - """ - if not self.coordinates: - return None - - x_coords, y_coords = zip(*self.coordinates.values()) - return [(min(x_coords), max(x_coords)), (min(y_coords), max(y_coords))] - - def get_center(self, method: str = 'mean') -> np.ndarray: - """Calculate center of coordinates""" - coords = self.get_coords_array() - if method == 'mean': - return np.mean(coords, axis=0) - elif method == 'min_max': - return (np.max(coords, axis=0) + np.min(coords, axis=0)) / 2 - elif method == 'origin': - return np.zeros(self.dim) - raise ValueError(f"Unknown center method: {method}") - - def get_bounding_radius(self, type='origin'): - """ - Method to find the radius of the bounding circle of the vertex coordinates in the graph. - - Parameters: - type (str): The type of center to calculate the radius relative to. Options are 'mean', 'min_max', or 'origin'. - - Returns: - float: The radius of the bounding circle. - - """ - if not self.coordinates: - return 0 - - center = self.get_center(type) - coords = np.array(list(self.coordinates.values())) - distances = np.linalg.norm(coords - center, axis=1) - return np.max(distances) - - # ------ - # Methods for normalizing the coordinates in various ways - # ------ - - def get_centered_coordinates(self, type='min_max'): - """ - Method to find the centered coordinates of the vertices in the graph. - - If type is 'min_max', the coordinates are centered at the mean of the min and max values of the x and y coordinates. - If type is 'mean', the coordinates are centered at the mean of the x and y coordinates. - """ - - if not self.coordinates: - return None - - center = self.get_center(type) - return {v: (x - center[0], y - center[1]) for v, (x, y) in self.coordinates.items()} - - def set_centered_coordinates(self, type='min_max'): - """ - Method to set the centered coordinates of the vertices in the graph. Warning: This overwrites the original coordinates. - """ - - self.coordinates = self.get_centered_coordinates(type=type) - - def get_scaled_coordinates(self, radius=1): - """ - Method to find the scaled coordinates of the vertices in the graph to fit in the disk centered at 0 with radius given by `radius`. - - Parameters: - radius (float): - The radius of the bounding disk. - - Returns: - dict: A dictionary mapping vertices to their scaled coordinates. - - """ - if not self.coordinates: - return None - - x_coords, y_coords = zip(*self.coordinates.values()) - max_norm = max(np.linalg.norm(point) - for point in zip(x_coords, y_coords)) - x_coords = x_coords * radius / max_norm - y_coords = y_coords * radius / max_norm - - return {v: (x, y) for v, x, y in zip(self.coordinates.keys(), x_coords, y_coords)} - - def set_scaled_coordinates(self, radius=1): - """ - Method to set the scaled coordinates of the vertices in the graph to fit in the disk centered at 0 with radius given by `radius`. Warning: This overwrites the original coordinates - - """ - - self.coordinates = self.get_scaled_coordinates(radius) - - def get_PCA_coordinates(self): - """ - Method to find the PCA coordinates of the vertices in the graph. - - Returns: - dict: A dictionary mapping vertices to their PCA normalized coordinates. - - """ - - if not self.coordinates: + if self._coord_matrix.size == 0: return None - x_coords, y_coords = zip(*self.coordinates.values()) - M = np.array((x_coords, y_coords)).T - - pca = PCA(n_components=2) # initiate PCA - pca.fit_transform(M) # fit PCA to coordinates to find longest axis - pca_scores = pca.transform(M) # retrieve PCA coordinates - - nodes = list(self.coordinates.keys()) - n = len(nodes) - out = {nodes[i]: pca_scores[i] for i in range(n)} - - return out - - def set_PCA_coordinates(self, center_type=None, scale_radius=None): - """ - Method to set the PCA coordinates of the vertices in the graph which is helpful for coarse alignment. - If you also want to center at zero, the options for `center_type` are `mean` or `min_max`. - Set `scale_radius` to a value to scale to a specific radius. - Warning: This overwrites the original coordinates - """ - self.coordinates = self.get_PCA_coordinates() - - if center_type: - self.set_centered_coordinates(center_type) - - if scale_radius: - self.set_scaled_coordinates(radius=scale_radius) - - # def g_omega(self, theta): - # """ - # Function to compute the function :math:`g_\omega(v)` for all vertices :math:`v` in the graph in the direction of :math:`\\theta \in [0,2\pi]` . This function is defined by :math:`g_\omega(v) = \langle \\texttt{pos}(v), \omega \\rangle` . - - # Parameters: - - # theta (float): - # The angle in :math:`[0,2\pi]` for the direction to compute the :math:`g(v)` values. - - # Returns: - - # dict: A dictionary mapping vertices to their :math:`g(v)` values. - - # """ - - # vertices = list(self.nodes()) - # coords = np.array([self.coordinates[v] for v in vertices]) - - # directions = np.stack( - # [np.cos(theta), np.sin(theta)], axis=1) # [num_dirs, 2] - - # projections = np.matmul(coords, directions.T) # [num_points, num_dirs] - - # g_values = {v: projections[i] for i, v in enumerate(vertices)} - - # return g_values - - # def g_omega_edges(self, theta): - # """ - # Calculates the function value of the edges of the graph by making the value equal to the max vertex value - - # Parameters: - - # theta (float): - # The direction of the function to be calculated. - - # Returns: - # dict - # A dictionary of the function values of the edges. - # """ - # vertex_g_values = self.g_omega(theta) - - # edge_g_values = {} - # for u, v in self.edges(): - # u_proj = vertex_g_values[u] - # v_proj = vertex_g_values[v] - - # edge_g_values[(u, v)] = np.maximum(u_proj, v_proj) - # edge_g_values[(v, u)] = edge_g_values[(u, v)] # symmetric - - # return edge_g_values + return [(dim_min, dim_max) for dim_min, dim_max in zip( + self._coord_matrix.min(axis=0), + self._coord_matrix.max(axis=0) + )] + + # =================== + # Visualization + # =================== + def plot(self, projection=None, ax=None, **kwargs): + """2D visualization with optional PCA projection""" + if self.dim != 2: + if projection is None: + raise ValueError( + "Require 2D coordinates or specify projection") + self.apply_pca(target_dim=2) - # def sort_vertices(self, theta, return_g=False): - # """ - # Function to sort the vertices of the graph according to the function g_omega(v) in the direction of theta \in [0,2*np.pi]. - - # TODO: eventually, do we want this to return a sorted list of g values as well? Since we're already doing the sorting work, it might be helpful. - - # Parameters: - # theta (float): - # The angle in [0,2*np.pi] for the direction to sort the vertices. - # return_g (bool): - # Whether to return the g(v) values along with the sorted vertices. - - # Returns: - # list - # A list of vertices sorted in increasing order of the :math:`g(v)` values. - # If ``return_g`` is True, also returns the ``g`` dictionary with the function values ``g[vertex_name]=func_value``. - - # """ - # g = self.g_omega(theta) - - # v_list = sorted(self.nodes, key=lambda v: g[v]) - - # if return_g: - # # g_sorted = [g[v] for v in v_list] - # return v_list, g - # else: - # return v_list - - # def sort_edges(self, theta, return_g=False): - # """ - # Function to sort the edges of the graph according to the function - - # .. math :: - - # g_\omega(e) = \max \{ g_\omega(v) \mid v \in e \} - - # in the direction of :math:`\\theta \in [0,2\pi]` . - - # Parameters: - # theta (float): - # The angle in :math:`[0,2\pi]` for the direction to sort the edges. - # return_g (bool): - # Whether to return the :math:`g(v)` values along with the sorted edges. - - # Returns: - # A list of edges sorted in increasing order of the :math:`g(v)` values. - # If ``return_g`` is True, also returns the ``g`` dictionary with the function values ``g[vertex_name]=func_value``. - - # """ - # g_e = self.g_omega_edges(theta) - - # e_list = sorted(self.edges, key=lambda e: g_e[e]) - - # if return_g: - # # g_sorted = [g[v] for v in v_list] - # return e_list, g_e - # else: - # return e_list - - # def lower_edges(self, v, omega): - # """ - # Function to compute the number of lower edges of a vertex v for a specific direction (included by the use of sorted v_list). - - # Parameters: - # v (str): - # The vertex to compute the number of lower edges for. - # omega (tuple): - # The direction vector to consider given as an angle in [0, 2pi]. - - # Returns: - # int: The number of lower edges of the vertex v. - - # """ - # L = [n for n in self.neighbors(v)] - # gv = np.dot(self.coordinates[v], omega) - # Lg = [np.dot(self.coordinates[v], omega) for v in L] - # return sum(n >= gv for n in Lg) # includes possible duplicate counts - - def plot(self, - bounding_circle=False, - bounding_center_type='origin', - color_nodes_theta=None, - ax=None, - with_labels=True, - **kwargs): - """ - Function to plot the graph with the embedded coordinates. - - If ``bounding_circle`` is True, a bounding circle is drawn around the graph. This is centered at the center type defined by ``bounding_center_type``. - - If ``color_nodes_theta`` is not None, it should be given as a theta in :math:`[0,2\pi]`. Then the nodes are colored according to the :math:`g(v)` values in the direction of theta. - - If with_labels is True, the nodes are labeled with their names. - - """ if ax is None: fig, ax = plt.subplots() - else: - fig = ax.get_figure() - - pos = self.coordinates - # center = self.get_center(type = 'min_max') - # r = self.get_bounding_radius(type = 'min_max') - - if color_nodes_theta is None: - nx.draw(self, pos, with_labels=with_labels, ax=ax, **kwargs) - else: - g = self.g_omega(color_nodes_theta) - color_map = [g[v] for v in self.nodes] - # Some weird plotting to make the colorbar work. - pathcollection = nx.draw_networkx_nodes( - self, pos, node_color=color_map, ax=ax) - nx.draw_networkx_labels(self, pos=pos, font_color='black', ax=ax) - nx.draw_networkx_edges(self, pos, ax=ax, width=1, **kwargs) - fig.colorbar(pathcollection, ax=ax, **kwargs) - - plt.axis('on') - ax.tick_params(left=True, bottom=True, - labelleft=True, labelbottom=True) - - if bounding_circle: - circle_center = self.get_center(bounding_center_type) - r = self.get_bounding_radius(type=bounding_center_type) - circle = plt.Circle(circle_center, r, fill=False, - linestyle='--', color='r') - ax.add_patch(circle) - - # Always adjust the plot limits to show the full graph - ax.set_xlim(circle_center[0] - r, circle_center[0] + r) - ax.set_ylim(circle_center[1] - r, circle_center[1] + r) - ax.set_aspect('equal', 'box') + pos = {n: self._coord_matrix[i] + for i, n in enumerate(self._node_order)} + nx.draw(self, pos, ax=ax, **kwargs) + ax.set_aspect('equal') return ax - - def rescale_to_unit_disk(self, preserve_center=True, center_type='origin'): - """ - Rescales the graph coordinates to fit within a radius 1 disk. - - Parameters: - preserve_center (bool): If True, maintains the current center point of type ``center_type``. - If False, centers the graph at (0, 0). - - Returns: - self: Returns the instance for method chaining. - - Raises: - ValueError: If the graph has no coordinates or all coordinates are identical. - """ - if not self.coordinates: - raise ValueError("Graph has no coordinates to rescale.") - - center = self.get_center(center_type) - coords = np.array(list(self.coordinates.values())) - - coords_centered = coords - center - - max_distance = np.max(np.linalg.norm(coords_centered, axis=1)) - - if np.isclose(max_distance, 0): - raise ValueError("All coordinates are identical. Cannot rescale.") - - scale_factor = 1 / max_distance - - new_coords = (coords_centered * scale_factor) + \ - (center if preserve_center else 0) - - for vertex, new_coord in zip(self.coordinates.keys(), new_coords): - self.coordinates[vertex] = tuple(new_coord) - - return self From 7bed36cd160cadd145d1df03dbeed3f3724fcf4f Mon Sep 17 00:00:00 2001 From: yemeen Date: Wed, 29 Jan 2025 18:53:28 -0500 Subject: [PATCH 08/35] Add EmbeddedGraph class with coordinate transformation methods --- ect/ect/embed_graph.py | 103 ++++++++++++++++++++++++++--------------- ect/ect/utils/utils.py | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 37 deletions(-) create mode 100644 ect/ect/utils/utils.py diff --git a/ect/ect/embed_graph.py b/ect/ect/embed_graph.py index c4d464f..614326a 100644 --- a/ect/ect/embed_graph.py +++ b/ect/ect/embed_graph.py @@ -5,10 +5,31 @@ from utils import next_vert_name +CENTER_TYPES = ["mean", "min_max"] +TRANSFORM_TYPES = ["pca"] + + class EmbeddedGraph(nx.Graph): + """ + A class to represent a graph with embedded coordinates for each vertex. + + Attributes + graph : nx.Graph + a NetworkX graph object + coord_matrix : np.ndarray + a matrix of embedded coordinates for each vertex + node_list : list + a list of node names + node_to_index : dict + a dictionary mapping node ids to their index in the coord_matrix + dim : int + the dimension of the embedded coordinates + + """ + def __init__(self): super().__init__() - self._node_order = [] + self._node_list = [] self._node_to_index = {} self._coord_matrix = np.empty((0, 0)) self.dim = 0 @@ -16,29 +37,36 @@ def __init__(self): # ====================================== # Core Node Management # ====================================== - def add_node(self, name, coordinates): - if name in self._node_to_index: - raise ValueError(f"Node {name} already exists.") + def add_node(self, node_id, coordinates): + """Add a vertex to the graph. + If the vertex name is given as None, it will be assigned via the next_vert_name method. + + Parameters: + node_id (hashable like int or str, or None) : The name of the vertex to add. + coordinates (array-like) : The coordinates of the vertex being added. + """ + if node_id in self._node_to_index: + raise ValueError(f"Node {node_id} already exists.") coords = np.array(coordinates, dtype=float) if coords.ndim != 1: raise ValueError("Coordinates must be a 1D array.") - if len(self._node_order) == 0: + if len(self._node_list) == 0: self.dim = coords.size self._coord_matrix = np.empty((0, self.dim)) if coords.size != self.dim: raise ValueError(f"Coordinates must have dimension {self.dim}.") - self._node_order.append(name) - self._node_to_index[name] = len(self._node_order) - 1 + self._node_list.append(node_id) + self._node_to_index[node_id] = len(self._node_list) - 1 self._coord_matrix = np.vstack([self._coord_matrix, coords]) - super().add_node(name) + super().add_node(node_id) - def add_nodes_from(self, nodes_with_coords): - for name, coords in nodes_with_coords: - self.add_node(name, coords) + def add_nodes_from(self, coords): + for node_id, coords in nodes_with_coords: + self.add_node(node_id, coords) # ====================================== # Coordinate Access @@ -64,9 +92,9 @@ def set_coordinates(self, node_id, new_coords): self._coord_matrix[idx] = new_coords @property - def node_names(self): + def node_list(self): """Return ordered list of node names.""" - return self._node_order + return self._node_list # ====================================== # Graph Operations @@ -76,7 +104,7 @@ def add_cycle(self, coord_matrix): """Add nodes in a cyclic pattern from coordinate matrix""" n = coord_matrix.shape[0] new_names = self.next_vert_name( - self._node_order[-1] if self._node_order else 0, n) + self._node_list[-1] if self._node_list else 0, n) self.add_nodes_from(zip(new_names, coord_matrix)) self.add_edges_from([(new_names[i], new_names[(i+1) % n]) for i in range(n)]) @@ -107,7 +135,7 @@ def get_bounding_radius(self, center_type: str = 'mean') -> float: coords = self.get_coords_array() return np.max(np.linalg.norm(coords - center, axis=1)) - def get_critical_angles(self, edges_only=False, decimals=6): + def get_normal_angles(self, edges_only=False, decimals=6): """Get angles where edge order changes (2D only)""" if self.dim != 2: raise ValueError("Angle calculations require 2D coordinates") @@ -120,19 +148,17 @@ def get_critical_angles(self, edges_only=False, decimals=6): norms = np.linalg.norm(vecs, axis=2) valid = ~np.isclose(norms, 0) - # Calculate angles in bulk with np.errstate(divide='ignore', invalid='ignore'): angles_rad = np.arctan2(vecs[:, :, 0], -vecs[:, :, 1]) % (2*np.pi) angles_rad[~valid] = np.nan - # Process pairs for i in range(coords.shape[0]): for j in range(i+1, coords.shape[0]): - if edges_only and not self.has_edge(self._node_order[i], self._node_order[j]): + if edges_only and not self.has_edge(self._node_list[i], self._node_list[j]): continue angle = np.round(angles_rad[i, j], decimals) - pair = (self._node_order[i], self._node_order[j]) + pair = (self._node_list[i], self._node_list[j]) if angle in angles: angles[angle].append(pair) @@ -145,16 +171,18 @@ def get_critical_angles(self, edges_only=False, decimals=6): # Coordinate transformations # ============================ + def transform_coordinates(self, center_type=None, transform_type=None): + """Transform coordinates center and orientation""" + if center_type is not None: + self.center_coordinates(center_type) + if transform_type is not None: + self.transform_coordinates(transform_type) + def center_coordinates(self, center_type="mean"): - if center_type == "mean": - center = self._coord_matrix.mean(axis=0) - elif center_type == "min_max": - center = (self._coord_matrix.max(axis=0) + - self._coord_matrix.min(axis=0)) / 2 - elif center_type == "origin": - center = np.zeros(self.dim) - else: + if center_type not in CENTER_TYPES: raise ValueError(f"Unknown center method: {center_type}") + + center = self.get_center(center_type) self._coord_matrix -= center def scale_coordinates(self, radius=1): @@ -163,7 +191,14 @@ def scale_coordinates(self, radius=1): if current_max > 0: self._coord_matrix *= (radius / current_max) - def apply_pca(self, target_dim=2): + def transform_coordinates(self, transform_type: str): + """Transform coordinates using a function""" + if transform_type == "pca": + self.pca_transform() + else: + raise ValueError(f"Unknown transform type: {transform_type}") + + def pca_transform(self, target_dim=2): """Dimensionality reduction using PCA""" if self.dim <= target_dim: return @@ -172,12 +207,6 @@ def apply_pca(self, target_dim=2): self._coord_matrix = pca.fit_transform(self._coord_matrix) self.dim = target_dim - def set_centered_coordinates(self, method: str = 'mean'): - """Center coordinates using specified method""" - center = self.get_center(method) - for v in self.nodes(): - self.nodes[v]['coords'] = self.nodes[v]['coords'] - center - def add_cycle(self, coords: np.ndarray): """Add nodes in a cycle from coordinates""" n = len(coords) @@ -220,17 +249,17 @@ def get_bounding_box(self): # =================== def plot(self, projection=None, ax=None, **kwargs): """2D visualization with optional PCA projection""" - if self.dim != 2: + if self.dim >= 3: if projection is None: raise ValueError( "Require 2D coordinates or specify projection") - self.apply_pca(target_dim=2) + self.apply_pca(target_dim=3) if ax is None: fig, ax = plt.subplots() pos = {n: self._coord_matrix[i] - for i, n in enumerate(self._node_order)} + for i, n in enumerate(self._node_list)} nx.draw(self, pos, ax=ax, **kwargs) ax.set_aspect('equal') return ax diff --git a/ect/ect/utils/utils.py b/ect/ect/utils/utils.py new file mode 100644 index 0000000..d8b02f1 --- /dev/null +++ b/ect/ect/utils/utils.py @@ -0,0 +1,87 @@ +from ect.embed_graph import EmbeddedGraph +from ect.embed_cw import EmbeddedCW +import inspect +from typing import Type + + +def create_example_cw(centered=True, center_type='min_max'): + """ + Creates an example EmbeddedCW object with a simple CW complex. If centered is True, the coordinates are centered around the center type, which could be ``mean``, ``min_max`` or ``origin``. + + + Returns: + EmbeddedCW + The example EmbeddedCW object. + """ + G = create_example_graph(centered=False) + K = EmbeddedCW() + K.add_from_embedded_graph(G) + K.add_node('G', 2, 4) + K.add_node('H', 1, 5) + K.add_node('I', 5, 4) + K.add_node('J', 2, 2) + K.add_node('K', 2, 7) + K.add_edges_from([('G', 'A'), ('G', 'H'), ('H', 'D'), ('I', 'E'), + ('I', 'C'), ('J', 'E'), ('K', 'D'), ('K', 'C')]) + K.add_face(['B', 'A', 'G', 'H', 'D']) + K.add_face(['K', 'D', 'C']) + + if centered: + K.set_centered_coordinates(type=center_type) + + return K + + +def create_example_graph(centered=True, center_type='min_max'): + """ + Function to create an example ``EmbeddedGraph`` object. Helpful for testing. If ``centered`` is True, the coordinates are centered using the center type given by ``center_type``, either ``mean`` or ``min_max``. + + Returns: + EmbeddedGraph: An example ``EmbeddedGraph`` object. + + """ + graph = EmbeddedGraph() + + graph.add_node('A', 1, 2) + graph.add_node('B', 3, 4) + graph.add_node('C', 5, 7) + graph.add_node('D', 3, 6) + graph.add_node('E', 4, 3) + graph.add_node('F', 4, 5) + + graph.add_edge('A', 'B') + graph.add_edge('B', 'C') + graph.add_edge('B', 'D') + graph.add_edge('B', 'E') + graph.add_edge('C', 'D') + graph.add_edge('E', 'F') + + if centered: + graph.set_centered_coordinates(center_type) + + return graph + + +def next_vert_name(self, s, num_verts=1): + """Generate sequential vertex names (alphabetical or numerical).""" + if isinstance(s, int): + return [s + i + 1 for i in range(num_verts)] if num_verts > 1 else s + 1 + + def increment_char(c): + return 'A' if c == 'Z' else chr(ord(c) + 1) + + def increment_str(s): + chars = list(s) + for i in reversed(range(len(chars))): + chars[i] = increment_char(chars[i]) + if chars[i] != 'A': + break + elif i == 0: + return 'A' + ''.join(chars) + return ''.join(chars) + + # handle multiple increments + names = [s] + for _ in range(num_verts): + names.append(increment_str(names[-1])) + return names[1:] if num_verts > 1 else names[1] From 3173d4371444c6c8e1f44329c5bfa80ec89c74fe Mon Sep 17 00:00:00 2001 From: yemeen Date: Mon, 3 Feb 2025 21:49:31 -0500 Subject: [PATCH 09/35] Update node management and coordinate validation --- ect/ect/embed_graph.py | 227 ++++++++++++++++++++++++----------------- 1 file changed, 132 insertions(+), 95 deletions(-) diff --git a/ect/ect/embed_graph.py b/ect/ect/embed_graph.py index 614326a..342eec2 100644 --- a/ect/ect/embed_graph.py +++ b/ect/ect/embed_graph.py @@ -3,9 +3,10 @@ import matplotlib.pyplot as plt from sklearn.decomposition import PCA from utils import next_vert_name +from typing import Dict, List, Tuple -CENTER_TYPES = ["mean", "min_max"] +CENTER_TYPES = ["mean", "bounding_box"] TRANSFORM_TYPES = ["pca"] @@ -32,11 +33,58 @@ def __init__(self): self._node_list = [] self._node_to_index = {} self._coord_matrix = np.empty((0, 0)) - self.dim = 0 + + @property + def coord_matrix(self): + """Return the N x D coordinate matrix.""" + return self._coord_matrix + + @property + def dim(self): + """Return the dimension of the embedded coordinates""" + return self._coord_matrix.shape[1] if self._coord_matrix.size > 0 else 0 + + @property + def node_list(self): + """Return ordered list of node names.""" + return self._node_list # ====================================== - # Core Node Management + # Node Management # ====================================== + def _validate_coordinates(func): + def wrapper(self, *args, **kwargs): + coords = next((arg for arg in args if isinstance( + arg, (list, np.ndarray))), None) + if coords is not None: + coords = np.array(coords, dtype=float) + if coords.ndim != 1: + raise ValueError("Coordinates must be a 1D array") + + if len(self._node_list) == 0: + self.dim = coords.size + self._coord_matrix = np.empty((0, self.dim)) + elif coords.size != self.dim: + raise ValueError( + f"Coordinates must have dimension {self.dim}") + + return func(self, *args, **kwargs) + return wrapper + + def _validate_node(exists=True): + def decorator(func): + def wrapper(self, node_id, *args, **kwargs): + node_exists = node_id in self._node_to_index + if exists and not node_exists: + raise ValueError(f"Node {node_id} does not exist") + if not exists and node_exists: + raise ValueError(f"Node {node_id} already exists") + return func(self, node_id, *args, **kwargs) + return wrapper + return decorator + + @_validate_coordinates + @_validate_node(exists=False) def add_node(self, node_id, coordinates): """Add a vertex to the graph. If the vertex name is given as None, it will be assigned via the next_vert_name method. @@ -45,26 +93,12 @@ def add_node(self, node_id, coordinates): node_id (hashable like int or str, or None) : The name of the vertex to add. coordinates (array-like) : The coordinates of the vertex being added. """ - if node_id in self._node_to_index: - raise ValueError(f"Node {node_id} already exists.") - - coords = np.array(coordinates, dtype=float) - if coords.ndim != 1: - raise ValueError("Coordinates must be a 1D array.") - - if len(self._node_list) == 0: - self.dim = coords.size - self._coord_matrix = np.empty((0, self.dim)) - - if coords.size != self.dim: - raise ValueError(f"Coordinates must have dimension {self.dim}.") - self._node_list.append(node_id) self._node_to_index[node_id] = len(self._node_list) - 1 self._coord_matrix = np.vstack([self._coord_matrix, coords]) super().add_node(node_id) - def add_nodes_from(self, coords): + def add_nodes_from(self, nodes_with_coords): for node_id, coords in nodes_with_coords: self.add_node(node_id, coords) @@ -72,30 +106,17 @@ def add_nodes_from(self, coords): # Coordinate Access # ====================================== - @property - def coord_matrix(self): - """Return the N x D coordinate matrix.""" - return self._coord_matrix - def get_coordinates(self, node_id): """Return the coordinates of a node""" return self._coord_matrix[self._node_to_index[node_id]].copy() + @_validate_coordinates + @_validate_node(exists=True) def set_coordinates(self, node_id, new_coords): """Set the coordinates of a node""" - if node_id not in self._node_to_index: - raise ValueError(f"Node {node_id} does not exist.") - if new_coords.shape != (self.dim,): - raise ValueError(f"Coordinates must be {self.dim}-dimensional") - idx = self._node_to_index[node_id] self._coord_matrix[idx] = new_coords - @property - def node_list(self): - """Return ordered list of node names.""" - return self._node_list - # ====================================== # Graph Operations # ====================================== @@ -103,7 +124,7 @@ def node_list(self): def add_cycle(self, coord_matrix): """Add nodes in a cyclic pattern from coordinate matrix""" n = coord_matrix.shape[0] - new_names = self.next_vert_name( + new_names = next_vert_name( self._node_list[-1] if self._node_list else 0, n) self.add_nodes_from(zip(new_names, coord_matrix)) self.add_edges_from([(new_names[i], new_names[(i+1) % n]) @@ -113,13 +134,13 @@ def add_cycle(self, coord_matrix): # Geometric Calculations # ====================================== - def get_center(self, method: str = 'mean') -> np.ndarray: + def get_center(self, method: str = 'bounding_box') -> np.ndarray: """Calculate center of coordinates""" - coords = self.get_coords_array() + coords = self._coord_matrix if method == 'mean': return np.mean(coords, axis=0) - elif method == 'min_max': + elif method == 'bounding_box': return (np.max(coords, axis=0) + np.min(coords, axis=0)) / 2 elif method == 'origin': return np.zeros(self.dim) @@ -132,51 +153,90 @@ def get_bounding_box(self): def get_bounding_radius(self, center_type: str = 'mean') -> float: """Get radius of minimal bounding sphere""" center = self.get_center(center_type) - coords = self.get_coords_array() + coords = self._coord_matrix return np.max(np.linalg.norm(coords - center, axis=1)) def get_normal_angles(self, edges_only=False, decimals=6): - """Get angles where edge order changes (2D only)""" + """ + Get angles where edge order changes (2D only). + + Args: + edges_only: Only compute angles between vertices connected by edges + decimals: Number of decimal places to round angles to + + Returns: + Dictionary mapping angles to lists of vertex pairs + """ if self.dim != 2: raise ValueError("Angle calculations require 2D coordinates") - angles = {} - coords = self._coord_matrix + vertices = list(self.nodes()) + coords = self.get_coords_array() + n = len(vertices) - # vectorized angle calculation - vecs = coords[:, None, :] - coords[None, :, :] - norms = np.linalg.norm(vecs, axis=2) - valid = ~np.isclose(norms, 0) + angles = {} - with np.errstate(divide='ignore', invalid='ignore'): - angles_rad = np.arctan2(vecs[:, :, 0], -vecs[:, :, 1]) % (2*np.pi) - angles_rad[~valid] = np.nan + if edges_only: + edges = np.array(list(self.edges())) + idx1 = np.array([vertices.index(u) for u, _ in edges]) + idx2 = np.array([vertices.index(v) for _, v in edges]) - for i in range(coords.shape[0]): - for j in range(i+1, coords.shape[0]): - if edges_only and not self.has_edge(self._node_list[i], self._node_list[j]): - continue + diffs = coords[idx2] - coords[idx1] - angle = np.round(angles_rad[i, j], decimals) - pair = (self._node_list[i], self._node_list[j]) + edge_angles = np.arctan2(diffs[:, 0], -diffs[:, 1]) % (2*np.pi) + edge_angles = np.round(edge_angles, decimals) + for i, angle in enumerate(edge_angles): + pair = (vertices[idx1[i]], vertices[idx2[i]]) if angle in angles: angles[angle].append(pair) else: angles[angle] = [pair] + else: + diffs = coords[:, np.newaxis, :] - coords[np.newaxis, :, :] + + norms = np.linalg.norm(diffs, axis=2) + valid = ~np.isclose(norms, 0) + + with np.errstate(divide='ignore', invalid='ignore'): + all_angles = np.arctan2( + diffs[..., 0], -diffs[..., 1]) % (2*np.pi) + all_angles[~valid] = np.nan + all_angles = np.round(all_angles, decimals) + + for i in range(n): + for j in range(i+1, n): + if valid[i, j]: + angle = all_angles[i, j] + pair = (vertices[i], vertices[j]) + if angle in angles: + angles[angle].append(pair) + else: + angles[angle] = [pair] + return angles + def get_normal_angles_matrix(self, edges_only=False, decimals=6): + """ + Get angles where edge order changes (2D only). + Vectorized implementation for efficiency. + """ + if self.dim != 2: + raise ValueError("Angle calculations require 2D coordinates") + # ============================ # Coordinate transformations # ============================ - def transform_coordinates(self, center_type=None, transform_type=None): + def transform_coordinates(self, center_type="bounding_box", projection_type="pca"): """Transform coordinates center and orientation""" - if center_type is not None: - self.center_coordinates(center_type) - if transform_type is not None: - self.transform_coordinates(transform_type) + if projection_type not in TRANSFORM_TYPES: + raise ValueError(f"Unknown transform type: {projection_type}") + self.project_coordinates(projection_type) + if center_type not in CENTER_TYPES: + raise ValueError(f"Unknown center method: {center_type}") + self.center_coordinates(center_type) def center_coordinates(self, center_type="mean"): if center_type not in CENTER_TYPES: @@ -191,14 +251,14 @@ def scale_coordinates(self, radius=1): if current_max > 0: self._coord_matrix *= (radius / current_max) - def transform_coordinates(self, transform_type: str): - """Transform coordinates using a function""" - if transform_type == "pca": - self.pca_transform() + def project_coordinates(self, projection_type="pca"): + """Project coordinates using a function""" + if projection_type == "pca": + self.pca_projection() else: - raise ValueError(f"Unknown transform type: {transform_type}") + raise ValueError(f"Unknown projection type: {projection_type}") - def pca_transform(self, target_dim=2): + def pca_projection(self, target_dim=2): """Dimensionality reduction using PCA""" if self.dim <= target_dim: return @@ -207,42 +267,19 @@ def pca_transform(self, target_dim=2): self._coord_matrix = pca.fit_transform(self._coord_matrix) self.dim = target_dim - def add_cycle(self, coords: np.ndarray): - """Add nodes in a cycle from coordinates""" - n = len(coords) - node_ids = [str(i) for i in range(n)] - - # Add nodes - for node_id, coord in zip(node_ids, coords): - self.add_node(node_id, coords=coord) - - # Add edges - edges = [(node_ids[i], node_ids[(i+1) % n]) for i in range(n)] - self.add_edges_from(edges) - - def add_edge(self, u, v): + @_validate_node(exists=True) + def add_edge(self, node_id1, node_id2): """ - Adds an edge between the vertices u and v if they exist. + Adds an edge between the vertices node_id1 and node_id2 if they exist. Parameters: - u (str): + node_id1 (str): The first vertex of the edge. - v (str): + node_id2 (str): The second vertex of the edge. """ - if not self.has_node(u) or not self.has_node(v): - raise ValueError("One or both vertices do not exist in the graph.") - else: - super().add_edge(u, v) - - def get_bounding_box(self): - if self._coord_matrix.size == 0: - return None - return [(dim_min, dim_max) for dim_min, dim_max in zip( - self._coord_matrix.min(axis=0), - self._coord_matrix.max(axis=0) - )] + super().add_edge(node_id1, node_id2) # =================== # Visualization @@ -253,7 +290,7 @@ def plot(self, projection=None, ax=None, **kwargs): if projection is None: raise ValueError( "Require 2D coordinates or specify projection") - self.apply_pca(target_dim=3) + self.pca_projection(target_dim=3) if ax is None: fig, ax = plt.subplots() From 0255211e9076afb5a14dae538c2b98e7ac0c99d3 Mon Sep 17 00:00:00 2001 From: yemeen Date: Mon, 3 Feb 2025 21:51:18 -0500 Subject: [PATCH 10/35] Remove unnecessary function create_example_cw --- ect/ect/embed_cw.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/ect/ect/embed_cw.py b/ect/ect/embed_cw.py index 7c25e77..81dde8c 100644 --- a/ect/ect/embed_cw.py +++ b/ect/ect/embed_cw.py @@ -174,31 +174,3 @@ def plot(self, bounding_circle=False, color_nodes_theta=None, ax=None, **kwargs) ax = super().plot(bounding_circle=bounding_circle, color_nodes_theta=color_nodes_theta, ax=ax) return ax - - -def create_example_cw(centered=True, center_type='min_max'): - """ - Creates an example EmbeddedCW object with a simple CW complex. If centered is True, the coordinates are centered around the center type, which could be ``mean``, ``min_max`` or ``origin``. - - - Returns: - EmbeddedCW - The example EmbeddedCW object. - """ - G = create_example_graph(centered=False) - K = EmbeddedCW() - K.add_from_embedded_graph(G) - K.add_node('G', 2, 4) - K.add_node('H', 1, 5) - K.add_node('I', 5, 4) - K.add_node('J', 2, 2) - K.add_node('K', 2, 7) - K.add_edges_from([('G', 'A'), ('G', 'H'), ('H', 'D'), ('I', 'E'), - ('I', 'C'), ('J', 'E'), ('K', 'D'), ('K', 'C')]) - K.add_face(['B', 'A', 'G', 'H', 'D']) - K.add_face(['K', 'D', 'C']) - - if centered: - K.set_centered_coordinates(type=center_type) - - return K From a5a67e011f40a2e1cc6785dec22e2268d20627ab Mon Sep 17 00:00:00 2001 From: yemeen Date: Fri, 7 Feb 2025 20:41:34 -0500 Subject: [PATCH 11/35] Update direction sampling strategy and ECT calculation --- ect/ect/directions.py | 22 ++++----- ect/ect/ect_graph.py | 108 +++++++++++++++--------------------------- 2 files changed, 50 insertions(+), 80 deletions(-) diff --git a/ect/ect/directions.py b/ect/ect/directions.py index 1cf07a1..4b24621 100644 --- a/ect/ect/directions.py +++ b/ect/ect/directions.py @@ -3,10 +3,10 @@ from enum import Enum -class SamplingStrategy(Enum): - UNIFORM = "uniform" # Evenly spaced angles - RANDOM = "random" # Random angles - CUSTOM = "custom" # User-provided angles +class Sampling(Enum): + UNIFORM = "uniform" + RANDOM = "random" + CUSTOM = "custom" class Directions: @@ -30,12 +30,12 @@ class Directions: def __init__(self, num_dirs: int = 360, - strategy: SamplingStrategy = SamplingStrategy.UNIFORM, + sampling: Sampling = Sampling.UNIFORM, endpoint: bool = False, seed: Optional[int] = None): self.num_dirs = num_dirs - self.strategy = strategy + self.sampling = sampling self.endpoint = endpoint if seed is not None: @@ -47,23 +47,23 @@ def __init__(self, def _initialize_directions(self): """Initialize direction angles based on strategy""" - if self.strategy == SamplingStrategy.UNIFORM: + if self.sampling == Sampling.UNIFORM: self._thetas = np.linspace(0, 2*np.pi, self.num_dirs, endpoint=self.endpoint) - elif self.strategy == SamplingStrategy.RANDOM: + elif self.sampling == Sampling.RANDOM: self._thetas = np.random.uniform(0, 2*np.pi, self.num_dirs) self._thetas.sort() # Sort for consistency @classmethod def random(cls, num_dirs: int = 360, seed: Optional[int] = None) -> 'Directions': """Create instance with random direction sampling""" - return cls(num_dirs, SamplingStrategy.RANDOM, seed=seed) + return cls(num_dirs, Sampling.RANDOM, seed=seed) @classmethod def from_angles(cls, angles: Sequence[float]) -> 'Directions': """Create instance from custom angles""" - instance = cls(len(angles), SamplingStrategy.CUSTOM) + instance = cls(len(angles), Sampling.CUSTOM) instance._thetas = np.array(angles) return instance @@ -74,7 +74,7 @@ def from_vectors(cls, vectors: Sequence[tuple]) -> 'Directions': norms = np.linalg.norm(vectors, axis=1, keepdims=True) normalized = vectors / norms - instance = cls(len(vectors), SamplingStrategy.CUSTOM) + instance = cls(len(vectors), Sampling.CUSTOM) instance._vectors = normalized instance._thetas = np.arctan2(normalized[:, 1], normalized[:, 0]) return instance diff --git a/ect/ect/ect_graph.py b/ect/ect/ect_graph.py index 4731e27..5191217 100644 --- a/ect/ect/ect_graph.py +++ b/ect/ect/ect_graph.py @@ -1,6 +1,7 @@ import matplotlib.pyplot as plt import numpy as np from numba import jit, prange +from typing import Optional from ect.embed_cw import EmbeddedCW @@ -25,28 +26,33 @@ class ECT: """ - def __init__(self, num_dirs, num_thresh, bound_radius=None): + def __init__(self, + num_dirs: Optional[int] = None, + num_thresh: int = 100, + directions: Optional[Directions] = None, + bound_radius: Optional[float] = None): """ - Constructs all the necessary attributes for the ECT object. + Initialize ECT calculator. - Parameters: - num_dirs (int): - The number of directions to consider in the matrix. - num_thresh (int): - The number of thresholds to consider in the matrix. - bound_radius (int): - Either None, or a positive radius of the bounding circle. + Args: + num_dirs: Number of uniformly sampled directions (ignored if directions provided) + num_thresh: Number of threshold values + directions: Optional Directions object for custom sampling + bound_radius: Optional radius for bounding circle """ - self.num_dirs = num_dirs - - # Note: This version doesn't include 2pi since its the same as the 0 direction. - self.thetas = np.linspace(0, 2*np.pi, self.num_dirs, endpoint=False) + # Set up directions + if directions is not None: + self.directions = directions + self.num_dirs = len(directions) + else: + if num_dirs is None: + num_dirs = 360 + self.num_dirs = num_dirs + self.directions = Directions(num_dirs) self.num_thresh = num_thresh self.set_bounding_radius(bound_radius) - - self.ECT_matrix = np.zeros((num_dirs, num_thresh)) - self.SECT_matrix = np.zeros((num_dirs, num_thresh)) + self.ect_matrix = np.zeros((self.num_dirs, self.num_thresh)) def set_bounding_radius(self, bound_radius): """ @@ -102,18 +108,6 @@ def get_radius_and_thresh(self, G, bound_radius): return r, r_threshes - def get_ect(self): - """ - Returns the ECT matrix. - """ - return self.ect_matrix - - def get_sect(self): - """ - Returns the SECT matrix. - """ - return self.sect_matrix - def calculate_ecc(self, G, theta, bound_radius=None, return_counts=False): """ Function to compute the Euler Characteristic Curve (ECC) of an `EmbeddedGraph`. @@ -162,7 +156,7 @@ def calculate_ecc(self, G, theta, bound_radius=None, return_counts=False): @staticmethod @jit(nopython=True, parallel=True) - def fast_threshold_comp(projections, edge_maxes, thresholds): + def calculate_euler_chars(projections, edge_maxes, thresholds): """Calculate the euler characteristic for each direction in parallel Parameters: @@ -189,7 +183,6 @@ def fast_threshold_comp(projections, edge_maxes, thresholds): vert_count = 0 edge_count = 0 - # Use SIMD-friendly loops for v in range(num_vertices): if projections[v, i] <= thresh: vert_count += 1 @@ -201,7 +194,7 @@ def fast_threshold_comp(projections, edge_maxes, thresholds): return result - def calculate_ect(self, graph, bound_radius=None,): + def calculate_ect(self, graph: EmbeddedGraph, bound_radius=None,): """Vectorized ECT calculation using optimized numpy operations Parameters: @@ -216,29 +209,17 @@ def calculate_ect(self, graph, bound_radius=None,): """ r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) - coords = np.array([graph.coordinates[v] for v in graph.nodes()]) - - # create vertex index mapping and convert edges - vertex_to_idx = {v: i for i, v in enumerate(graph.nodes())} - edges = np.array([[vertex_to_idx[u], vertex_to_idx[v]] - for u, v in graph.edges()]) + coords = graph.coord_matrix + edges = graph.edge_index - directions = np.empty((self.num_dirs, 2), order='F') - np.stack([np.cos(self.thetas), np.sin(self.thetas)], - axis=1, out=directions) + projections = np.matmul(coords, self.directions.vectors.T) + edge_maxes = np.maximum(projections[edges[:, 0]], + projections[edges[:, 1]]) - projections = np.empty((len(coords), self.num_dirs), order='F') - np.matmul(coords, directions.T, out=projections) - - edge_maxes = np.maximum( - projections[edges[:, 0]], projections[edges[:, 1]]) - - # use numba-optimized threshold computation - ect_matrix = self.fast_threshold_comp( + # Calculate ECT + ect_matrix = self.calculate_euler_chars( projections, edge_maxes, r_threshes) - self.ect_matrix = ect_matrix - return ect_matrix def calculate_sect(self): @@ -251,7 +232,7 @@ def calculate_sect(self): """ # Calculate the SECT - M = self.ECT_matrix + M = self.ect_matrix # Get average of each row, corresponds to each direction A = np.average(M, axis=1) @@ -315,33 +296,22 @@ def plot_ect(self): X, Y = np.meshgrid(thetas, self.threshes) M = np.zeros_like(X) - # Transpose to get the correct orientation M[:, :-1] = self.ECT_matrix.T - M[:, -1] = M[:, 0] # Add the 2pi direction to the 0 direction + M[:, -1] = M[:, 0] # Add 2pi direction plt.pcolormesh(X, Y, M, cmap='viridis') plt.colorbar() ax = plt.gca() ax.set_xticks(np.linspace(0, 2*np.pi, 9)) - - labels = [ - r'$0$', - r'$\frac{\pi}{4}$', - r'$\frac{\pi}{2}$', - r'$\frac{3\pi}{4}$', - r'$\pi$', - r'$\frac{5\pi}{4}$', - r'$\frac{3\pi}{2}$', - r'$\frac{7\pi}{4}$', - r'$2\pi$', - ] - - ax.set_xticklabels(labels) + ax.set_xticklabels([ + r'$0$', r'$\frac{\pi}{4}$', r'$\frac{\pi}{2}$', + r'$\frac{3\pi}{4}$', r'$\pi$', r'$\frac{5\pi}{4}$', + r'$\frac{3\pi}{2}$', r'$\frac{7\pi}{4}$', r'$2\pi$' + ]) plt.xlabel(r'$\omega$') plt.ylabel(r'$a$') - plt.title(r'ECT of Input Graph') def plot_sect(self): @@ -353,7 +323,7 @@ def plot_sect(self): # Make meshgrid. # Add back the 2pi to thetas for the pcolormesh - thetas = np.concatenate((self.thetas, [2*np.pi])) + thetas = np.concatenate((self.directions.thetas, [2*np.pi])) X, Y = np.meshgrid(thetas, self.threshes) M = np.zeros_like(X) From 13cc6a6e728a3d3d5c653f5df6666d0ab1a40d1f Mon Sep 17 00:00:00 2001 From: yemeen Date: Tue, 11 Feb 2025 17:11:49 -0500 Subject: [PATCH 12/35] Start separating into ECTResults class and fix ECT not working for CWs --- ect/ect/directions.py | 2 +- ect/ect/ect_graph.py | 57 +++--- ect/ect/embed_cw.py | 47 ++--- ect/ect/embed_graph.py | 359 ++++++++++++++++++++++++++++++-------- ect/ect/types.py | 13 ++ ect/ect/utils/examples.py | 62 +++++++ ect/ect/utils/naming.py | 24 +++ 7 files changed, 437 insertions(+), 127 deletions(-) create mode 100644 ect/ect/types.py create mode 100644 ect/ect/utils/examples.py create mode 100644 ect/ect/utils/naming.py diff --git a/ect/ect/directions.py b/ect/ect/directions.py index 4b24621..dc8d09c 100644 --- a/ect/ect/directions.py +++ b/ect/ect/directions.py @@ -53,7 +53,7 @@ def _initialize_directions(self): endpoint=self.endpoint) elif self.sampling == Sampling.RANDOM: self._thetas = np.random.uniform(0, 2*np.pi, self.num_dirs) - self._thetas.sort() # Sort for consistency + self._thetas.sort() # sort for consistency @classmethod def random(cls, num_dirs: int = 360, seed: Optional[int] = None) -> 'Directions': diff --git a/ect/ect/ect_graph.py b/ect/ect/ect_graph.py index 5191217..9d94c10 100644 --- a/ect/ect/ect_graph.py +++ b/ect/ect/ect_graph.py @@ -1,9 +1,11 @@ import matplotlib.pyplot as plt import numpy as np from numba import jit, prange -from typing import Optional +from typing import Optional, Union from ect.embed_cw import EmbeddedCW +from ect.embed_graph import EmbeddedGraph +from ect.directions import Directions class ECT: @@ -28,7 +30,7 @@ class ECT: def __init__(self, num_dirs: Optional[int] = None, - num_thresh: int = 100, + num_thresh: int = 360, directions: Optional[Directions] = None, bound_radius: Optional[float] = None): """ @@ -40,7 +42,6 @@ def __init__(self, directions: Optional Directions object for custom sampling bound_radius: Optional radius for bounding circle """ - # Set up directions if directions is not None: self.directions = directions self.num_dirs = len(directions) @@ -52,7 +53,6 @@ def __init__(self, self.num_thresh = num_thresh self.set_bounding_radius(bound_radius) - self.ect_matrix = np.zeros((self.num_dirs, self.num_thresh)) def set_bounding_radius(self, bound_radius): """ @@ -156,7 +156,7 @@ def calculate_ecc(self, G, theta, bound_radius=None, return_counts=False): @staticmethod @jit(nopython=True, parallel=True) - def calculate_euler_chars(projections, edge_maxes, thresholds): + def calculate_euler_chars(projections, edge_maxes, face_maxes, thresholds): """Calculate the euler characteristic for each direction in parallel Parameters: @@ -194,7 +194,7 @@ def calculate_euler_chars(projections, edge_maxes, thresholds): return result - def calculate_ect(self, graph: EmbeddedGraph, bound_radius=None,): + def calculate(self, graph: Union[EmbeddedGraph, EmbeddedCW], theta: Optional[float] = None, bound_radius=None, return_counts=False): """Vectorized ECT calculation using optimized numpy operations Parameters: @@ -208,21 +208,35 @@ def calculate_ect(self, graph: EmbeddedGraph, bound_radius=None,): The ECT matrix of size (num_dirs, num_thresh). """ r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) - coords = graph.coord_matrix - edges = graph.edge_index - - projections = np.matmul(coords, self.directions.vectors.T) - edge_maxes = np.maximum(projections[edges[:, 0]], - projections[edges[:, 1]]) - - # Calculate ECT - ect_matrix = self.calculate_euler_chars( - projections, edge_maxes, r_threshes) - self.ect_matrix = ect_matrix - return ect_matrix + edges = graph.edge_indices - def calculate_sect(self): + if theta is None: + vertex_projections = np.matmul(coords, self.directions.vectors.T) + else: + vertex_projections = np.matmul( + coords, Directions.from_angles([theta]).vectors.T) + + edge_maxes = np.maximum( + vertex_projections[edges[:, 0]], vertex_projections[edges[:, 1]]) + + face_maxes = np.empty((0, self.num_dirs)) + if isinstance(graph, EmbeddedCW) and len(graph.faces) > 0: + node_to_index = {n: i for i, n in enumerate(graph.node_list)} + face_indices = [ + [node_to_index[v] for v in face] + for face in graph.faces + ] + face_maxes = np.array([ + np.max(vertex_projections[face, :], axis=0) + for face in face_indices + ]) + + return self.calculate_euler_chars( + vertex_projections, edge_maxes, face_maxes, r_threshes + ) + + def calculate_sect(self, ect_matrix=None): """ Function to calculate the Smooth Euler Characteristic Transform (SECT) from the ECT matrix. @@ -232,7 +246,10 @@ def calculate_sect(self): """ # Calculate the SECT - M = self.ect_matrix + if ect_matrix is None: + M = self.calculate_ect() + else: + M = ect_matrix # Get average of each row, corresponds to each direction A = np.average(M, axis=1) diff --git a/ect/ect/embed_cw.py b/ect/ect/embed_cw.py index 81dde8c..8263d31 100644 --- a/ect/ect/embed_cw.py +++ b/ect/ect/embed_cw.py @@ -1,9 +1,8 @@ import numpy as np -from itertools import compress, combinations import matplotlib.pyplot as plt import networkx as nx -from ect.embed_graph import EmbeddedGraph, create_example_graph -from scipy.optimize import linprog +from ect.embed_graph import EmbeddedGraph +from ect.utils.face_check import point_in_polygon class EmbeddedCW(EmbeddedGraph): @@ -46,36 +45,22 @@ def add_face(self, face, check=True): check (bool): Whether to check that the face is a valid addition to the cw complex. """ - if check: - # Make sure all edges are in the graph + if check: + # Edge existence check edges = list(zip(face, face[1:] + [face[0]])) - for edge in edges: - if edge not in self.edges: - raise ValueError(f"Edge {edge} not in graph.") - - # TODO: The goal here is to check that none of the other vertices are in the polygon defined by the face. - # Problem is that the face could be concave, so we can't just check if the point is in the convex hull of the face. - # This is a bit of a tricky problem, so I'm going to leave it for now. - - # def in_hull(points, x): - # # Solution for checking if a point is in a convex hull - # # from Nils answer here: - # # https://stackoverflow.com/questions/16750618/whats-an-efficient-way-to-find-if-a-point-lies-in-the-convex-hull-of-a-point-cl - # n_points = len(points) - # n_dim = len(x) - # c = np.zeros(n_points) - # A = np.r_[points.T,np.ones((1,n_points))] - # b = np.r_[x, np.ones(1)] - # lp = linprog(c, A_eq=A, b_eq=b) - # return lp.success - - # points = np.array([self.coordinates[v] for v in face]) - # if not in_hull(points.T, self.coordinates[face[0]]): - # raise ValueError(f"Face {face} does not bound an empty region in the plane.") - - # Note: faces need to be tuples to make - # the face hashable so it can be used as a key in a dictionary + for u, v in edges: + if not self.has_edge(u, v): + raise ValueError(f"Edge ({u},{v}) missing") + + # Point-in-polygon check for other vertices + polygon = np.array([self.coordinates[v] for v in face]) + for node in self.nodes: + if node in face: + continue + if point_in_polygon(self.coordinates[node], polygon): + raise ValueError(f"Node {node} inside face {face}") + self.faces.append(tuple(face)) def g_omega_faces(self, theta): diff --git a/ect/ect/embed_graph.py b/ect/ect/embed_graph.py index 342eec2..5e3b90f 100644 --- a/ect/ect/embed_graph.py +++ b/ect/ect/embed_graph.py @@ -3,10 +3,10 @@ import matplotlib.pyplot as plt from sklearn.decomposition import PCA from utils import next_vert_name -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Optional -CENTER_TYPES = ["mean", "bounding_box"] +CENTER_TYPES = ["mean", "bounding_box", "origin"] TRANSFORM_TYPES = ["pca"] @@ -49,10 +49,23 @@ def node_list(self): """Return ordered list of node names.""" return self._node_list + @property + def node_to_index(self): + """Return a dictionary mapping node ids to their index in the coord_matrix""" + return self._node_to_index + + @property + def position_dict(self): + """Return a dictionary mapping node ids to their coordinates""" + return {node: self._coord_matrix[i] + for i, node in enumerate(self._node_list)} + # ====================================== # Node Management # ====================================== - def _validate_coordinates(func): + def _validate_coords(func): + """Validates if coordinates are nonempty and have valid dimension""" + def wrapper(self, *args, **kwargs): coords = next((arg for arg in args if isinstance( arg, (list, np.ndarray))), None) @@ -62,8 +75,7 @@ def wrapper(self, *args, **kwargs): raise ValueError("Coordinates must be a 1D array") if len(self._node_list) == 0: - self.dim = coords.size - self._coord_matrix = np.empty((0, self.dim)) + self._coord_matrix = np.empty((0, coords.size)) elif coords.size != self.dim: raise ValueError( f"Coordinates must have dimension {self.dim}") @@ -72,6 +84,7 @@ def wrapper(self, *args, **kwargs): return wrapper def _validate_node(exists=True): + """Validates if a node exists or not already""" def decorator(func): def wrapper(self, node_id, *args, **kwargs): node_exists = node_id in self._node_to_index @@ -83,9 +96,9 @@ def wrapper(self, node_id, *args, **kwargs): return wrapper return decorator - @_validate_coordinates + @_validate_coords @_validate_node(exists=False) - def add_node(self, node_id, coordinates): + def add_node(self, node_id, coord): """Add a vertex to the graph. If the vertex name is given as None, it will be assigned via the next_vert_name method. @@ -95,24 +108,24 @@ def add_node(self, node_id, coordinates): """ self._node_list.append(node_id) self._node_to_index[node_id] = len(self._node_list) - 1 - self._coord_matrix = np.vstack([self._coord_matrix, coords]) + self._coord_matrix = np.vstack([self._coord_matrix, coord]) super().add_node(node_id) def add_nodes_from(self, nodes_with_coords): - for node_id, coords in nodes_with_coords: - self.add_node(node_id, coords) + for node_id, coordinates in nodes_with_coords: + self.add_node(node_id, coordinates) # ====================================== # Coordinate Access # ====================================== - def get_coordinates(self, node_id): + def get_coord(self, node_id): """Return the coordinates of a node""" return self._coord_matrix[self._node_to_index[node_id]].copy() - @_validate_coordinates + @_validate_coords @_validate_node(exists=True) - def set_coordinates(self, node_id, new_coords): + def set_coord(self, node_id, new_coords): """Set the coordinates of a node""" idx = self._node_to_index[node_id] self._coord_matrix[idx] = new_coords @@ -156,74 +169,109 @@ def get_bounding_radius(self, center_type: str = 'mean') -> float: coords = self._coord_matrix return np.max(np.linalg.norm(coords - center, axis=1)) - def get_normal_angles(self, edges_only=False, decimals=6): + def get_normal_angle_matrix(self, + edges_only: bool = False, + decimals: Optional[int] = None + ) -> Tuple[np.ndarray, List[str]]: """ - Get angles where edge order changes (2D only). + Optimized angle matrix computation using vectorized operations. Args: - edges_only: Only compute angles between vertices connected by edges - decimals: Number of decimal places to round angles to + edges_only: Only compute angles between connected vertices + decimals: Round angles to specified decimal places Returns: - Dictionary mapping angles to lists of vertex pairs + angle_matrix: NaN-filled matrix with pair angles + vertex_labels: Ordered node identifiers """ - if self.dim != 2: - raise ValueError("Angle calculations require 2D coordinates") - - vertices = list(self.nodes()) - coords = self.get_coords_array() + coords = self._coord_matrix + vertices = self._node_list n = len(vertices) - angles = {} + angle_matrix = np.full((n, n), np.nan, dtype=np.float64) if edges_only: edges = np.array(list(self.edges())) - idx1 = np.array([vertices.index(u) for u, _ in edges]) - idx2 = np.array([vertices.index(v) for _, v in edges]) + if edges.size == 0: + return angle_matrix, vertices - diffs = coords[idx2] - coords[idx1] + u_indices = np.vectorize(self._node_to_index.get)(edges[:, 0]) + v_indices = np.vectorize(self._node_to_index.get)(edges[:, 1]) - edge_angles = np.arctan2(diffs[:, 0], -diffs[:, 1]) % (2*np.pi) - edge_angles = np.round(edge_angles, decimals) + dx = coords[v_indices, 0] - coords[u_indices, 0] + dy = coords[v_indices, 1] - coords[u_indices, 1] - for i, angle in enumerate(edge_angles): - pair = (vertices[idx1[i]], vertices[idx2[i]]) - if angle in angles: - angles[angle].append(pair) - else: - angles[angle] = [pair] + angles = np.arctan2(dx, -dy) % (2*np.pi) + rev_angles = (angles + np.pi) % (2*np.pi) + + if decimals is not None: + angles = np.round(angles, decimals) + rev_angles = np.round(rev_angles, decimals) + + angle_matrix[u_indices, v_indices] = angles + angle_matrix[v_indices, u_indices] = rev_angles else: - diffs = coords[:, np.newaxis, :] - coords[np.newaxis, :, :] - - norms = np.linalg.norm(diffs, axis=2) - valid = ~np.isclose(norms, 0) - - with np.errstate(divide='ignore', invalid='ignore'): - all_angles = np.arctan2( - diffs[..., 0], -diffs[..., 1]) % (2*np.pi) - all_angles[~valid] = np.nan - all_angles = np.round(all_angles, decimals) - - for i in range(n): - for j in range(i+1, n): - if valid[i, j]: - angle = all_angles[i, j] - pair = (vertices[i], vertices[j]) - if angle in angles: - angles[angle].append(pair) - else: - angles[angle] = [pair] - - return angles - - def get_normal_angles_matrix(self, edges_only=False, decimals=6): + x = coords[:, 0] + y = coords[:, 1] + + # compute all pairwise differences + dx = x[:, None] - x[None, :] + dy = y[:, None] - y[None, :] + + # Compute angles and mask invalid pairs + angle_matrix = np.arctan2(dx, -dy) % (2*np.pi) + angle_matrix[np.isclose(dx**2 + dy**2, 0)] = np.nan # Zero vectors + + if decimals is not None: + angle_matrix = np.round(angle_matrix, decimals) + + # mask diagonal since we don't want + np.fill_diagonal(angle_matrix, np.nan) + + return angle_matrix, vertices + + def get_normal_angles(self, + edges_only: bool = False, + decimals: int = 6 + ) -> Dict[float, List[Tuple[str, str]]]: """ - Get angles where edge order changes (2D only). - Vectorized implementation for efficiency. + Optimized angle dictionary construction using NumPy grouping. + + Args: + edges_only: Only include edge-connected pairs + decimals: Round angles to specified decimal places + + Returns: + Dictionary mapping rounded angles to vertex pairs """ - if self.dim != 2: - raise ValueError("Angle calculations require 2D coordinates") + angle_matrix, vertices = self.get_angle_matrix(edges_only, decimals) + n = len(vertices) + + # Extract upper triangle indices + rows, cols = np.triu_indices(n, k=1) + angles = angle_matrix[rows, cols] + valid_mask = ~np.isnan(angles) + + if not valid_mask.any(): + return defaultdict(list) + + # Filter valid pairs + valid_rows = rows[valid_mask] + valid_cols = cols[valid_mask] + valid_angles = angles[valid_mask] + + # Group pairs by rounded angle + angle_dict = defaultdict(list) + unique_angles, inverse = np.unique(valid_angles, return_inverse=True) + + for idx, angle in enumerate(unique_angles): + mask = (inverse == idx) + pairs = [(vertices[i], vertices[j]) + for i, j in zip(valid_rows[mask], valid_cols[mask])] + angle_dict[float(angle)].extend(pairs) + + return angle_dict # ============================ # Coordinate transformations @@ -284,19 +332,180 @@ def add_edge(self, node_id1, node_id2): # =================== # Visualization # =================== - def plot(self, projection=None, ax=None, **kwargs): - """2D visualization with optional PCA projection""" - if self.dim >= 3: - if projection is None: + def validate_plot_parameters(func): + """Decorator to validate plot method parameters""" + + def wrapper(self, *args, **kwargs): + bounding_center_type = kwargs.get( + 'bounding_center_type', 'bounding_box') + + if self.dim not in [2, 3]: raise ValueError( - "Require 2D coordinates or specify projection") - self.pca_projection(target_dim=3) + "At least 2D or 3D coordinates required for plotting") - if ax is None: - fig, ax = plt.subplots() + if bounding_center_type not in CENTER_TYPES: + raise ValueError(f"Invalid center type: {bounding_center_type}. " + f"Valid options: {CENTER_TYPES}") + + return func(self, *args, **kwargs) + return wrapper + + @validate_plot_parameters + def plot( + self, + bounding_circle: bool = False, + bounding_center_type: str = "bounding_box", + color_nodes_theta: Optional[float] = None, + ax: Optional[plt.Axes] = None, + with_labels: bool = True, + node_size: int = 300, + edge_color: str = "gray", + elev: float = 25, + azim: float = -60, + **kwargs + ) -> plt.Axes: + """ + Visualize the embedded graph in 2D or 3D + """ + ax = self._create_axes(ax, self.dim) + original_dim = self.dim + + pos = {node: self._coord_matrix[i] + for i, node in enumerate(self._node_list)} - pos = {n: self._coord_matrix[i] - for i, n in enumerate(self._node_list)} - nx.draw(self, pos, ax=ax, **kwargs) - ax.set_aspect('equal') + if self.dim == 2: + self._draw_2d(ax, pos, with_labels, + node_size, edge_color, **kwargs) + else: + self._draw_3d(ax, pos, node_size, edge_color, elev, azim, **kwargs) + + if color_nodes_theta is not None: + self._add_node_coloring( + ax, pos, color_nodes_theta, node_size, self.dim, **kwargs) + + if bounding_circle: + self._add_bounding_shape(ax, bounding_center_type, self.dim) + + return ax + + def _create_axes(self, ax): + """Create appropriate axes if not provided""" + if ax is None: + fig = plt.figure() + ax = fig.add_subplot( + 111, projection='3d' if self.dim == 3 else None) + elif self.dim == 3 and not hasattr(ax, 'zaxis'): + raise ValueError("For 3D plots, provide axes with 3D projection") return ax + + def _draw_2d(self, ax, pos, with_labels, node_size, edge_color, **kwargs): + """2D visualization components""" + nx.draw_networkx_edges( + self, pos=pos, ax=ax, edge_color=edge_color, width=1.5, **kwargs + ) + nx.draw_networkx_nodes( + self, pos=pos, ax=ax, node_size=node_size, + node_color="lightblue", edgecolors="black", linewidths=0.5, **kwargs + ) + if with_labels: + nx.draw_networkx_labels( + self, pos=pos, ax=ax, font_size=8, font_color="black", **kwargs + ) + + def _draw_3d(self, ax, pos, node_size, edge_color, elev, azim, **kwargs): + """3D visualization components""" + ax.view_init(elev=elev, azim=azim) + + coords = np.array(list(pos.values())) + ax.scatter3D( + coords[:, 0], coords[:, 1], coords[:, 2], + s=node_size, c='lightblue', edgecolors='black', linewidth=0.5 + ) + + for u, v in self.edges(): + x = [pos[u][0], pos[v][0]] + y = [pos[u][1], pos[v][1]] + z = [pos[u][2], pos[v][2]] + ax.plot3D(x, y, z, color=edge_color, linewidth=1.5) + + def _add_node_coloring(self, ax, pos, theta, node_size, **kwargs): + """Add node coloring based on directional projection""" + node_colors = self._calculate_node_colors(theta) + + if self.dim == 2: + nodes = nx.draw_networkx_nodes( + self, pos=pos, ax=ax, node_size=node_size, + node_color=node_colors, cmap=plt.cm.viridis, + edgecolors="black", linewidths=0.5, **kwargs + ) + else: + coords = np.array(list(pos.values())) + nodes = ax.scatter3D( + coords[:, 0], coords[:, 1], coords[:, 2], + s=node_size, c=node_colors, cmap=plt.cm.viridis, + edgecolors='black', linewidth=0.5, **kwargs + ) + + norm = plt.Normalize(vmin=min(node_colors), vmax=max(node_colors)) + sm = plt.cm.ScalarMappable(norm=norm, cmap=plt.cm.viridis) + sm.set_array([]) + cbar = plt.colorbar(sm, ax=ax, orientation="vertical", shrink=0.8) + cbar.set_label(f"Projection Value (θ={np.degrees(theta):.1f}°)") + + def _add_bounding_shape(self, ax, center_type): + """Add bounding circle/sphere visualization""" + center = self.get_center(center_type) + radius = self.get_bounding_radius(center_type) + + if self.dim == 2: + circle = plt.Circle( + center[:2], radius, fill=False, linestyle="--", + color="darkred", linewidth=1.2, alpha=0.7 + ) + ax.add_patch(circle) + padding = radius * 0.1 + ax.set_xlim(center[0] - radius - padding, + center[0] + radius + padding) + ax.set_ylim(center[1] - radius - padding, + center[1] + radius + padding) + else: + # sphere wireframe + u = np.linspace(0, 2 * np.pi, 30) + v = np.linspace(0, np.pi, 30) + x = radius * np.outer(np.cos(u), np.sin(v)) + center[0] + y = radius * np.outer(np.sin(u), np.sin(v)) + center[1] + z = radius * np.outer(np.ones(np.size(u)), np.cos(v)) + center[2] + + ax.plot_wireframe( + x, y, z, color="darkred", linewidth=0.5, alpha=0.3, rstride=2, cstride=2 + ) + padding = radius * 0.1 + ax.set_xlim3d(center[0] - radius - padding, + center[0] + radius + padding) + ax.set_ylim3d(center[1] - radius - padding, + center[1] + radius + padding) + ax.set_zlim3d(center[2] - radius - padding, + center[2] + radius + padding) + + def _configure_axes(self, ax): + """Finalize plot appearance""" + if hasattr(ax, 'zaxis'): + ax.grid(True, linestyle=":", linewidth=0.5, alpha=0.7) + ax.xaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) + ax.yaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) + ax.zaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) + ax.set_xlabel('X') + ax.set_ylabel('Y') + ax.set_zlabel('Z') + else: + ax.set_aspect("equal") + ax.grid(True, linestyle=":", linewidth=0.5, alpha=0.7) + + ax.tick_params( + axis="both", + which="both", + bottom=True, + left=True, + labelbottom=True, + labelleft=True + ) diff --git a/ect/ect/types.py b/ect/ect/types.py new file mode 100644 index 0000000..35b0c50 --- /dev/null +++ b/ect/ect/types.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +import numpy as np +from ect.directions import Directions + + +@dataclass +class ECTResult: + matrix: np.ndarray + directions: Directions + thresholds: np.ndarray + vertex_counts: np.ndarray | None = None + edge_counts: np.ndarray | None = None + face_counts: np.ndarray | None = None diff --git a/ect/ect/utils/examples.py b/ect/ect/utils/examples.py new file mode 100644 index 0000000..878ab50 --- /dev/null +++ b/ect/ect/utils/examples.py @@ -0,0 +1,62 @@ +from ect.embed_graph import EmbeddedGraph +from ect.embed_cw import EmbeddedCW +from typing import Type +import numpy as np + + +def create_example_cw(centered=True, center_type='min_max'): + """ + Creates an example EmbeddedCW object with a simple CW complex. If centered is True, the coordinates are centered around the center type, which could be ``mean``, ``min_max`` or ``origin``. + + + Returns: + EmbeddedCW + The example EmbeddedCW object. + """ + G = create_example_graph(centered=False) + K = EmbeddedCW() + K.add_from_embedded_graph(G) + K.add_node('G', 2, 4) + K.add_node('H', 1, 5) + K.add_node('I', 5, 4) + K.add_node('J', 2, 2) + K.add_node('K', 2, 7) + K.add_edges_from([('G', 'A'), ('G', 'H'), ('H', 'D'), ('I', 'E'), + ('I', 'C'), ('J', 'E'), ('K', 'D'), ('K', 'C')]) + K.add_face(['B', 'A', 'G', 'H', 'D']) + K.add_face(['K', 'D', 'C']) + + if centered: + K.set_centered_coordinates(type=center_type) + + return K + + +def create_example_graph(centered=True, center_type='min_max'): + """ + Function to create an example ``EmbeddedGraph`` object. Helpful for testing. If ``centered`` is True, the coordinates are centered using the center type given by ``center_type``, either ``mean`` or ``min_max``. + + Returns: + EmbeddedGraph: An example ``EmbeddedGraph`` object. + + """ + graph = EmbeddedGraph() + + graph.add_node('A', 1, 2) + graph.add_node('B', 3, 4) + graph.add_node('C', 5, 7) + graph.add_node('D', 3, 6) + graph.add_node('E', 4, 3) + graph.add_node('F', 4, 5) + + graph.add_edge('A', 'B') + graph.add_edge('B', 'C') + graph.add_edge('B', 'D') + graph.add_edge('B', 'E') + graph.add_edge('C', 'D') + graph.add_edge('E', 'F') + + if centered: + graph.set_centered_coordinates(center_type) + + return graph diff --git a/ect/ect/utils/naming.py b/ect/ect/utils/naming.py new file mode 100644 index 0000000..3824f9f --- /dev/null +++ b/ect/ect/utils/naming.py @@ -0,0 +1,24 @@ + +def next_vert_name(s, num_verts=1): + """Generate sequential vertex names (alphabetical or numerical).""" + if isinstance(s, int): + return [s + i + 1 for i in range(num_verts)] if num_verts > 1 else s + 1 + + def increment_char(c): + return 'A' if c == 'Z' else chr(ord(c) + 1) + + def increment_str(s): + chars = list(s) + for i in reversed(range(len(chars))): + chars[i] = increment_char(chars[i]) + if chars[i] != 'A': + break + elif i == 0: + return 'A' + ''.join(chars) + return ''.join(chars) + + # handle multiple increments + names = [s] + for _ in range(num_verts): + names.append(increment_str(names[-1])) + return names[1:] if num_verts > 1 else names[1] From 1de92dc54db85f10e3ea2442831afbfd93270054 Mon Sep 17 00:00:00 2001 From: yemeen Date: Mon, 10 Mar 2025 15:44:18 -0400 Subject: [PATCH 13/35] add smooth function to ECTResult --- ect/ect/directions.py | 125 ++++++++++++++++------ ect/ect/ect_graph.py | 234 ++++++++++++++++------------------------- ect/ect/embed_graph.py | 83 +++++++++------ ect/ect/results.py | 85 +++++++++++++++ 4 files changed, 319 insertions(+), 208 deletions(-) create mode 100644 ect/ect/results.py diff --git a/ect/ect/directions.py b/ect/ect/directions.py index dc8d09c..003fa40 100644 --- a/ect/ect/directions.py +++ b/ect/ect/directions.py @@ -1,5 +1,5 @@ import numpy as np -from typing import Union, Optional, List, Sequence +from typing import Optional, Sequence from enum import Enum @@ -14,89 +14,144 @@ class Directions: Manages direction vectors for ECT calculations. Supports uniform, random, or custom sampling of directions. - Example: - # Uniform sampling - dirs = Directions(num_dirs=8) + Examples: + # Uniform sampling in 2D (default) + dirs = Directions.uniform(num_dirs=8) - # Random sampling + # Uniform sampling in 3D + dirs = Directions.uniform(num_dirs=10, dim=3) + + # Random sampling in 2D dirs = Directions.random(num_dirs=10, seed=42) - # Custom angles + # Custom angles (2D only) dirs = Directions.from_angles([0, np.pi/4, np.pi/2]) - # Custom vectors - dirs = Directions.from_vectors([(1,0), (1,1), (0,1)]) + # Custom vectors in any dimension + dirs = Directions.from_vectors([(1,0,0), (0,1,0), (0,0,1)]) """ def __init__(self, num_dirs: int = 360, sampling: Sampling = Sampling.UNIFORM, + dim: int = 2, endpoint: bool = False, seed: Optional[int] = None): - self.num_dirs = num_dirs self.sampling = sampling + self.dim = dim self.endpoint = endpoint - if seed is not None: - np.random.seed(seed) - + self._rng = np.random.RandomState(seed) self._thetas = None self._vectors = None self._initialize_directions() def _initialize_directions(self): - """Initialize direction angles based on strategy""" + """ + Initialize direction vectors using the chosen sampling strategy. + For 2D, the angles may be stored; for n-dim (n>2) the vectors are generated from + random normal samples and normalized to lie on the unit sphere. + """ if self.sampling == Sampling.UNIFORM: - self._thetas = np.linspace(0, 2*np.pi, - self.num_dirs, - endpoint=self.endpoint) + if self.dim == 2: + self._thetas = np.linspace( + 0, 2*np.pi, self.num_dirs, endpoint=self.endpoint) + else: + self._vectors = self._rng.randn(self.num_dirs, self.dim) + self._vectors /= np.linalg.norm(self._vectors, + axis=1, keepdims=True) elif self.sampling == Sampling.RANDOM: - self._thetas = np.random.uniform(0, 2*np.pi, self.num_dirs) - self._thetas.sort() # sort for consistency + if self.dim == 2: + self._thetas = self._rng.uniform(0, 2*np.pi, self.num_dirs) + self._thetas.sort() + else: + self._vectors = self._rng.randn(self.num_dirs, self.dim) + self._vectors /= np.linalg.norm(self._vectors, + axis=1, keepdims=True) @classmethod - def random(cls, num_dirs: int = 360, seed: Optional[int] = None) -> 'Directions': - """Create instance with random direction sampling""" - return cls(num_dirs, Sampling.RANDOM, seed=seed) + def uniform(cls, num_dirs: int = 360, dim: int = 2, + endpoint: bool = False, seed: Optional[int] = None) -> 'Directions': + """ + Factory method for uniform sampling. + + Parameters: + num_dirs: Number of direction vectors. + dim: Dimension of the space (default 2). + endpoint: Whether to include the endpoint (for 2D angles). + seed: Optional random seed. + """ + return cls(num_dirs, Sampling.UNIFORM, dim, endpoint, seed) + + @classmethod + def random(cls, num_dirs: int = 360, dim: int = 2, + seed: Optional[int] = None) -> 'Directions': + """ + Factory method for random sampling. + + Parameters: + num_dirs: Number of direction vectors. + dim: Dimension of the space. + seed: optional random seed. + """ + return cls(num_dirs, Sampling.RANDOM, dim, seed=seed) @classmethod def from_angles(cls, angles: Sequence[float]) -> 'Directions': - """Create instance from custom angles""" - instance = cls(len(angles), Sampling.CUSTOM) + """ + Create an instance for custom angles (2D only). + """ + instance = cls(len(angles), Sampling.CUSTOM, dim=2) instance._thetas = np.array(angles) return instance @classmethod def from_vectors(cls, vectors: Sequence[tuple]) -> 'Directions': - """Create instance from custom direction vectors""" - vectors = np.array(vectors) + """ + Create an instance from custom direction vectors. + Works in any number of dimensions. + """ + vectors = np.array(vectors, dtype=float) norms = np.linalg.norm(vectors, axis=1, keepdims=True) + if np.any(norms == 0): + raise ValueError("Zero-magnitude vectors are not allowed") normalized = vectors / norms - - instance = cls(len(vectors), Sampling.CUSTOM) + instance = cls(len(vectors), Sampling.CUSTOM, dim=vectors.shape[1]) instance._vectors = normalized - instance._thetas = np.arctan2(normalized[:, 1], normalized[:, 0]) + if instance.dim == 2: + instance._thetas = np.arctan2(normalized[:, 1], normalized[:, 0]) return instance @property def thetas(self) -> np.ndarray: - """Get angles for all directions""" + """Get the angles for 2D directions. Raises an error if dim > 2.""" + if self.dim != 2: + raise ValueError( + "Angle representation is only available for 2D directions.") + if self._thetas is None: + # Compute the angles from the vectors. + self._thetas = np.arctan2(self.vectors[:, 1], self.vectors[:, 0]) return self._thetas @property def vectors(self) -> np.ndarray: - """Get unit vectors for all directions""" + """Get unit direction vectors. + For 2D, they are computed from thetas if not already created. + For n-dim (n>2), they should be available. + """ if self._vectors is None: - self._vectors = np.column_stack([ - np.cos(self._thetas), - np.sin(self._thetas) - ]) + if self.dim == 2: + self._vectors = np.column_stack( + (np.cos(self._thetas), np.sin(self._thetas))) + else: + raise ValueError( + "Direction vectors for dimensions >2 should be generated during initialization.") return self._vectors def __len__(self) -> int: return self.num_dirs def __getitem__(self, idx) -> np.ndarray: - """Get direction vector at index""" + """Return the direction vector at index idx.""" return self.vectors[idx] diff --git a/ect/ect/ect_graph.py b/ect/ect/ect_graph.py index 9d94c10..5399949 100644 --- a/ect/ect/ect_graph.py +++ b/ect/ect/ect_graph.py @@ -6,6 +6,8 @@ from ect.embed_cw import EmbeddedCW from ect.embed_graph import EmbeddedGraph from ect.directions import Directions +from ect.results import ECTResult +from functools import wraps class ECT: @@ -37,7 +39,7 @@ def __init__(self, Initialize ECT calculator. Args: - num_dirs: Number of uniformly sampled directions (ignored if directions provided) + num_dirs: Number of directions for uniform sampling (ignored if directions provided) num_thresh: Number of threshold values directions: Optional Directions object for custom sampling bound_radius: Optional radius for bounding circle @@ -46,14 +48,29 @@ def __init__(self, self.directions = directions self.num_dirs = len(directions) else: - if num_dirs is None: - num_dirs = 360 - self.num_dirs = num_dirs - self.directions = Directions(num_dirs) + self.num_dirs = num_dirs or 360 + self.directions = None self.num_thresh = num_thresh self.set_bounding_radius(bound_radius) + def _ensure_valid_directions(calculation_method): + """ + Decorator to ensure directions match graph dimension. + Reinitializes directions if dimensions don't match. + """ + @wraps(calculation_method) + def wrapper(ect_instance, graph, *args, **kwargs): + if ect_instance.directions is None: + ect_instance.directions = Directions.uniform( + ect_instance.num_dirs, dim=graph.dim) + elif ect_instance.directions.dim != graph.dim: + ect_instance.directions = Directions.uniform( + ect_instance.num_dirs, dim=graph.dim) + + return calculation_method(ect_instance, graph, *args, **kwargs) + return wrapper + def set_bounding_radius(self, bound_radius): """ Manually sets the radius of the bounding circle centered at the origin for the ECT object. @@ -108,38 +125,64 @@ def get_radius_and_thresh(self, G, bound_radius): return r, r_threshes - def calculate_ecc(self, G, theta, bound_radius=None, return_counts=False): - """ - Function to compute the Euler Characteristic Curve (ECC) of an `EmbeddedGraph`. + @_ensure_valid_directions + def calculate(self, graph, theta=None, bound_radius=None, return_counts=False): + """Calculate ECT - directions are validated by decorator""" + # Initialize directions if needed + if self.directions is None: + self.directions = Directions.uniform( + self.num_dirs, dim=graph.dim) - Parameters: - G (nx.Graph): The graph to compute the ECC for. - theta (float): The angle (in radians) for the direction function. - bound_radius (float, optional): Radius for threshold range. Default is None. - return_counts (bool, optional): Whether to return vertex, edge, and face counts. Default is False. + r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) + coords = graph.coord_matrix + edges = graph.edge_indices - Returns: - numpy.ndarray: ECC values at each threshold. - (Optional) Tuple of counts: (ecc, vertex_count, edge_count, face_count) - """ - r, r_threshes = self.get_radius_and_thresh(G, bound_radius) + if theta is None: + vertex_projections = np.matmul(coords, self.directions.vectors.T) + else: + vertex_projections = np.matmul( + coords, Directions.from_angles([theta]).vectors.T) + + edge_maxes = np.maximum( + vertex_projections[edges[:, 0]], vertex_projections[edges[:, 1]]) + + face_maxes = np.empty((0, self.num_dirs)) + if isinstance(graph, EmbeddedCW) and len(graph.faces) > 0: + node_to_index = {n: i for i, n in enumerate(graph.node_list)} + face_indices = [ + [node_to_index[v] for v in face] + for face in graph.faces + ] + face_maxes = np.array([ + np.max(vertex_projections[face, :], axis=0) + for face in face_indices + ]) + + return self.calculate_euler_chars( + vertex_projections, edge_maxes, face_maxes, r_threshes + ) + + @_ensure_valid_directions + def calculate_ecc(self, graph, theta, bound_radius=None, return_counts=False): + """Calculate ECC - directions are validated by decorator""" + r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) r_threshes = np.array(r_threshes) # Sort vertices and edges based on projection - v_list, g = G.sort_vertices(theta, return_g=True) + v_list, g = graph.sort_vertices(theta, return_g=True) g_list = np.array([g[v] for v in v_list]) sorted_g_list = np.sort(g_list) - e_list, g_e = G.sort_edges(theta, return_g=True) + e_list, g_e = graph.sort_edges(theta, return_g=True) g_e_list = np.array([g_e[e] for e in e_list]) sorted_g_e_list = np.sort(g_e_list) vertex_count = np.searchsorted(sorted_g_list, r_threshes, side='right') edge_count = np.searchsorted(sorted_g_e_list, r_threshes, side='right') - if isinstance(G, EmbeddedCW): - f_list, g_f = G.sort_faces(theta, return_g=True) + if isinstance(graph, EmbeddedCW): + f_list, g_f = graph.sort_faces(theta, return_g=True) g_f_list = np.array([g_f[f] for f in f_list]) sorted_g_f_list = np.sort(g_f_list) face_count = np.searchsorted( @@ -155,86 +198,38 @@ def calculate_ecc(self, G, theta, bound_radius=None, return_counts=False): return ecc @staticmethod - @jit(nopython=True, parallel=True) - def calculate_euler_chars(projections, edge_maxes, face_maxes, thresholds): - """Calculate the euler characteristic for each direction in parallel - - Parameters: - projections (np.array): - The projections of the vertices. - edge_maxes (np.array): - The projections of the edges. - thresholds (np.array): - The thresholds to compute the ECT at. - - Returns: - np.array: - The ECT matrix of size (num_dirs, num_thresh). - """ - num_vertices, num_dir = projections.shape - num_edges = edge_maxes.shape[0] - num_thresh = len(thresholds) + @jit(nopython=True, parallel=True, fastmath=True) + def _calculate_euler_chars_numba(projections, edge_maxes, face_maxes, thresholds): + """Pure numerical computation of Euler characteristics""" + num_dir = projections.shape[1] + num_thresh = thresholds.shape[0] result = np.empty((num_dir, num_thresh), dtype=np.int32) - # parallelize over directions - for i in prange(num_dir): - for j in range(num_thresh): - thresh = thresholds[j] - vert_count = 0 - edge_count = 0 - - for v in range(num_vertices): - if projections[v, i] <= thresh: - vert_count += 1 - for e in range(num_edges): - if edge_maxes[e, i] <= thresh: - edge_count += 1 + sorted_projections = np.empty_like(projections) + sorted_edge_maxes = np.empty_like(edge_maxes) - result[i, j] = vert_count - edge_count + for i in prange(num_dir): + sorted_projections[:, i] = np.sort(projections[:, i]) + sorted_edge_maxes[:, i] = np.sort(edge_maxes[:, i]) + + for j in prange(num_thresh): + thresh = thresholds[j] + for i in range(num_dir): + v = np.searchsorted( + sorted_projections[:, i], thresh, side='right') + e = np.searchsorted( + sorted_edge_maxes[:, i], thresh, side='right') + f = np.searchsorted( + face_maxes[:, i], thresh, side='right') if face_maxes.shape[0] > 0 else 0 + result[i, j] = v - e + f return result - def calculate(self, graph: Union[EmbeddedGraph, EmbeddedCW], theta: Optional[float] = None, bound_radius=None, return_counts=False): - """Vectorized ECT calculation using optimized numpy operations - - Parameters: - graph (EmbeddedGraph/EmbeddedCW): - The input graph or CW complex. - bound_radius (float): - If None, uses the following in order: (i) the bounding radius stored in the class; or if not available (ii) the bounding radius of the given graph. Otherwise, should be a postive float :math:`R` where the ECC will be computed at thresholds in :math:`[-R,R]`. Default is None. - - Returns: - np.array: - The ECT matrix of size (num_dirs, num_thresh). - """ - r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) - coords = graph.coord_matrix - edges = graph.edge_indices - - if theta is None: - vertex_projections = np.matmul(coords, self.directions.vectors.T) - else: - vertex_projections = np.matmul( - coords, Directions.from_angles([theta]).vectors.T) - - edge_maxes = np.maximum( - vertex_projections[edges[:, 0]], vertex_projections[edges[:, 1]]) - - face_maxes = np.empty((0, self.num_dirs)) - if isinstance(graph, EmbeddedCW) and len(graph.faces) > 0: - node_to_index = {n: i for i, n in enumerate(graph.node_list)} - face_indices = [ - [node_to_index[v] for v in face] - for face in graph.faces - ] - face_maxes = np.array([ - np.max(vertex_projections[face, :], axis=0) - for face in face_indices - ]) - - return self.calculate_euler_chars( - vertex_projections, edge_maxes, face_maxes, r_threshes - ) + def calculate_euler_chars(self, projections, edge_maxes, face_maxes, thresholds): + """Calculate Euler characteristics and wrap in ECTResult""" + result = ECT._calculate_euler_chars_numba( + projections, edge_maxes, face_maxes, thresholds) + return ECTResult(result, self.directions, self.threshes) def calculate_sect(self, ect_matrix=None): """ @@ -300,37 +295,6 @@ def plot_ecc(self, graph, theta, bound_radius=None, draw_counts=False): plt.xlabel('$a$') plt.ylabel(r'$\chi(K_a)$') - def plot_ect(self): - """ - Function to plot the Euler Characteristic Transform (ECT) matrix. Note that the ECT matrix must be calculated before calling this function. - - The resulting plot will have the angle on the x-axis and the threshold on the y-axis. - """ - - # Make meshgrid. - # Add back the 2pi to thetas for the pcolormesh - thetas = np.concatenate((self.thetas, [2*np.pi])) - X, Y = np.meshgrid(thetas, self.threshes) - M = np.zeros_like(X) - - M[:, :-1] = self.ECT_matrix.T - M[:, -1] = M[:, 0] # Add 2pi direction - - plt.pcolormesh(X, Y, M, cmap='viridis') - plt.colorbar() - - ax = plt.gca() - ax.set_xticks(np.linspace(0, 2*np.pi, 9)) - ax.set_xticklabels([ - r'$0$', r'$\frac{\pi}{4}$', r'$\frac{\pi}{2}$', - r'$\frac{3\pi}{4}$', r'$\pi$', r'$\frac{5\pi}{4}$', - r'$\frac{3\pi}{2}$', r'$\frac{7\pi}{4}$', r'$2\pi$' - ]) - - plt.xlabel(r'$\omega$') - plt.ylabel(r'$a$') - plt.title(r'ECT of Input Graph') - def plot_sect(self): """ Function to plot the Smooth Euler Characteristic Transform (SECT) matrix. Note that the SECT matrix must be calculated before calling this function. @@ -371,19 +335,3 @@ def plot_sect(self): plt.ylabel(r'$t$') plt.title(r'SECT of Input Graph') - - def plot(self, plot_type): - """ - Function to plot the ECT or SECT matrix. The type parameter should be either 'ECT' or 'SECT'. - - Parameters: - plot_type : str - The type of plot to make. Either 'ECT' or 'SECT'. - """ - - if plot_type == 'ECT': - self.plot_ect() - elif plot_type == 'SECT': - self.plot_sect() - else: - raise ValueError('plot_type must be either "ECT" or "SECT".') diff --git a/ect/ect/embed_graph.py b/ect/ect/embed_graph.py index 5e3b90f..45dc9f3 100644 --- a/ect/ect/embed_graph.py +++ b/ect/ect/embed_graph.py @@ -2,7 +2,7 @@ import numpy as np import matplotlib.pyplot as plt from sklearn.decomposition import PCA -from utils import next_vert_name +from ect.utils.naming import next_vert_name from typing import Dict, List, Tuple, Optional @@ -32,17 +32,21 @@ def __init__(self): super().__init__() self._node_list = [] self._node_to_index = {} - self._coord_matrix = np.empty((0, 0)) + self._coord_matrix = None @property def coord_matrix(self): - """Return the N x D coordinate matrix.""" + """Return the N x D coordinate matrix""" + if self._coord_matrix is None: + return np.empty((0, 0)) return self._coord_matrix @property def dim(self): """Return the dimension of the embedded coordinates""" - return self._coord_matrix.shape[1] if self._coord_matrix.size > 0 else 0 + if self._coord_matrix is None: + return 0 + return self._coord_matrix.shape[1] @property def node_list(self): @@ -60,6 +64,13 @@ def position_dict(self): return {node: self._coord_matrix[i] for i, node in enumerate(self._node_list)} + @property + def edge_indices(self): + """Return edges as array of index pairs""" + edges = np.array([(self._node_to_index[u], self._node_to_index[v]) + for u, v in self.edges()], dtype=int) + return edges if len(edges) > 0 else np.empty((0, 2), dtype=int) + # ====================================== # Node Management # ====================================== @@ -70,15 +81,15 @@ def wrapper(self, *args, **kwargs): coords = next((arg for arg in args if isinstance( arg, (list, np.ndarray))), None) if coords is not None: - coords = np.array(coords, dtype=float) + coords = np.asarray(coords, dtype=float) if coords.ndim != 1: raise ValueError("Coordinates must be a 1D array") - if len(self._node_list) == 0: - self._coord_matrix = np.empty((0, coords.size)) - elif coords.size != self.dim: - raise ValueError( - f"Coordinates must have dimension {self.dim}") + # Skip dimension check for first node + if len(self._node_list) > 0: + if coords.size != self._coord_matrix.shape[1]: + raise ValueError( + f"Coordinates must have dimension {self._coord_matrix.shape[1]}") return func(self, *args, **kwargs) return wrapper @@ -86,29 +97,44 @@ def wrapper(self, *args, **kwargs): def _validate_node(exists=True): """Validates if a node exists or not already""" def decorator(func): - def wrapper(self, node_id, *args, **kwargs): + def wrapper(self, *args, **kwargs): + # Handle both positional and keyword arguments + if args: + node_id = args[0] + else: + node_id = kwargs.get('node_id') or kwargs.get('node_id1') + node_exists = node_id in self._node_to_index if exists and not node_exists: raise ValueError(f"Node {node_id} does not exist") if not exists and node_exists: raise ValueError(f"Node {node_id} already exists") - return func(self, node_id, *args, **kwargs) + return func(self, *args, **kwargs) return wrapper return decorator @_validate_coords @_validate_node(exists=False) def add_node(self, node_id, coord): - """Add a vertex to the graph. - If the vertex name is given as None, it will be assigned via the next_vert_name method. + """Add a vertex to the graph.""" + coord = np.asarray(coord, dtype=float) + # Debug + print(f"Adding node {node_id} with coord shape: {coord.shape}") + + if len(self._node_list) == 0: + print("First node, initializing matrix") # Debug + self._coord_matrix = coord.reshape(1, -1) + # Debug + print(f"Matrix shape after init: {self._coord_matrix.shape}") + else: + print(f"Current matrix shape: {self._coord_matrix.shape}") # Debug + coord_reshaped = coord.reshape(1, -1) + print(f"New coord shape: {coord_reshaped.shape}") # Debug + self._coord_matrix = np.vstack( + [self._coord_matrix, coord_reshaped]) - Parameters: - node_id (hashable like int or str, or None) : The name of the vertex to add. - coordinates (array-like) : The coordinates of the vertex being added. - """ self._node_list.append(node_id) self._node_to_index[node_id] = len(self._node_list) - 1 - self._coord_matrix = np.vstack([self._coord_matrix, coord]) super().add_node(node_id) def add_nodes_from(self, nodes_with_coords): @@ -315,18 +341,15 @@ def pca_projection(self, target_dim=2): self._coord_matrix = pca.fit_transform(self._coord_matrix) self.dim = target_dim - @_validate_node(exists=True) - def add_edge(self, node_id1, node_id2): - """ - Adds an edge between the vertices node_id1 and node_id2 if they exist. - - Parameters: - node_id1 (str): - The first vertex of the edge. - node_id2 (str): - The second vertex of the edge. + def _validate_node_exists(self, node_id): + """Check if a node exists in the graph""" + if node_id not in self._node_to_index: + raise ValueError(f"Node {node_id} does not exist") - """ + def add_edge(self, node_id1, node_id2): + """Add an edge between two nodes""" + self._validate_node_exists(node_id1) + self._validate_node_exists(node_id2) super().add_edge(node_id1, node_id2) # =================== diff --git a/ect/ect/results.py b/ect/ect/results.py new file mode 100644 index 0000000..7cdf496 --- /dev/null +++ b/ect/ect/results.py @@ -0,0 +1,85 @@ +import matplotlib.pyplot as plt +import numpy as np +from ect.directions import Sampling + + +class ECTResult(np.ndarray): + """ + A numpy ndarray subclass that carries ECT metadata and plotting capabilities + Acts like a regular matrix but with added visualization methods + """ + def __new__(cls, matrix, directions, thresholds): + obj = np.asarray(matrix).view(cls) + obj.directions = directions + obj.thresholds = thresholds + return obj + + def __array_finalize__(self, obj): + if obj is None: + return + self.directions = getattr(obj, 'directions', None) + self.thresholds = getattr(obj, 'thresholds', None) + + def plot(self, ax=None): + """Plot ECT matrix with proper handling for both 2D and 3D""" + ax = ax or plt.gca() + + if self.thresholds is None: + self.thresholds = np.linspace(-1, 1, self.shape[1]) + + if self.directions.dim == 2: + # 2D case - use angle representation + if self.directions.sampling == Sampling.UNIFORM and not self.directions.endpoint: + plot_thetas = np.concatenate( + [self.directions.thetas, [2*np.pi]]) + # Circular closure + ect_data = np.hstack([self.T, self.T[:, [0]]]) + else: + plot_thetas = self.directions.thetas + ect_data = self.T + + X = plot_thetas + Y = self.thresholds + + else: + X = np.arange(self.shape[0]) + Y = self.thresholds + ect_data = self.T + + ax.set_xlabel('Direction Index') + + mesh = ax.pcolormesh(X[None, :], Y[:, None], ect_data, + cmap='viridis', shading='nearest') + plt.colorbar(mesh, ax=ax) + + if self.directions.dim == 2: + ax.set_xlabel(r'Direction $\omega$ (radians)') + if self.directions.sampling == Sampling.UNIFORM: + ax.set_xticks(np.linspace(0, 2*np.pi, 9)) + ax.set_xticklabels([ + r'$0$', r'$\frac{\pi}{4}$', r'$\frac{\pi}{2}$', + r'$\frac{3\pi}{4}$', r'$\pi$', r'$\frac{5\pi}{4}$', + r'$\frac{3\pi}{2}$', r'$\frac{7\pi}{4}$', r'$2\pi$' + ]) + + ax.set_ylabel(r'Threshold $a$') + return ax + + + def smooth(self): + """ + Function to calculate the Smooth Euler Characteristic Transform (SECT) from the ECT matrix. + + Returns: + ECTResult: The SECT matrix with same directions and thresholds + """ + avg = np.average(self, axis=1) + + #subtract the average from each row + centered_ect = self - avg[:, np.newaxis] + + # take the cumulative sum of each row to get the SECT + sect = np.cumsum(centered_ect, axis=1) + + # Return as ECTResult to maintain plotting capability + return ECTResult(sect, self.directions, self.thresholds) From db2f70f3098b88c5704452f7c7870971628fc827 Mon Sep 17 00:00:00 2001 From: yemeen Date: Tue, 11 Mar 2025 14:54:14 -0400 Subject: [PATCH 14/35] Update graph operations and ECC calculation --- ect/ect/__init__.py | 4 +- ect/ect/directions.py | 3 +- ect/ect/ect_graph.py | 376 ++++++++++++------------------------ ect/ect/embed_graph.py | 19 +- ect/ect/results.py | 31 ++- ect/ect/utils/__init__.py | 3 + ect/ect/utils/face_check.py | 16 ++ ect/ect/utils/utils.py | 87 --------- 8 files changed, 187 insertions(+), 352 deletions(-) create mode 100644 ect/ect/utils/__init__.py create mode 100644 ect/ect/utils/face_check.py delete mode 100644 ect/ect/utils/utils.py diff --git a/ect/ect/__init__.py b/ect/ect/__init__.py index 6c1db5d..c590515 100644 --- a/ect/ect/__init__.py +++ b/ect/ect/__init__.py @@ -3,5 +3,5 @@ # import .embed_graph as embed_graph from .ect_graph import ECT -from .embed_graph import EmbeddedGraph, create_example_graph -from .embed_cw import EmbeddedCW, create_example_cw +from .embed_graph import EmbeddedGraph +from .embed_cw import EmbeddedCW diff --git a/ect/ect/directions.py b/ect/ect/directions.py index 003fa40..620ea81 100644 --- a/ect/ect/directions.py +++ b/ect/ect/directions.py @@ -1,7 +1,7 @@ -import numpy as np from typing import Optional, Sequence from enum import Enum +import numpy as np class Sampling(Enum): UNIFORM = "uniform" @@ -58,6 +58,7 @@ def _initialize_directions(self): self._thetas = np.linspace( 0, 2*np.pi, self.num_dirs, endpoint=self.endpoint) else: + # generate random normal samples and normalize to lie on the unit sphere self._vectors = self._rng.randn(self.num_dirs, self.dim) self._vectors /= np.linalg.norm(self._vectors, axis=1, keepdims=True) diff --git a/ect/ect/ect_graph.py b/ect/ect/ect_graph.py index 5399949..e7c3715 100644 --- a/ect/ect/ect_graph.py +++ b/ect/ect/ect_graph.py @@ -1,3 +1,5 @@ +from functools import wraps + import matplotlib.pyplot as plt import numpy as np from numba import jit, prange @@ -7,7 +9,7 @@ from ect.embed_graph import EmbeddedGraph from ect.directions import Directions from ect.results import ECTResult -from functools import wraps + class ECT: @@ -21,317 +23,191 @@ class ECT: The number of directions to consider in the matrix. num_thresh (int): The number of thresholds to consider in the matrix. + directions (Directions): + The directions to consider for projection. bound_radius (int): Either ``None``, or a positive radius of the bounding circle. - ect_matrix (np.array): - The matrix to store the ECT. - sect_matrix (np.array): - The matrix to store the SECT. - """ def __init__(self, - num_dirs: Optional[int] = None, - num_thresh: int = 360, directions: Optional[Directions] = None, + *, + num_dirs: Optional[int] = None, + num_thresh: Optional[int] = None, bound_radius: Optional[float] = None): - """ - Initialize ECT calculator. - + """Initialize ECT calculator with either a Directions object or sampling parameters + Args: - num_dirs: Number of directions for uniform sampling (ignored if directions provided) - num_thresh: Number of threshold values - directions: Optional Directions object for custom sampling + directions: Optional pre-configured Directions object + num_dirs: Number of directions to sample (ignored if directions provided) + num_thresh: Number of threshold values (required if directions not provided) bound_radius: Optional radius for bounding circle """ if directions is not None: self.directions = directions self.num_dirs = len(directions) + if num_thresh is None: + self.num_thresh = self.num_dirs else: - self.num_dirs = num_dirs or 360 self.directions = None + self.num_dirs = num_dirs or 360 + self.num_thresh = num_thresh or 360 self.num_thresh = num_thresh - self.set_bounding_radius(bound_radius) + self.bound_radius = None + self.threshes = None + if bound_radius is not None: + self.set_bounding_radius(bound_radius) - def _ensure_valid_directions(calculation_method): - """ - Decorator to ensure directions match graph dimension. - Reinitializes directions if dimensions don't match. - """ + def _ensure_valid_directions(self, calculation_method): + """Ensures directions match graph dimension, creating if needed""" @wraps(calculation_method) def wrapper(ect_instance, graph, *args, **kwargs): - if ect_instance.directions is None: - ect_instance.directions = Directions.uniform( - ect_instance.num_dirs, dim=graph.dim) - elif ect_instance.directions.dim != graph.dim: - ect_instance.directions = Directions.uniform( - ect_instance.num_dirs, dim=graph.dim) - + if ect_instance.directions is None or ect_instance.directions.dim != graph.dim: + ect_instance.directions = Directions.uniform(ect_instance.num_dirs, dim=graph.dim) return calculation_method(ect_instance, graph, *args, **kwargs) return wrapper - def set_bounding_radius(self, bound_radius): - """ - Manually sets the radius of the bounding circle centered at the origin for the ECT object. - - Parameters: - bound_radius (int): - Either None, or a positive radius of the bounding circle. - """ - self.bound_radius = bound_radius + def set_bounding_radius(self, radius: Optional[float]): + """Sets the bounding radius and updates thresholds""" + if radius is not None and radius <= 0: + raise ValueError(f'Bounding radius must be positive, got {radius}') + + self.bound_radius = radius + if radius is not None: + self.threshes = np.linspace(-radius, radius, self.num_thresh) + + def get_thresholds(self, graph: Union[EmbeddedGraph, EmbeddedCW], override_radius: Optional[float] = None): + """Gets thresholds based on priority: override_radius > instance radius > graph radius""" + if override_radius is not None: + if override_radius <= 0: + raise ValueError(f'Bounding radius must be positive, got {override_radius}') + return override_radius, np.linspace(-override_radius, override_radius, self.num_thresh) + + if self.bound_radius is not None: + return self.bound_radius, self.thresholds + + graph_radius = graph.get_bounding_radius() + return graph_radius, np.linspace(-graph_radius, graph_radius, self.num_thresh) - if self.bound_radius is None: - self.threshes = None - else: - self.threshes = np.linspace(-bound_radius, - bound_radius, self.num_thresh) - - def get_radius_and_thresh(self, G, bound_radius): - """ - An internally used function to get the bounding radius and thresholds for the ECT calculation. + @_ensure_valid_directions + def calculate(self, graph: Union[EmbeddedGraph, EmbeddedCW], theta=None, bound_radius=None): + """Calculate Euler Characteristic Transform (ECT) for a given graph and direction theta - Parameters: - G (EmbeddedGraph / EmbeddedCW): + Args: + graph (EmbeddedGraph/EmbeddedCW): The input graph to calculate the ECT for. + theta (float): + The angle in :math:`[0,2\pi]` for the direction to calculate the ECT. bound_radius (float): If None, uses the following in order: (i) the bounding radius stored in the class; or if not available (ii) the bounding radius of the given graph. Otherwise, should be a postive float :math:`R` where the ECC will be computed at thresholds in :math:`[-R,R]`. Default is None. - - Returns: - float, np.array - The bounding radius and the thresholds for the ECT calculation. - """ - # Either use the global radius and the set self.threshes; or use the tight bounding box and calculate - # the thresholds from that. - if bound_radius is None: - # First try to get the internally stored bounding radius - if self.bound_radius is not None: - r = self.bound_radius - r_threshes = self.threshes - - # If the bounding radius is not set, use the global bounding radius - else: - r = G.get_bounding_radius() - r_threshes = np.linspace(-r, r, self.num_thresh) - - else: - # The user wants to use a different bounding radius - if bound_radius <= 0: - raise ValueError( - f'Bounding radius given was {bound_radius}, but must be a positive number.') - r = bound_radius - r_threshes = np.linspace(-r, r, self.num_thresh) - - return r, r_threshes - - @_ensure_valid_directions - def calculate(self, graph, theta=None, bound_radius=None, return_counts=False): - """Calculate ECT - directions are validated by decorator""" - # Initialize directions if needed - if self.directions is None: - self.directions = Directions.uniform( - self.num_dirs, dim=graph.dim) - - r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) + radius, thresholds = self.get_thresholds(graph, bound_radius) coords = graph.coord_matrix edges = graph.edge_indices if theta is None: - vertex_projections = np.matmul(coords, self.directions.vectors.T) + node_projections = self._compute_node_projections(coords, self.directions) else: - vertex_projections = np.matmul( - coords, Directions.from_angles([theta]).vectors.T) + node_projections = self._compute_node_projections(coords, Directions.from_angles([theta])) - edge_maxes = np.maximum( - vertex_projections[edges[:, 0]], vertex_projections[edges[:, 1]]) + simplex_projections = self._compute_simplex_projections(node_projections, simplex) face_maxes = np.empty((0, self.num_dirs)) if isinstance(graph, EmbeddedCW) and len(graph.faces) > 0: node_to_index = {n: i for i, n in enumerate(graph.node_list)} - face_indices = [ - [node_to_index[v] for v in face] - for face in graph.faces - ] - face_maxes = np.array([ - np.max(vertex_projections[face, :], axis=0) - for face in face_indices - ]) - - return self.calculate_euler_chars( - vertex_projections, edge_maxes, face_maxes, r_threshes - ) - - @_ensure_valid_directions - def calculate_ecc(self, graph, theta, bound_radius=None, return_counts=False): - """Calculate ECC - directions are validated by decorator""" - r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) - - r_threshes = np.array(r_threshes) - - # Sort vertices and edges based on projection - v_list, g = graph.sort_vertices(theta, return_g=True) - g_list = np.array([g[v] for v in v_list]) - sorted_g_list = np.sort(g_list) - - e_list, g_e = graph.sort_edges(theta, return_g=True) - g_e_list = np.array([g_e[e] for e in e_list]) - sorted_g_e_list = np.sort(g_e_list) - - vertex_count = np.searchsorted(sorted_g_list, r_threshes, side='right') - edge_count = np.searchsorted(sorted_g_e_list, r_threshes, side='right') - - if isinstance(graph, EmbeddedCW): - f_list, g_f = graph.sort_faces(theta, return_g=True) - g_f_list = np.array([g_f[f] for f in f_list]) - sorted_g_f_list = np.sort(g_f_list) - face_count = np.searchsorted( - sorted_g_f_list, r_threshes, side='right') - else: - face_count = np.zeros_like(r_threshes, dtype=np.int32) - - ecc = vertex_count - edge_count + face_count - - if return_counts: - return ecc, vertex_count, edge_count, face_count - else: - return ecc - + face_indices = [[node_to_index[v] for v in face] for face in graph.faces] + face_maxes = np.array([np.max(node_projections[face, :], axis=0) for face in face_indices]) + + return self._calculate_euler_chars(node_projections, edge_maxes, face_maxes, thresholds) + + def _compute_node_projections(self, coords, directions): + """Compute inner products of coordinates with directions""" + return np.matmul(coords, directions.vectors.T) + + def _compute_simplex_projections(vertex_projections, simplices): + """For all k ≥ 0, compute max projections of each k-simplex""" + max_projections = [] + for k in range(len(simplices)): + if k == 0: + max_projections.append(vertex_projections.copy()) + else: + k_simplices = simplices[k] + k_proj = np.array([ + np.max(vertex_projections[s, :], axis=0) + for s in k_simplices + ]) + max_projections.append(k_proj) + return max_projections + @staticmethod @jit(nopython=True, parallel=True, fastmath=True) - def _calculate_euler_chars_numba(projections, edge_maxes, face_maxes, thresholds): - """Pure numerical computation of Euler characteristics""" - num_dir = projections.shape[1] + def _sort_projections(simplex_projections_list, thresholds): + """Sort projections and count occurrences of each threshold""" + num_dir = simplex_projections_list[0].shape[1] num_thresh = thresholds.shape[0] result = np.empty((num_dir, num_thresh), dtype=np.int32) - sorted_projections = np.empty_like(projections) - sorted_edge_maxes = np.empty_like(edge_maxes) - - for i in prange(num_dir): - sorted_projections[:, i] = np.sort(projections[:, i]) - sorted_edge_maxes[:, i] = np.sort(edge_maxes[:, i]) + sorted_projections = [np.sort(proj, axis=0) for proj in simplex_projections_list] for j in prange(num_thresh): thresh = thresholds[j] for i in range(num_dir): - v = np.searchsorted( - sorted_projections[:, i], thresh, side='right') - e = np.searchsorted( - sorted_edge_maxes[:, i], thresh, side='right') - f = np.searchsorted( - face_maxes[:, i], thresh, side='right') if face_maxes.shape[0] > 0 else 0 - result[i, j] = v - e + f - + simplex_counts_list = [] + for k in range(len(sorted_projections)): + projs = sorted_projections[k][:, i] + simplex_counts_list.append(np.searchsorted(projs, thresh, side='right')) + result[i, j] = self._calculate_euler_chars(simplex_counts_list) return result + + def _calculate_euler_chars(self, simplex_counts_list): + """Calculate Euler characteristics from sorted counts""" + chi = 0 + for k in range(len(simplex_counts_list)): + chi += simplex_counts_list[k] + return chi + def calculate_euler_chars(self, projections, edge_maxes, face_maxes, thresholds): """Calculate Euler characteristics and wrap in ECTResult""" result = ECT._calculate_euler_chars_numba( projections, edge_maxes, face_maxes, thresholds) - return ECTResult(result, self.directions, self.threshes) - - def calculate_sect(self, ect_matrix=None): - """ - Function to calculate the Smooth Euler Characteristic Transform (SECT) from the ECT matrix. - - Returns: - np.array - The matrix representing the SECT of size (num_dirs,num_thresh). - """ - - # Calculate the SECT - if ect_matrix is None: - M = self.calculate_ect() - else: - M = ect_matrix - - # Get average of each row, corresponds to each direction - A = np.average(M, axis=1) - - # Subtract the average from each row - M_normalized = M - A[:, np.newaxis] + return ECTResult(result, self.directions, self.thresholds) - # Take the cumulative sum of each row to get the SECT - M_sect = np.cumsum(M_normalized, axis=1) - return M_sect + # @_ensure_valid_directions + # def calculate_ecc(self, graph, theta, bound_radius=None, return_counts=False): + # """Calculate ECC - directions are validated by decorator""" + # r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) - def plot_ecc(self, graph, theta, bound_radius=None, draw_counts=False): - """ - Function to plot the Euler Characteristic Curve (ECC) for a specific direction theta. Note that this calculates the ECC for the input graph and then plots it. - - Parameters: - graph (EmbeddedGraph/EmbeddedCW): - The input graph or CW complex. - theta (float): - The angle in :math:`[0,2\pi]` for the direction to plot the ECC. - bound_radius (float): - If None, uses the following in order: (i) the bounding radius stored in the class; or if not available (ii) the bounding radius of the given graph. Otherwise, should be a postive float :math:`R` where the ECC will be computed at thresholds in :math:`[-R,R]`. Default is None. - draw_counts (bool): - Whether to draw the counts of vertices, edges, and faces varying across thresholds. Default is False. - """ - - r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) - if not draw_counts: - ECC = self.calculate_ecc(graph, theta, r) - else: - ECC, vertex_count, edge_count, face_count = self.calculate_ecc( - graph, theta, r, return_counts=True) - - # if self.threshes is None: - # self.set_bounding_radius(graph.get_bounding_radius()) - - plt.step(r_threshes, ECC, label='ECC') - - if draw_counts: - plt.step(r_threshes, vertex_count, label='Vertices') - plt.step(r_threshes, edge_count, label='Edges') - plt.step(r_threshes, face_count, label='Faces') - plt.legend() - - theta_round = str(np.round(theta, 2)) - plt.title(r'ECC for $\omega = ' + theta_round + '$') - plt.xlabel('$a$') - plt.ylabel(r'$\chi(K_a)$') - - def plot_sect(self): - """ - Function to plot the Smooth Euler Characteristic Transform (SECT) matrix. Note that the SECT matrix must be calculated before calling this function. - - The resulting plot will have the angle on the x-axis and the threshold on the y-axis. - """ + # r_threshes = np.array(r_threshes) - # Make meshgrid. - # Add back the 2pi to thetas for the pcolormesh - thetas = np.concatenate((self.directions.thetas, [2*np.pi])) - X, Y = np.meshgrid(thetas, self.threshes) - M = np.zeros_like(X) + # # Sort vertices and edges based on projection + # v_list, g = graph.sort_vertices(theta, return_g=True) + # g_list = np.array([g[v] for v in v_list]) + # sorted_g_list = np.sort(g_list) - # Transpose to get the correct orientation - M[:, :-1] = self.SECT_matrix.T - M[:, -1] = M[:, 0] # Add the 2pi direction to the 0 direction + # e_list, g_e = graph.sort_edges(theta, return_g=True) + # g_e_list = np.array([g_e[e] for e in e_list]) + # sorted_g_e_list = np.sort(g_e_list) - plt.pcolormesh(X, Y, M, cmap='viridis') - plt.colorbar() + # vertex_count = np.searchsorted(sorted_g_list, r_threshes, side='right') + # edge_count = np.searchsorted(sorted_g_e_list, r_threshes, side='right') - ax = plt.gca() - ax.set_xticks(np.linspace(0, 2*np.pi, 9)) + # if isinstance(graph, EmbeddedCW): + # f_list, g_f = graph.sort_faces(theta, return_g=True) + # g_f_list = np.array([g_f[f] for f in f_list]) + # sorted_g_f_list = np.sort(g_f_list) + # face_count = np.searchsorted( + # sorted_g_f_list, r_threshes, side='right') + # else: + # face_count = np.zeros_like(r_threshes, dtype=np.int32) - labels = [r'$0$', - r'$\frac{\pi}{4}$', - r'$\frac{\pi}{2}$', - r'$\frac{3\pi}{4}$', - r'$\pi$', - r'$\frac{5\pi}{4}$', - r'$\frac{3\pi}{2}$', - r'$\frac{7\pi}{4}$', - r'$2\pi$', - ] + # ecc = vertex_count - edge_count + face_count - ax.set_xticklabels(labels) + # if return_counts: + # return ecc, vertex_count, edge_count, face_count + # else: + # return ecc - plt.xlabel(r'$\omega$') - plt.ylabel(r'$t$') - plt.title(r'SECT of Input Graph') diff --git a/ect/ect/embed_graph.py b/ect/ect/embed_graph.py index 45dc9f3..b561bbd 100644 --- a/ect/ect/embed_graph.py +++ b/ect/ect/embed_graph.py @@ -1,9 +1,13 @@ +from collections import defaultdict +from typing import Dict, List, Tuple, Optional + import networkx as nx import numpy as np import matplotlib.pyplot as plt from sklearn.decomposition import PCA + from ect.utils.naming import next_vert_name -from typing import Dict, List, Tuple, Optional + CENTER_TYPES = ["mean", "bounding_box", "origin"] @@ -12,7 +16,7 @@ class EmbeddedGraph(nx.Graph): """ - A class to represent a graph with embedded coordinates for each vertex. + A class to represent a graph with embedded coordinates for each vertex with simple geometric graph operations. Attributes graph : nx.Graph @@ -74,7 +78,7 @@ def edge_indices(self): # ====================================== # Node Management # ====================================== - def _validate_coords(func): + def _validate_coords(self,func): """Validates if coordinates are nonempty and have valid dimension""" def wrapper(self, *args, **kwargs): @@ -94,7 +98,7 @@ def wrapper(self, *args, **kwargs): return func(self, *args, **kwargs) return wrapper - def _validate_node(exists=True): + def _validate_node(self, exists=True): """Validates if a node exists or not already""" def decorator(func): def wrapper(self, *args, **kwargs): @@ -118,18 +122,11 @@ def wrapper(self, *args, **kwargs): def add_node(self, node_id, coord): """Add a vertex to the graph.""" coord = np.asarray(coord, dtype=float) - # Debug - print(f"Adding node {node_id} with coord shape: {coord.shape}") if len(self._node_list) == 0: - print("First node, initializing matrix") # Debug self._coord_matrix = coord.reshape(1, -1) - # Debug - print(f"Matrix shape after init: {self._coord_matrix.shape}") else: - print(f"Current matrix shape: {self._coord_matrix.shape}") # Debug coord_reshaped = coord.reshape(1, -1) - print(f"New coord shape: {coord_reshaped.shape}") # Debug self._coord_matrix = np.vstack( [self._coord_matrix, coord_reshaped]) diff --git a/ect/ect/results.py b/ect/ect/results.py index 7cdf496..0128092 100644 --- a/ect/ect/results.py +++ b/ect/ect/results.py @@ -27,6 +27,9 @@ def plot(self, ax=None): if self.thresholds is None: self.thresholds = np.linspace(-1, 1, self.shape[1]) + if len(self.directions) == 1: + self.plot_ecc(self, self.directions[0]) + if self.directions.dim == 2: # 2D case - use angle representation if self.directions.sampling == Sampling.UNIFORM and not self.directions.endpoint: @@ -36,7 +39,6 @@ def plot(self, ax=None): ect_data = np.hstack([self.T, self.T[:, [0]]]) else: plot_thetas = self.directions.thetas - ect_data = self.T X = plot_thetas Y = self.thresholds @@ -83,3 +85,30 @@ def smooth(self): # Return as ECTResult to maintain plotting capability return ECTResult(sect, self.directions, self.thresholds) + + def plot_ecc(self, theta): + """ + Function to plot the Euler Characteristic Curve (ECC) for a specific direction theta. Note that this calculates the ECC for the input graph and then plots it. + + Parameters: + graph (EmbeddedGraph/EmbeddedCW): + The input graph or CW complex. + theta (float): + The angle in :math:`[0,2\pi]` for the direction to plot the ECC. + bound_radius (float): + If None, uses the following in order: (i) the bounding radius stored in the class; or if not available (ii) the bounding radius of the given graph. Otherwise, should be a postive float :math:`R` where the ECC will be computed at thresholds in :math:`[-R,R]`. Default is None. + draw_counts (bool): + Whether to draw the counts of vertices, edges, and faces varying across thresholds. Default is False. + """ + + + + # if self.threshes is None: + # self.set_bounding_radius(graph.get_bounding_radius()) + + plt.step(self.thresholds, self.T, label='ECC') + + theta_round = str(np.round(theta, 2)) + plt.title(r'ECC for $\omega = ' + theta_round + '$') + plt.xlabel('$a$') + plt.ylabel(r'$\chi(K_a)$') diff --git a/ect/ect/utils/__init__.py b/ect/ect/utils/__init__.py new file mode 100644 index 0000000..05837be --- /dev/null +++ b/ect/ect/utils/__init__.py @@ -0,0 +1,3 @@ +from .naming import next_vert_name + +__all__ = ['next_vert_name'] diff --git a/ect/ect/utils/face_check.py b/ect/ect/utils/face_check.py new file mode 100644 index 0000000..e567951 --- /dev/null +++ b/ect/ect/utils/face_check.py @@ -0,0 +1,16 @@ +import numpy as np + + +def point_in_polygon(point: np.ndarray, polygon: np.ndarray) -> bool: + """Ray casting algorithm for point-in-polygon test""" + x, y = point + n = polygon.shape[0] + inside = False + for i in range(n): + p1 = polygon[i] + p2 = polygon[(i+1) % n] + if ((p1[1] > y) != (p2[1] > y)): + xinters = (y - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1]) + p1[0] + if x <= xinters: + inside = not inside + return inside diff --git a/ect/ect/utils/utils.py b/ect/ect/utils/utils.py deleted file mode 100644 index d8b02f1..0000000 --- a/ect/ect/utils/utils.py +++ /dev/null @@ -1,87 +0,0 @@ -from ect.embed_graph import EmbeddedGraph -from ect.embed_cw import EmbeddedCW -import inspect -from typing import Type - - -def create_example_cw(centered=True, center_type='min_max'): - """ - Creates an example EmbeddedCW object with a simple CW complex. If centered is True, the coordinates are centered around the center type, which could be ``mean``, ``min_max`` or ``origin``. - - - Returns: - EmbeddedCW - The example EmbeddedCW object. - """ - G = create_example_graph(centered=False) - K = EmbeddedCW() - K.add_from_embedded_graph(G) - K.add_node('G', 2, 4) - K.add_node('H', 1, 5) - K.add_node('I', 5, 4) - K.add_node('J', 2, 2) - K.add_node('K', 2, 7) - K.add_edges_from([('G', 'A'), ('G', 'H'), ('H', 'D'), ('I', 'E'), - ('I', 'C'), ('J', 'E'), ('K', 'D'), ('K', 'C')]) - K.add_face(['B', 'A', 'G', 'H', 'D']) - K.add_face(['K', 'D', 'C']) - - if centered: - K.set_centered_coordinates(type=center_type) - - return K - - -def create_example_graph(centered=True, center_type='min_max'): - """ - Function to create an example ``EmbeddedGraph`` object. Helpful for testing. If ``centered`` is True, the coordinates are centered using the center type given by ``center_type``, either ``mean`` or ``min_max``. - - Returns: - EmbeddedGraph: An example ``EmbeddedGraph`` object. - - """ - graph = EmbeddedGraph() - - graph.add_node('A', 1, 2) - graph.add_node('B', 3, 4) - graph.add_node('C', 5, 7) - graph.add_node('D', 3, 6) - graph.add_node('E', 4, 3) - graph.add_node('F', 4, 5) - - graph.add_edge('A', 'B') - graph.add_edge('B', 'C') - graph.add_edge('B', 'D') - graph.add_edge('B', 'E') - graph.add_edge('C', 'D') - graph.add_edge('E', 'F') - - if centered: - graph.set_centered_coordinates(center_type) - - return graph - - -def next_vert_name(self, s, num_verts=1): - """Generate sequential vertex names (alphabetical or numerical).""" - if isinstance(s, int): - return [s + i + 1 for i in range(num_verts)] if num_verts > 1 else s + 1 - - def increment_char(c): - return 'A' if c == 'Z' else chr(ord(c) + 1) - - def increment_str(s): - chars = list(s) - for i in reversed(range(len(chars))): - chars[i] = increment_char(chars[i]) - if chars[i] != 'A': - break - elif i == 0: - return 'A' + ''.join(chars) - return ''.join(chars) - - # handle multiple increments - names = [s] - for _ in range(num_verts): - names.append(increment_str(names[-1])) - return names[1:] if num_verts > 1 else names[1] From 085506f1e8ec23f3b1e3b28db87c26fb0d9f0006 Mon Sep 17 00:00:00 2001 From: yemeen Date: Thu, 13 Mar 2025 13:21:08 -0400 Subject: [PATCH 15/35] fix tests to work with new structure, add directions and results testing, update benchmarks --- benchmarks/benchmark_cw.py | 13 ++- benchmarks/benchmark_graph.py | 33 ++---- benchmarks/run_benchmarks.py | 5 +- ect/ect/__init__.py | 7 -- ect/ect/types.py | 13 --- ect/ect/utils/__init__.py | 3 - ect/ect/utils/examples.py | 62 ---------- pyproject.toml | 8 +- src/ect/__init__.py | 23 ++++ {ect => src}/ect/directions.py | 0 {ect => src}/ect/ect_graph.py | 164 ++++++++++++--------------- {ect => src}/ect/embed_cw.py | 76 ++----------- {ect => src}/ect/embed_graph.py | 43 +++---- {ect => src}/ect/results.py | 60 ++++------ src/ect/utils/examples.py | 77 +++++++++++++ {ect => src}/ect/utils/face_check.py | 0 {ect => src}/ect/utils/naming.py | 0 tests/test_directions.py | 88 ++++++++++++++ tests/test_ect_graph.py | 132 +++++++++++++-------- tests/test_ect_result.py | 83 ++++++++++++++ tests/test_embed_cw.py | 50 ++++---- tests/test_embed_graph.py | 115 +++++++++---------- 22 files changed, 595 insertions(+), 460 deletions(-) delete mode 100644 ect/ect/__init__.py delete mode 100644 ect/ect/types.py delete mode 100644 ect/ect/utils/__init__.py delete mode 100644 ect/ect/utils/examples.py create mode 100644 src/ect/__init__.py rename {ect => src}/ect/directions.py (100%) rename {ect => src}/ect/ect_graph.py (55%) rename {ect => src}/ect/embed_cw.py (59%) rename {ect => src}/ect/embed_graph.py (95%) rename {ect => src}/ect/results.py (59%) create mode 100644 src/ect/utils/examples.py rename {ect => src}/ect/utils/face_check.py (100%) rename {ect => src}/ect/utils/naming.py (100%) create mode 100644 tests/test_directions.py create mode 100644 tests/test_ect_result.py diff --git a/benchmarks/benchmark_cw.py b/benchmarks/benchmark_cw.py index 78a2b53..ae29e67 100644 --- a/benchmarks/benchmark_cw.py +++ b/benchmarks/benchmark_cw.py @@ -1,7 +1,8 @@ """Benchmarks for CW complex computations""" import numpy as np import time -from ect import ECT, EmbeddedCW, create_example_cw +from ect import ECT +from ect.utils.examples import create_example_cw import json from pathlib import Path @@ -11,9 +12,9 @@ def benchmark_cw_ect(num_runs=5): results = {} configs = [ - (8, 10), # Small - (36, 36), # Medium - (360, 360), # Large + (8, 10), + (36, 36), + (360, 360), ] for num_dir, num_thresh in configs: @@ -26,7 +27,7 @@ def benchmark_cw_ect(num_runs=5): start_time = time.time() myect = ECT(num_dirs=num_dir, num_thresh=num_thresh) - myect.calculateECT(K) + myect.calculate(K) execution_time = time.time() - start_time times.append(execution_time) @@ -45,7 +46,7 @@ def benchmark_cw_ect(num_runs=5): print("Running CW complex benchmarks...") results = benchmark_cw_ect() - # Save results + output_dir = Path("benchmark_results") output_dir.mkdir(exist_ok=True) diff --git a/benchmarks/benchmark_graph.py b/benchmarks/benchmark_graph.py index cdfad0d..46339eb 100644 --- a/benchmarks/benchmark_graph.py +++ b/benchmarks/benchmark_graph.py @@ -21,12 +21,20 @@ def benchmark_graph_ect(num_runs=5): """Benchmark ECT computation on graphs""" results = {} + # Warmup run to trigger JIT compilation + warmup_shape = create_test_shape(100, 1) + G_warmup = EmbeddedGraph() + G_warmup.add_cycle(warmup_shape) + myect = ECT(num_dirs=360, num_thresh=360) + myect.calculate(G_warmup) # Warmup run + configs = [ (100, 1), (1000, 1), (100, 3), (1000, 3), (10000, 3), + (100000, 5), ] for points, complexity in configs: @@ -41,7 +49,7 @@ def benchmark_graph_ect(num_runs=5): for _ in range(num_runs): start_time = time.time() myect = ECT(num_dirs=360, num_thresh=360) - myect.calculateECT(G) + myect.calculate(G) times.append(time.time() - start_time) results[f'points_{points}_complexity_{complexity}'] = { @@ -50,30 +58,9 @@ def benchmark_graph_ect(num_runs=5): 'min_time': float(np.min(times)), 'max_time': float(np.max(times)) } + print(results) return results -def benchmark_g_omega(num_runs=5): - """Benchmark g_omega computation""" - results = {} - - sizes = [100, 500, 1000] - for size in sizes: - shape = create_test_shape(size) - G = EmbeddedGraph() - G.add_cycle(shape) - - times = [] - for _ in range(num_runs): - start_time = time.time() - for theta in np.linspace(0, 2*np.pi, 360): - G.g_omega(theta) - times.append(time.time() - start_time) - - results[f'size_{size}'] = { - 'mean_time': float(np.mean(times)), - 'std_time': float(np.std(times)) - } - return results diff --git a/benchmarks/run_benchmarks.py b/benchmarks/run_benchmarks.py index 6f30bbb..6cab6f6 100644 --- a/benchmarks/run_benchmarks.py +++ b/benchmarks/run_benchmarks.py @@ -3,7 +3,7 @@ import time from pathlib import Path import json -from benchmark_graph import benchmark_graph_ect, benchmark_g_omega +from benchmark_graph import benchmark_graph_ect from benchmark_cw import benchmark_cw_ect import platform @@ -26,8 +26,7 @@ def run_all_benchmarks(num_runs=5): print("\nRunning CW complex benchmarks...") results['benchmarks']['cw_ect'] = benchmark_cw_ect(num_runs=num_runs) - print("\nRunning g_omega benchmarks...") - results['benchmarks']['g_omega'] = benchmark_g_omega(num_runs=num_runs) + return results diff --git a/ect/ect/__init__.py b/ect/ect/__init__.py deleted file mode 100644 index c590515..0000000 --- a/ect/ect/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# __init__.py -# import .ect_graph as ect_graph -# import .embed_graph as embed_graph - -from .ect_graph import ECT -from .embed_graph import EmbeddedGraph -from .embed_cw import EmbeddedCW diff --git a/ect/ect/types.py b/ect/ect/types.py deleted file mode 100644 index 35b0c50..0000000 --- a/ect/ect/types.py +++ /dev/null @@ -1,13 +0,0 @@ -from dataclasses import dataclass -import numpy as np -from ect.directions import Directions - - -@dataclass -class ECTResult: - matrix: np.ndarray - directions: Directions - thresholds: np.ndarray - vertex_counts: np.ndarray | None = None - edge_counts: np.ndarray | None = None - face_counts: np.ndarray | None = None diff --git a/ect/ect/utils/__init__.py b/ect/ect/utils/__init__.py deleted file mode 100644 index 05837be..0000000 --- a/ect/ect/utils/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .naming import next_vert_name - -__all__ = ['next_vert_name'] diff --git a/ect/ect/utils/examples.py b/ect/ect/utils/examples.py deleted file mode 100644 index 878ab50..0000000 --- a/ect/ect/utils/examples.py +++ /dev/null @@ -1,62 +0,0 @@ -from ect.embed_graph import EmbeddedGraph -from ect.embed_cw import EmbeddedCW -from typing import Type -import numpy as np - - -def create_example_cw(centered=True, center_type='min_max'): - """ - Creates an example EmbeddedCW object with a simple CW complex. If centered is True, the coordinates are centered around the center type, which could be ``mean``, ``min_max`` or ``origin``. - - - Returns: - EmbeddedCW - The example EmbeddedCW object. - """ - G = create_example_graph(centered=False) - K = EmbeddedCW() - K.add_from_embedded_graph(G) - K.add_node('G', 2, 4) - K.add_node('H', 1, 5) - K.add_node('I', 5, 4) - K.add_node('J', 2, 2) - K.add_node('K', 2, 7) - K.add_edges_from([('G', 'A'), ('G', 'H'), ('H', 'D'), ('I', 'E'), - ('I', 'C'), ('J', 'E'), ('K', 'D'), ('K', 'C')]) - K.add_face(['B', 'A', 'G', 'H', 'D']) - K.add_face(['K', 'D', 'C']) - - if centered: - K.set_centered_coordinates(type=center_type) - - return K - - -def create_example_graph(centered=True, center_type='min_max'): - """ - Function to create an example ``EmbeddedGraph`` object. Helpful for testing. If ``centered`` is True, the coordinates are centered using the center type given by ``center_type``, either ``mean`` or ``min_max``. - - Returns: - EmbeddedGraph: An example ``EmbeddedGraph`` object. - - """ - graph = EmbeddedGraph() - - graph.add_node('A', 1, 2) - graph.add_node('B', 3, 4) - graph.add_node('C', 5, 7) - graph.add_node('D', 3, 6) - graph.add_node('E', 4, 3) - graph.add_node('F', 4, 5) - - graph.add_edge('A', 'B') - graph.add_edge('B', 'C') - graph.add_edge('B', 'D') - graph.add_edge('B', 'E') - graph.add_edge('C', 'D') - graph.add_edge('E', 'F') - - if centered: - graph.set_centered_coordinates(center_type) - - return graph diff --git a/pyproject.toml b/pyproject.toml index 62c36b0..870c7bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ect" -version = "0.1.9" +version = "1.0.0" authors = [ { name="Liz Munch", email="muncheli@msu.edu" }, ] @@ -20,8 +20,12 @@ dependencies = ["numpy", "scikit-learn" ] +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + [tool.setuptools.packages.find] -where = ["ect"] +where = ["src"] [project.urls] diff --git a/src/ect/__init__.py b/src/ect/__init__.py new file mode 100644 index 0000000..750fd5e --- /dev/null +++ b/src/ect/__init__.py @@ -0,0 +1,23 @@ +""" +ECT: A Python package for computing the Euler Characteristic Transform + +Main classes: + ECT: Calculator for Euler Characteristic Transform + EmbeddedGraph: Graph representation for ECT computation + EmbeddedCW: CW complex representation for ECT computation + Directions: Direction vector management for ECT computation +""" + +from .ect_graph import ECT +from .embed_graph import EmbeddedGraph +from .embed_cw import EmbeddedCW +from .directions import Directions +from .utils import examples + +__all__ = [ + 'ECT', + 'EmbeddedGraph', + 'EmbeddedCW', + 'Directions', + 'examples', +] diff --git a/ect/ect/directions.py b/src/ect/directions.py similarity index 100% rename from ect/ect/directions.py rename to src/ect/directions.py diff --git a/ect/ect/ect_graph.py b/src/ect/ect_graph.py similarity index 55% rename from ect/ect/ect_graph.py rename to src/ect/ect_graph.py index e7c3715..0d11af5 100644 --- a/ect/ect/ect_graph.py +++ b/src/ect/ect_graph.py @@ -5,10 +5,10 @@ from numba import jit, prange from typing import Optional, Union -from ect.embed_cw import EmbeddedCW -from ect.embed_graph import EmbeddedGraph -from ect.directions import Directions -from ect.results import ECTResult +from .embed_cw import EmbeddedCW +from .embed_graph import EmbeddedGraph +from .directions import Directions +from .results import ECTResult @@ -16,7 +16,7 @@ class ECT: """ A class to calculate the Euler Characteristic Transform (ECT) from an input :any:`EmbeddedGraph` or :any:`EmbeddedCW`. - The result is a matrix where entry ``M[i,j]`` is :math:`\chi(K_{a_i})` for the direction :math:`\omega_j` where :math:`a_i` is the ith entry in ``self.threshes``, and :math:`\omega_j` is the ith entry in ``self.thetas``. + The result is a matrix where entry ``M[i,j]`` is :math:`\chi(K_{a_i})` for the direction :math:`\omega_j` where :math:`a_i` is the ith entry in ``self.thresholds``, and :math:`\omega_j` is the ith entry in ``self.thetas``. Attributes num_dirs (int): @@ -51,20 +51,28 @@ def __init__(self, else: self.directions = None self.num_dirs = num_dirs or 360 - self.num_thresh = num_thresh or 360 + self.num_thresh = num_thresh or self.num_dirs - self.num_thresh = num_thresh self.bound_radius = None - self.threshes = None + self.thresholds = None if bound_radius is not None: self.set_bounding_radius(bound_radius) - def _ensure_valid_directions(self, calculation_method): - """Ensures directions match graph dimension, creating if needed""" + @staticmethod + def _ensure_valid_directions(calculation_method): + """ + Decorator to ensure directions match graph dimension. + Reinitializes directions if dimensions don't match. + """ @wraps(calculation_method) def wrapper(ect_instance, graph, *args, **kwargs): - if ect_instance.directions is None or ect_instance.directions.dim != graph.dim: - ect_instance.directions = Directions.uniform(ect_instance.num_dirs, dim=graph.dim) + if ect_instance.directions is None: + ect_instance.directions = Directions.uniform( + ect_instance.num_dirs, dim=graph.dim) + elif ect_instance.directions.dim != graph.dim: + ect_instance.directions = Directions.uniform( + ect_instance.num_dirs, dim=graph.dim) + return calculation_method(ect_instance, graph, *args, **kwargs) return wrapper @@ -75,7 +83,7 @@ def set_bounding_radius(self, radius: Optional[float]): self.bound_radius = radius if radius is not None: - self.threshes = np.linspace(-radius, radius, self.num_thresh) + self.thresholds = np.linspace(-radius, radius, self.num_thresh) def get_thresholds(self, graph: Union[EmbeddedGraph, EmbeddedCW], override_radius: Optional[float] = None): """Gets thresholds based on priority: override_radius > instance radius > graph radius""" @@ -103,52 +111,69 @@ def calculate(self, graph: Union[EmbeddedGraph, EmbeddedCW], theta=None, bound_r If None, uses the following in order: (i) the bounding radius stored in the class; or if not available (ii) the bounding radius of the given graph. Otherwise, should be a postive float :math:`R` where the ECC will be computed at thresholds in :math:`[-R,R]`. Default is None. """ radius, thresholds = self.get_thresholds(graph, bound_radius) - coords = graph.coord_matrix - edges = graph.edge_indices - if theta is None: - node_projections = self._compute_node_projections(coords, self.directions) - else: - node_projections = self._compute_node_projections(coords, Directions.from_angles([theta])) + directions = self.directions if theta is None else Directions.from_angles([theta]) - simplex_projections = self._compute_simplex_projections(node_projections, simplex) + simplex_projections = self._compute_simplex_projections(graph, directions) - face_maxes = np.empty((0, self.num_dirs)) - if isinstance(graph, EmbeddedCW) and len(graph.faces) > 0: - node_to_index = {n: i for i, n in enumerate(graph.node_list)} - face_indices = [[node_to_index[v] for v in face] for face in graph.faces] - face_maxes = np.array([np.max(node_projections[face, :], axis=0) for face in face_indices]) + ect = self._compute_directional_transform(simplex_projections, thresholds) - return self._calculate_euler_chars(node_projections, edge_maxes, face_maxes, thresholds) + return ECTResult(ect, directions, thresholds) def _compute_node_projections(self, coords, directions): """Compute inner products of coordinates with directions""" return np.matmul(coords, directions.vectors.T) - def _compute_simplex_projections(vertex_projections, simplices): - """For all k ≥ 0, compute max projections of each k-simplex""" - max_projections = [] - for k in range(len(simplices)): - if k == 0: - max_projections.append(vertex_projections.copy()) - else: - k_simplices = simplices[k] - k_proj = np.array([ - np.max(vertex_projections[s, :], axis=0) - for s in k_simplices - ]) - max_projections.append(k_proj) - return max_projections - + def _compute_simplex_projections(self, graph: Union[EmbeddedGraph, EmbeddedCW], directions): + """Compute max projections of each simplex (vertices, edges, faces)""" + simplex_projections = [] + node_projections = self._compute_node_projections(graph.coord_matrix, directions) + edge_maxes = np.maximum(node_projections[graph.edge_indices[:, 0]], + node_projections[graph.edge_indices[:, 1]]) + + simplex_projections.append(node_projections) + simplex_projections.append(edge_maxes) + + if isinstance(graph, EmbeddedCW) and len(graph.faces) > 0: + node_to_index = {n: i for i, n in enumerate(graph.node_list)} + face_indices = [[node_to_index[v] for v in face] for face in graph.faces] + face_maxes = np.array([np.max(node_projections[face, :], axis=0) + for face in face_indices]) + simplex_projections.append(face_maxes) + + return simplex_projections + + @staticmethod @jit(nopython=True, parallel=True, fastmath=True) - def _sort_projections(simplex_projections_list, thresholds): - """Sort projections and count occurrences of each threshold""" + def _compute_directional_transform(simplex_projections_list, thresholds): + """Compute ECT by counting simplices below each threshold + + Args: + simplex_projections_list: List of arrays containing projections for each simplex type + [vertex_projections, edge_projections, face_projections] + thresholds: Array of threshold values to compute ECT at + + Returns: + Array of shape (num_directions, num_thresholds) containing Euler characteristics + """ num_dir = simplex_projections_list[0].shape[1] num_thresh = thresholds.shape[0] result = np.empty((num_dir, num_thresh), dtype=np.int32) - sorted_projections = [np.sort(proj, axis=0) for proj in simplex_projections_list] + sorted_projections = [] + for proj in simplex_projections_list: + sorted_proj = np.empty_like(proj) + for i in prange(num_dir): + sorted_proj[:, i] = np.sort(proj[:, i]) + sorted_projections.append(sorted_proj) + + def compute_shape_descriptor(simplex_counts_list): + """Calculate shape descriptor from simplex counts (Euler characteristic)""" + chi = 0 + for k in range(len(simplex_counts_list)): + chi += (-1)**k * simplex_counts_list[k] + return chi for j in prange(num_thresh): thresh = thresholds[j] @@ -157,57 +182,8 @@ def _sort_projections(simplex_projections_list, thresholds): for k in range(len(sorted_projections)): projs = sorted_projections[k][:, i] simplex_counts_list.append(np.searchsorted(projs, thresh, side='right')) - result[i, j] = self._calculate_euler_chars(simplex_counts_list) + result[i, j] = compute_shape_descriptor(simplex_counts_list) return result - def _calculate_euler_chars(self, simplex_counts_list): - """Calculate Euler characteristics from sorted counts""" - chi = 0 - for k in range(len(simplex_counts_list)): - chi += simplex_counts_list[k] - return chi - - - def calculate_euler_chars(self, projections, edge_maxes, face_maxes, thresholds): - """Calculate Euler characteristics and wrap in ECTResult""" - result = ECT._calculate_euler_chars_numba( - projections, edge_maxes, face_maxes, thresholds) - return ECTResult(result, self.directions, self.thresholds) - - - # @_ensure_valid_directions - # def calculate_ecc(self, graph, theta, bound_radius=None, return_counts=False): - # """Calculate ECC - directions are validated by decorator""" - # r, r_threshes = self.get_radius_and_thresh(graph, bound_radius) - - # r_threshes = np.array(r_threshes) - - # # Sort vertices and edges based on projection - # v_list, g = graph.sort_vertices(theta, return_g=True) - # g_list = np.array([g[v] for v in v_list]) - # sorted_g_list = np.sort(g_list) - - # e_list, g_e = graph.sort_edges(theta, return_g=True) - # g_e_list = np.array([g_e[e] for e in e_list]) - # sorted_g_e_list = np.sort(g_e_list) - - # vertex_count = np.searchsorted(sorted_g_list, r_threshes, side='right') - # edge_count = np.searchsorted(sorted_g_e_list, r_threshes, side='right') - - # if isinstance(graph, EmbeddedCW): - # f_list, g_f = graph.sort_faces(theta, return_g=True) - # g_f_list = np.array([g_f[f] for f in f_list]) - # sorted_g_f_list = np.sort(g_f_list) - # face_count = np.searchsorted( - # sorted_g_f_list, r_threshes, side='right') - # else: - # face_count = np.zeros_like(r_threshes, dtype=np.int32) - - # ecc = vertex_count - edge_count + face_count - - # if return_counts: - # return ecc, vertex_count, edge_count, face_count - # else: - # return ecc diff --git a/ect/ect/embed_cw.py b/src/ect/embed_cw.py similarity index 59% rename from ect/ect/embed_cw.py rename to src/ect/embed_cw.py index 8263d31..596e25b 100644 --- a/ect/ect/embed_cw.py +++ b/src/ect/embed_cw.py @@ -1,8 +1,8 @@ import numpy as np import matplotlib.pyplot as plt import networkx as nx -from ect.embed_graph import EmbeddedGraph -from ect.utils.face_check import point_in_polygon +from .embed_graph import EmbeddedGraph +from .utils.face_check import point_in_polygon class EmbeddedCW(EmbeddedGraph): @@ -18,7 +18,6 @@ def __init__(self): Initializes an empty EmbeddedCW object. """ - # The super class initializes the graph and the coordinates dictionary. super().__init__() self.faces = [] @@ -27,94 +26,43 @@ def add_from_embedded_graph(self, G): Adds the edges and coordinates from an EmbeddedGraph object to the EmbeddedCW object. Parameters: - embedded_graph (EmbeddedGraph): + G (EmbeddedGraph): The EmbeddedGraph object to add from. """ - self.add_nodes_from(G.nodes(), G.coordinates) + nodes_with_coords = [(node, G.coord_matrix[G.node_to_index[node]]) + for node in G.nodes()] + self.add_nodes_from(nodes_with_coords) self.add_edges_from(G.edges()) + @EmbeddedGraph._validate_node(exists=True) def add_face(self, face, check=True): """ Adds a face to the list of faces. - TODO: Do we want a check to make sure the face is legit? (i.e. is a cycle in the graph, and bounds a region in the plane) - Parameters: face (list): A list of vertices that make up the face. check (bool): Whether to check that the face is a valid addition to the cw complex. """ + if len(face) < 3: + raise ValueError("Face must have at least 3 vertices") if check: - # Edge existence check edges = list(zip(face, face[1:] + [face[0]])) for u, v in edges: if not self.has_edge(u, v): raise ValueError(f"Edge ({u},{v}) missing") - # Point-in-polygon check for other vertices - polygon = np.array([self.coordinates[v] for v in face]) + polygon = np.array([self.coord_matrix[self._node_to_index[v]] for v in face]) for node in self.nodes: if node in face: continue - if point_in_polygon(self.coordinates[node], polygon): + if point_in_polygon(self.coord_matrix[self._node_to_index[node]], polygon): raise ValueError(f"Node {node} inside face {face}") self.faces.append(tuple(face)) - def g_omega_faces(self, theta): - """ - Calculates the function value of the faces of the graph by making the value equal to the max vertex value - - Parameters: - - theta (float): - The direction of the function to be calculated. - - Returns: - dict - A dictionary of the function values of the faces. - """ - g = super().g_omega(theta) - - g_faces = {} - for face in self.faces: - g_faces[tuple(face)] = max([g[v] for v in face]) - - return g_faces - - def sort_faces(self, theta, return_g=False): - """ - Function to sort the faces of the graph according to the function - - .. math :: - - g_\omega(\sigma) = \max \{ g_\omega(v) \mid v \in \sigma \} - - in the direction of :math:`\\theta \in [0,2\pi]` . - - Parameters: - theta (float): - The angle in :math:`[0,2\pi]` for the direction to sort the edges. - return_g (bool): - Whether to return the :math:`g(v)` values along with the sorted edges. - - Returns: - A list of edges sorted in increasing order of the :math:`g(v)` values. - If ``return_g`` is True, also returns the ``g`` dictionary with the function values ``g[vertex_name]=func_value``. - - """ - g_f = self.g_omega_faces(theta) - - f_list = sorted(self.faces, key=lambda face: g_f[face]) - - if return_g: - # g_sorted = [g[v] for v in v_list] - return f_list, g_f - else: - return f_list - def plot_faces(self, theta=None, ax=None, **kwargs): """ Plots the faces of the graph in the direction of theta. @@ -137,7 +85,7 @@ def plot_faces(self, theta=None, ax=None, **kwargs): fig = ax.get_figure() for face in self.faces: - face_coords = np.array([self.coordinates[v] for v in face]) + face_coords = np.array([self.coord_matrix[self.node_to_index[v]] for v in face]) ax.fill(face_coords[:, 0], face_coords[:, 1], **kwargs) return ax diff --git a/ect/ect/embed_graph.py b/src/ect/embed_graph.py similarity index 95% rename from ect/ect/embed_graph.py rename to src/ect/embed_graph.py index b561bbd..60d65b9 100644 --- a/ect/ect/embed_graph.py +++ b/src/ect/embed_graph.py @@ -6,7 +6,7 @@ import matplotlib.pyplot as plt from sklearn.decomposition import PCA -from ect.utils.naming import next_vert_name +from .utils.naming import next_vert_name @@ -78,9 +78,9 @@ def edge_indices(self): # ====================================== # Node Management # ====================================== - def _validate_coords(self,func): + @staticmethod + def _validate_coords(func): """Validates if coordinates are nonempty and have valid dimension""" - def wrapper(self, *args, **kwargs): coords = next((arg for arg in args if isinstance( arg, (list, np.ndarray))), None) @@ -98,21 +98,24 @@ def wrapper(self, *args, **kwargs): return func(self, *args, **kwargs) return wrapper - def _validate_node(self, exists=True): - """Validates if a node exists or not already""" + @staticmethod + def _validate_node(exists=True): + """Validates if nodes exist or not already""" def decorator(func): def wrapper(self, *args, **kwargs): # Handle both positional and keyword arguments if args: - node_id = args[0] + nodes = args[0] if isinstance(args[0], (list, tuple)) else [args[0]] else: node_id = kwargs.get('node_id') or kwargs.get('node_id1') - - node_exists = node_id in self._node_to_index - if exists and not node_exists: - raise ValueError(f"Node {node_id} does not exist") - if not exists and node_exists: - raise ValueError(f"Node {node_id} already exists") + nodes = [node_id] if node_id else [] + + for node_id in nodes: + node_exists = node_id in self._node_to_index + if exists and not node_exists: + raise ValueError(f"Node {node_id} does not exist") + if not exists and node_exists: + raise ValueError(f"Node {node_id} already exists") return func(self, *args, **kwargs) return wrapper return decorator @@ -120,7 +123,12 @@ def wrapper(self, *args, **kwargs): @_validate_coords @_validate_node(exists=False) def add_node(self, node_id, coord): - """Add a vertex to the graph.""" + """Add a vertex to the graph. + + Args: + node_id: Identifier for the node + coord: Array-like coordinates for the node + """ coord = np.asarray(coord, dtype=float) if len(self._node_list) == 0: @@ -142,6 +150,7 @@ def add_nodes_from(self, nodes_with_coords): # Coordinate Access # ====================================== + @_validate_node(exists=True) def get_coord(self, node_id): """Return the coordinates of a node""" return self._coord_matrix[self._node_to_index[node_id]].copy() @@ -338,15 +347,9 @@ def pca_projection(self, target_dim=2): self._coord_matrix = pca.fit_transform(self._coord_matrix) self.dim = target_dim - def _validate_node_exists(self, node_id): - """Check if a node exists in the graph""" - if node_id not in self._node_to_index: - raise ValueError(f"Node {node_id} does not exist") - + @_validate_node(exists=True) def add_edge(self, node_id1, node_id2): """Add an edge between two nodes""" - self._validate_node_exists(node_id1) - self._validate_node_exists(node_id2) super().add_edge(node_id1, node_id2) # =================== diff --git a/ect/ect/results.py b/src/ect/results.py similarity index 59% rename from ect/ect/results.py rename to src/ect/results.py index 0128092..fd30196 100644 --- a/ect/ect/results.py +++ b/src/ect/results.py @@ -9,7 +9,11 @@ class ECTResult(np.ndarray): Acts like a regular matrix but with added visualization methods """ def __new__(cls, matrix, directions, thresholds): - obj = np.asarray(matrix).view(cls) + # allow float arrays for smooth transform + if np.issubdtype(matrix.dtype, np.floating): + obj = np.asarray(matrix, dtype=np.float64).view(cls) + else: + obj = np.asarray(matrix, dtype=np.int32).view(cls) obj.directions = directions obj.thresholds = thresholds return obj @@ -28,14 +32,14 @@ def plot(self, ax=None): self.thresholds = np.linspace(-1, 1, self.shape[1]) if len(self.directions) == 1: - self.plot_ecc(self, self.directions[0]) + self._plot_ecc(self.directions[0]) + return ax if self.directions.dim == 2: # 2D case - use angle representation if self.directions.sampling == Sampling.UNIFORM and not self.directions.endpoint: plot_thetas = np.concatenate( [self.directions.thetas, [2*np.pi]]) - # Circular closure ect_data = np.hstack([self.T, self.T[:, [0]]]) else: plot_thetas = self.directions.thetas @@ -69,45 +73,25 @@ def plot(self, ax=None): def smooth(self): - """ - Function to calculate the Smooth Euler Characteristic Transform (SECT) from the ECT matrix. - - Returns: - ECTResult: The SECT matrix with same directions and thresholds - """ - avg = np.average(self, axis=1) - - #subtract the average from each row - centered_ect = self - avg[:, np.newaxis] + """Calculate the Smooth Euler Characteristic Transform""" + # convert to float for calculations + data = self.astype(np.float64) - # take the cumulative sum of each row to get the SECT - sect = np.cumsum(centered_ect, axis=1) + # get average for each direction + direction_avgs = np.average(data, axis=1) - # Return as ECTResult to maintain plotting capability - return ECTResult(sect, self.directions, self.thresholds) - - def plot_ecc(self, theta): - """ - Function to plot the Euler Characteristic Curve (ECC) for a specific direction theta. Note that this calculates the ECC for the input graph and then plots it. - - Parameters: - graph (EmbeddedGraph/EmbeddedCW): - The input graph or CW complex. - theta (float): - The angle in :math:`[0,2\pi]` for the direction to plot the ECC. - bound_radius (float): - If None, uses the following in order: (i) the bounding radius stored in the class; or if not available (ii) the bounding radius of the given graph. Otherwise, should be a postive float :math:`R` where the ECC will be computed at thresholds in :math:`[-R,R]`. Default is None. - draw_counts (bool): - Whether to draw the counts of vertices, edges, and faces varying across thresholds. Default is False. - """ - + # center each direction's values + centered = data - direction_avgs[:, np.newaxis] - - # if self.threshes is None: - # self.set_bounding_radius(graph.get_bounding_radius()) - + # compute cumulative sum to get SECT + sect = np.cumsum(centered, axis=1) + + # create new ECTResult with float type + return ECTResult(sect.astype(np.float64), self.directions, self.thresholds) + + def _plot_ecc(self, theta): + """Plot the Euler Characteristic Curve for a specific direction""" plt.step(self.thresholds, self.T, label='ECC') - theta_round = str(np.round(theta, 2)) plt.title(r'ECC for $\omega = ' + theta_round + '$') plt.xlabel('$a$') diff --git a/src/ect/utils/examples.py b/src/ect/utils/examples.py new file mode 100644 index 0000000..59b1691 --- /dev/null +++ b/src/ect/utils/examples.py @@ -0,0 +1,77 @@ +from ect.embed_graph import EmbeddedGraph +from ect.embed_cw import EmbeddedCW +from typing import Type +import numpy as np + + +def create_example_cw(centered=True, center_type='bounding_box'): + """ + Creates an example EmbeddedCW object with a simple CW complex. If centered is True, the coordinates are centered around the center type, which could be ``mean``, ``bounding_box`` or ``origin``. + + + Returns: + EmbeddedCW + The example EmbeddedCW object. + """ + G = create_example_graph(centered=False) + K = EmbeddedCW() + K.add_from_embedded_graph(G) + + extra_coords = { + 'G': [2, 4], + 'H': [1, 5], + 'I': [5, 4], + 'J': [2, 2], + 'K': [2, 7] + } + + for node, coord in extra_coords.items(): + K.add_node(node, coord) + + extra_edges = [ + ('G', 'A'), ('G', 'H'), ('H', 'D'), ('I', 'E'), + ('I', 'C'), ('J', 'E'), ('K', 'D'), ('K', 'C') + ] + K.add_edges_from(extra_edges) + + K.add_face(['B', 'A', 'G', 'H', 'D']) + K.add_face(['K', 'D', 'C']) + + if centered: + K.center_coordinates(center_type) + + return K + + +def create_example_graph(centered=True, center_type='mean'): + """ + Function to create an example ``EmbeddedGraph`` object. Helpful for testing. If ``centered`` is True, the coordinates are centered using the center type given by ``center_type``, either ``mean``, ``bounding_box`` or ``origin``. + + Returns: + EmbeddedGraph: An example ``EmbeddedGraph`` object. + + """ + graph = EmbeddedGraph() + + coords = { + 'A': [1, 2], + 'B': [3, 4], + 'C': [5, 7], + 'D': [3, 6], + 'E': [4, 3], + 'F': [4, 5] + } + + for node, coord in coords.items(): + graph.add_node(node, coord) + + edges = [ + ('A', 'B'), ('B', 'C'), ('B', 'D'), + ('B', 'E'), ('C', 'D'), ('E', 'F') + ] + graph.add_edges_from(edges) + + if centered: + graph.center_coordinates(center_type) + + return graph diff --git a/ect/ect/utils/face_check.py b/src/ect/utils/face_check.py similarity index 100% rename from ect/ect/utils/face_check.py rename to src/ect/utils/face_check.py diff --git a/ect/ect/utils/naming.py b/src/ect/utils/naming.py similarity index 100% rename from ect/ect/utils/naming.py rename to src/ect/utils/naming.py diff --git a/tests/test_directions.py b/tests/test_directions.py new file mode 100644 index 0000000..99bd165 --- /dev/null +++ b/tests/test_directions.py @@ -0,0 +1,88 @@ +import unittest +import numpy as np +from ect import Directions +from ect.directions import Sampling + + +class TestDirections(unittest.TestCase): + def test_uniform_2d(self): + num_dirs = 8 + dirs = Directions.uniform(num_dirs, dim=2) + + self.assertEqual(len(dirs), num_dirs) + self.assertEqual(dirs.dim, 2) + self.assertEqual(dirs.sampling, Sampling.UNIFORM) + + # test vector properties + vectors = dirs.vectors + self.assertEqual(vectors.shape, (num_dirs, 2)) + norms = np.linalg.norm(vectors, axis=1) + np.testing.assert_allclose(norms, 1.0) + + def test_uniform_3d(self): + num_dirs = 10 + dirs = Directions.uniform(num_dirs, dim=3) + + self.assertEqual(len(dirs), num_dirs) + self.assertEqual(dirs.dim, 3) + + vectors = dirs.vectors + self.assertEqual(vectors.shape, (num_dirs, 3)) + norms = np.linalg.norm(vectors, axis=1) + np.testing.assert_allclose(norms, 1.0) + + def test_random_sampling(self): + num_dirs = 10 + seed = 42 + dirs = Directions.random(num_dirs, seed=seed) + + self.assertEqual(len(dirs), num_dirs) + self.assertEqual(dirs.sampling, Sampling.RANDOM) + + # test reproducibility + dirs2 = Directions.random(num_dirs, seed=seed) + np.testing.assert_array_equal(dirs.vectors, dirs2.vectors) + + def test_custom_angles(self): + angles = [0, np.pi/4, np.pi/2] + dirs = Directions.from_angles(angles) + + self.assertEqual(len(dirs), 3) + self.assertEqual(dirs.dim, 2) + self.assertEqual(dirs.sampling, Sampling.CUSTOM) + np.testing.assert_array_equal(dirs.thetas, angles) + + def test_custom_vectors(self): + vectors = [(1,0,0), (0,1,0), (0,0,1)] + dirs = Directions.from_vectors(vectors) + + self.assertEqual(len(dirs), 3) + self.assertEqual(dirs.dim, 3) + self.assertEqual(dirs.sampling, Sampling.CUSTOM) + + def test_invalid_vectors(self): + with self.assertRaises(ValueError): + Directions.from_vectors([(0,0), (0,0)]) + + def test_angle_access_3d(self): + dirs = Directions.uniform(8, dim=3) + with self.assertRaises(ValueError): + _ = dirs.thetas # angles not available in 3D + + def test_endpoint_behavior(self): + num_dirs = 4 + dirs_with_endpoint = Directions.uniform(num_dirs, endpoint=True) + dirs_without_endpoint = Directions.uniform(num_dirs, endpoint=False) + + self.assertNotEqual(dirs_with_endpoint.thetas[-1], + dirs_without_endpoint.thetas[-1]) + + def test_indexing(self): + dirs = Directions.uniform(10) + vector = dirs[0] + self.assertEqual(vector.shape, (2,)) + self.assertAlmostEqual(np.linalg.norm(vector), 1.0) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_ect_graph.py b/tests/test_ect_graph.py index 68e1964..c4a7f25 100644 --- a/tests/test_ect_graph.py +++ b/tests/test_ect_graph.py @@ -1,56 +1,94 @@ import unittest -from ect import embed_graph, ect_graph - +import numpy as np +from ect.utils.examples import create_example_graph, create_example_cw +from ect import ECT, Directions class TestECT(unittest.TestCase): - def test_example_graph_ect(self): - G = embed_graph.create_example_graph() - num_dirs = 8 - num_thresh = 10 - myect = ect_graph.ECT(num_dirs, num_thresh) - self.assertEqual( myect.ECT_matrix.shape, (8,10)) - - r = G.get_bounding_radius() - myect.set_bounding_radius(1.2*r) - ecc = myect.calculateECC(G, 0) - self.assertEqual( len(ecc), num_thresh) - - def test_check_bounding_radius(self): + def setUp(self): + self.graph = create_example_graph() + self.num_dirs = 8 + self.num_thresh = 10 + self.ect = ECT(num_dirs=self.num_dirs, num_thresh=self.num_thresh) + + def test_initialization(self): + self.assertIsNone(self.ect.bound_radius) + self.assertEqual(self.ect.num_dirs, self.num_dirs) + self.assertEqual(self.ect.num_thresh, self.num_thresh) + self.assertIsNone(self.ect.directions) + + def test_calculate_basic(self): + result = self.ect.calculate(self.graph) + self.assertEqual(result.shape, (self.num_dirs, self.num_thresh)) + self.assertTrue(isinstance(result.directions, Directions)) + self.assertIsNotNone(result.thresholds) + + def test_calculate_single_direction(self): + result = self.ect.calculate(self.graph, theta=0) + self.assertEqual(result.shape, (1, self.num_thresh)) + self.assertEqual(len(result.directions), 1) + + def test_bounding_radius(self): + radius = 2.0 + self.ect.set_bounding_radius(radius) + self.assertEqual(self.ect.bound_radius, radius) + self.assertEqual(len(self.ect.thresholds), self.num_thresh) + self.assertEqual(self.ect.thresholds[0], -radius) + self.assertEqual(self.ect.thresholds[-1], radius) + + def test_invalid_bounding_radius(self): + with self.assertRaises(ValueError): + self.ect.set_bounding_radius(-1) + with self.assertRaises(ValueError): + self.ect.calculate(self.graph, bound_radius=-1) + + def test_threshold_priority(self): + graph_radius = self.graph.get_bounding_radius() + instance_radius = 2 * graph_radius + override_radius = 3 * graph_radius + + # test graph radius is used when no other radius specified + result1 = self.ect.calculate(self.graph) + self.assertAlmostEqual(abs(result1.thresholds).max(), graph_radius) + + # test instance radius takes precedence over graph radius + self.ect.set_bounding_radius(instance_radius) + result2 = self.ect.calculate(self.graph) + self.assertAlmostEqual(abs(result2.thresholds).max(), instance_radius) + + # test override radius takes precedence over both + result3 = self.ect.calculate(self.graph, bound_radius=override_radius) + self.assertAlmostEqual(abs(result3.thresholds).max(), override_radius) + + def test_different_graph_types(self): + cw = create_example_cw() + result_graph = self.ect.calculate(self.graph) + result_cw = self.ect.calculate(cw) - # make an example graph - G = embed_graph.create_example_graph() - num_dirs = 8 - num_thresh = 10 - - myect = ect_graph.ECT(num_dirs, num_thresh) - - # At this point, there shouldn't be a radius set - self.assertIs( myect.bound_radius, None) - - # Try to calculate the ECC without a radius set - myect.calculateECC(G, 0, bound_radius=None) - - # Try to calculate the ECC with a negative radius. - # It should throw an error. - with self.assertRaises(ValueError): - myect.calculateECC(G, 0, bound_radius= -1) - - # Try to calculate the ECC with tightbbox set to True - # This should work fine - ecc = myect.calculateECC(G, 0, bound_radius=None) - self.assertEqual( len(ecc), num_thresh) - - # Now set the bounding radius - r = G.get_bounding_radius() - myect.set_bounding_radius(1.2*r) - ecc = myect.calculateECC(G, 0, bound_radius=None) - self.assertEqual( len(ecc), num_thresh) - - # TODO: write a test where we check that if None is passed and the radius is set internally, it will use that one. - + self.assertEqual(result_graph.shape, result_cw.shape) + self.assertEqual(len(result_graph.directions), len(result_cw.directions)) + + def test_directions_matching(self): + # test that directions are reinitialized when dimensions don't match + G2d = create_example_graph() + directions_3d = Directions.uniform(self.num_dirs, dim=3) + ect = ECT(directions=directions_3d) + + result = ect.calculate(G2d) + self.assertEqual(result.directions.dim, 2) + self.assertEqual(len(result.directions), self.num_dirs) - + def test_result_properties(self): + result = self.ect.calculate(self.graph) + + # test smooth transform + smooth = result.smooth() + self.assertEqual(smooth.shape, result.shape) + self.assertEqual(smooth.directions, result.directions) + self.assertEqual(smooth.thresholds.tolist(), result.thresholds.tolist()) + + # verify result is integer-valued + self.assertTrue(np.issubdtype(result.dtype, np.integer)) if __name__ == '__main__': diff --git a/tests/test_ect_result.py b/tests/test_ect_result.py new file mode 100644 index 0000000..15212c1 --- /dev/null +++ b/tests/test_ect_result.py @@ -0,0 +1,83 @@ +import unittest +import numpy as np +import matplotlib.pyplot as plt +from ect import ECT, Directions +from ect.utils.examples import create_example_graph + + +class TestECTResult(unittest.TestCase): + def setUp(self): + self.graph = create_example_graph() + self.ect = ECT(num_dirs=8, num_thresh=10) + self.result = self.ect.calculate(self.graph) + + def test_array_behavior(self): + # test numpy array operations + self.assertTrue(isinstance(self.result + 1, np.ndarray)) + # original array should be integer type + self.assertTrue(np.issubdtype(self.result.dtype, np.integer)) + # mean operation always returns float type in numpy + self.assertTrue(np.issubdtype(self.result.mean().dtype, np.floating)) + self.assertEqual(self.result.shape, (8, 10)) + + def test_metadata_preservation(self): + # test metadata is preserved after operations + result2 = self.result.copy() + self.assertEqual(result2.directions, self.result.directions) + self.assertTrue(np.array_equal(result2.thresholds, self.result.thresholds)) + + def test_smooth_transform(self): + smooth = self.result.smooth() + + # test shape preservation + self.assertEqual(smooth.shape, self.result.shape) + + # test metadata preservation + self.assertEqual(smooth.directions, self.result.directions) + self.assertTrue(np.array_equal(smooth.thresholds, self.result.thresholds)) + + # test each step of SECT calculation + data = self.result.astype(np.float64) + + # 1. test that row averages are subtracted correctly + row_avgs = np.average(data, axis=1) + for i in range(len(row_avgs)): + row = data[i] - row_avgs[i] + self.assertTrue(np.allclose(np.average(row), 0)) + + # 2. test that result is cumulative sum of centered data + centered = data - row_avgs[:, np.newaxis] + expected_smooth = np.cumsum(centered, axis=1) + self.assertTrue(np.allclose(smooth, expected_smooth)) + + def test_plotting(self): + # test basic plotting + ax = self.result.plot() + self.assertTrue(isinstance(ax, plt.Axes)) + plt.close() + + # test plotting with custom axes + fig, ax = plt.subplots() + self.result.plot(ax=ax) + plt.close() + + def test_single_direction_result(self): + result = self.ect.calculate(self.graph, theta=0) + + # test shape + self.assertEqual(result.shape, (1, self.ect.num_thresh)) + + # test plotting single direction + ax = result.plot() + self.assertTrue(isinstance(ax, plt.Axes)) + plt.close() + + def test_array_finalize(self): + # test metadata preservation in array operations + sliced = self.result[2:5] + self.assertEqual(sliced.directions, self.result.directions) + self.assertTrue(np.array_equal(sliced.thresholds, self.result.thresholds)) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_embed_cw.py b/tests/test_embed_cw.py index a69d4b2..3b9caf8 100644 --- a/tests/test_embed_cw.py +++ b/tests/test_embed_cw.py @@ -1,37 +1,49 @@ import unittest -from ect import EmbeddedCW, create_example_cw +from ect.utils.examples import create_example_cw import numpy as np class TestEmbeddedCW(unittest.TestCase): def test_example_cw(self): - # Make sure we can build a grpah in the first place - K = create_example_cw() - self.assertEqual( len(K.nodes), 11) # assuming my_function squares its input - - def test_add_face(self): - # Make sure adding a vertex updates the coordiantes list G = create_example_cw() - G.add_face(['D','B','C']) - self.assertEqual( len(G.faces), 3) - - # TODO: Add test to check that adding an invalid face throws an error + self.assertEqual(len(G.nodes), 11) + self.assertEqual(len(G.faces), 2) def test_get_coordinates(self): - # Make sure we can get the coordinates of a vertex G = create_example_cw(centered=False) - coords = G.get_coordinates('A') - self.assertEqual( coords, (1, 2)) + coords = G.get_coord('A') + self.assertTrue(np.array_equal(coords, np.array([1, 2]))) def test_mean_centered_coordinates(self): - # Make sure the mean centered coordinates are correct G = create_example_cw(centered=False) - G.set_centered_coordinates(type = 'mean') - x_coords = [x for x, y in G.coordinates.values()] + G.center_coordinates('mean') + x_coords = G.coord_matrix[:, 0] + self.assertAlmostEqual(np.average(x_coords), 0, places=1) - self.assertAlmostEqual( np.average(x_coords), 0, places = 1) + def test_add_face(self): + G = create_example_cw() + face = ['A', 'B', 'C'] + G.add_edges_from([(face[i], face[(i+1)%3]) for i in range(3)]) + G.add_face(face) + self.assertIn(tuple(face), G.faces) + + def test_invalid_face(self): + G = create_example_cw() + face = ['A', 'B', 'C'] + G.add_edges_from([(face[i], face[(i+1)%3]) for i in range(3)]) + + # test non-existent vertex + with self.assertRaises(ValueError): + G.add_face(['A', 'B', 'Z']) + + # test face with missing edges + with self.assertRaises(ValueError): + G.add_face(['A', 'D', 'E']) + + # test too short face + with self.assertRaises(ValueError): + G.add_face(['A', 'B']) - if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/tests/test_embed_graph.py b/tests/test_embed_graph.py index 41320f5..e8e725c 100644 --- a/tests/test_embed_graph.py +++ b/tests/test_embed_graph.py @@ -1,102 +1,99 @@ import unittest -from ect import embed_graph +from ect.utils.examples import create_example_graph import numpy as np class TestEmbeddedGraph(unittest.TestCase): def test_example_graph(self): - # Make sure we can build a grpah in the first place - G = embed_graph.create_example_graph() - self.assertEqual( len(G.nodes), 6) # assuming my_function squares its input + G = create_example_graph() + self.assertEqual(len(G.nodes), 6) + self.assertEqual(G.dim, 2) + def test_coord_matrix(self): + G = create_example_graph() + self.assertEqual(G.coord_matrix.shape, (6, 2)) + self.assertTrue(isinstance(G.coord_matrix, np.ndarray)) + + def test_node_list(self): + G = create_example_graph() + self.assertEqual(len(G.node_list), 6) + self.assertEqual(set(G.node_list), set(G.nodes)) def test_add_node(self): - # Make sure adding a vertex updates the coordiantes list - G = embed_graph.create_example_graph() - G.add_node('G', 1, 2) - self.assertEqual( len(G.nodes), 7) - self.assertEqual( len(G.coordinates), 7) + G = create_example_graph() + G.add_node('G', [1, 2]) + self.assertEqual(len(G.nodes), 7) + self.assertEqual(G.coord_matrix.shape, (7, 2)) + self.assertEqual(G.get_coord('G').tolist(), [1, 2]) def test_add_edge(self): - # Make sure adding an edge updates the edge list - G = embed_graph.create_example_graph() + G = create_example_graph() G.add_edge('A', 'B') - self.assertEqual( len(G.edges), 6) + self.assertEqual(len(G.edges), 6) - def test_get_coordinates(self): - # Make sure we can get the coordinates of a vertex - G = embed_graph.create_example_graph(centered=False) - coords = G.get_coordinates('A') - self.assertEqual( coords, (1, 2)) + def test_get_coord(self): + G = create_example_graph(centered=False) + coords = G.get_coord('A') + self.assertTrue(np.array_equal(coords, np.array([1, 2]))) - def test_coords_list(self): - # Make sure the keys in the coordinates list are the same as the nodes - G = embed_graph.create_example_graph(centered=False) - self.assertEqual( len(G.nodes), len(G.coordinates)) - self.assertEqual( set(G.nodes), set(G.coordinates.keys())) + def test_invalid_node_operations(self): + G = create_example_graph() + with self.assertRaises(ValueError): + G.add_node('A', [1, 2]) + with self.assertRaises(ValueError): + G.get_coord('Z') def test_mean_centered_coordinates(self): - # Make sure the mean centered coordinates are correct - G = embed_graph.create_example_graph(centered=False) - G.set_centered_coordinates(type = 'mean') - x_coords = [x for x, y in G.coordinates.values()] - - self.assertAlmostEqual( np.average(x_coords), 0, places = 1) + G = create_example_graph(centered=False) + G.center_coordinates('mean') + x_coords = G.coord_matrix[:, 0] + self.assertAlmostEqual(np.average(x_coords), 0, places=1) def test_get_center(self): - G = embed_graph.create_example_graph() - center = G.get_center(type = 'mean') + G = create_example_graph() + center = G.get_center('mean') self.assertIsInstance(center, np.ndarray) self.assertEqual(len(center), 2) - # Check if center is correctly calculated - coords = np.array(list(G.coordinates.values())) + coords = G.coord_matrix expected_center = np.mean(coords, axis=0) np.testing.assert_almost_equal(center, expected_center) def test_rescale_to_unit_disk(self): - G = embed_graph.create_example_graph() - original_center = G.get_center() - G.rescale_to_unit_disk(preserve_center=True) + G = create_example_graph() + G.scale_coordinates(1.0) self.assertAlmostEqual(G.get_bounding_radius(), 1.0, places=6) - np.testing.assert_almost_equal(G.get_center(), original_center) - G = embed_graph.create_example_graph() - G.rescale_to_unit_disk(preserve_center=False) - self.assertAlmostEqual(G.get_bounding_radius(), 1.0, places=6) - np.testing.assert_almost_equal(G.get_center(), np.array([0, 0]), decimal=6) + coords_before = G.coord_matrix.copy() + G.scale_coordinates(2.0) + coords_after = G.coord_matrix + self.assertTrue(np.allclose(coords_after / 2.0, coords_before)) - def test_min_max_centered_coordinates(self): - # Make sure the min-max centered coordinates are correct - G = embed_graph.create_example_graph(centered=False) - G.set_centered_coordinates(type = 'min_max') - x_coords = [x for x, y in G.coordinates.values()] - y_coords = [y for x, y in G.coordinates.values()] + def test_bounding_box_centered_coordinates(self): + G = create_example_graph(centered=False) + G.center_coordinates('bounding_box') + x_coords = G.coord_matrix[:, 0] + y_coords = G.coord_matrix[:, 1] - self.assertAlmostEqual( np.max(x_coords) + np.min(x_coords), 0, places = 1) - self.assertAlmostEqual( np.max(y_coords) + np.min(y_coords), 0, places = 1) + self.assertAlmostEqual(np.max(x_coords) + np.min(x_coords), 0, places=1) + self.assertAlmostEqual(np.max(y_coords) + np.min(y_coords), 0, places=1) def test_PCA_coords(self): - # Make sure the PCA coordinates are running - # Note this doesn't check correctness - G = embed_graph.create_example_graph(centered=False) - G.set_PCA_coordinates() - self.assertEqual( len(G.coordinates), 6) + G = create_example_graph(centered=False) + G.project_coordinates('pca') + self.assertEqual(G.coord_matrix.shape, (6, 2)) def test_add_cycle(self): - # Make sure we can add a loop of input - G = embed_graph.create_example_graph(centered=False) + G = create_example_graph(centered=False) num_verts = len(G.nodes) num_edges = len(G.edges) verts_to_add = 8 loop_coords = 3*np.random.rand(verts_to_add, 2) G.add_cycle(loop_coords) - G.plot() - self.assertEqual( len(G.nodes), num_verts + verts_to_add) - self.assertEqual( len(G.edges), num_edges + verts_to_add) - + self.assertEqual(len(G.nodes), num_verts + verts_to_add) + self.assertEqual(len(G.edges), num_edges + verts_to_add) if __name__ == '__main__': From 0995f82f0dfb627feb9c697e7676a7ea4536b61d Mon Sep 17 00:00:00 2001 From: yemeen Date: Thu, 13 Mar 2025 17:46:19 -0400 Subject: [PATCH 16/35] start sklearn estimator implement --- src/ect/ect_graph.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/ect/ect_graph.py b/src/ect/ect_graph.py index 0d11af5..747f82e 100644 --- a/src/ect/ect_graph.py +++ b/src/ect/ect_graph.py @@ -4,6 +4,7 @@ import numpy as np from numba import jit, prange from typing import Optional, Union +from sklearn.base import BaseEstimator, TransformerMixin from .embed_cw import EmbeddedCW from .embed_graph import EmbeddedGraph @@ -12,7 +13,7 @@ -class ECT: +class ECT(BaseEstimator, TransformerMixin): """ A class to calculate the Euler Characteristic Transform (ECT) from an input :any:`EmbeddedGraph` or :any:`EmbeddedCW`. @@ -31,10 +32,10 @@ class ECT: def __init__(self, directions: Optional[Directions] = None, - *, num_dirs: Optional[int] = None, num_thresh: Optional[int] = None, - bound_radius: Optional[float] = None): + bound_radius: Optional[float] = None, + dtype=np.int32): """Initialize ECT calculator with either a Directions object or sampling parameters Args: @@ -42,7 +43,10 @@ def __init__(self, num_dirs: Number of directions to sample (ignored if directions provided) num_thresh: Number of threshold values (required if directions not provided) bound_radius: Optional radius for bounding circle + dtype: numpy dtype for output (default: np.int32 to save memory) """ + super().__init__() + self.dtype = dtype if directions is not None: self.directions = directions self.num_dirs = len(directions) @@ -58,6 +62,21 @@ def __init__(self, if bound_radius is not None: self.set_bounding_radius(bound_radius) + def fit(self, X, y=None): + if not all(isinstance(x, (EmbeddedGraph, EmbeddedCW)) for x in X): + raise ValueError("All elements must be EmbeddedGraph or EmbeddedCW instances") + return self + + def transform(self, X): + results = [] + for graph in X: + ect_result = self.calculate(graph) + results.append(ect_result) + return np.array(results) + + def fit_transform(self, X, y=None): + return self.fit(X, y).transform(X) + @staticmethod def _ensure_valid_directions(calculation_method): """ @@ -118,7 +137,7 @@ def calculate(self, graph: Union[EmbeddedGraph, EmbeddedCW], theta=None, bound_r ect = self._compute_directional_transform(simplex_projections, thresholds) - return ECTResult(ect, directions, thresholds) + return ECTResult(ect.astype(self.dtype), directions, thresholds) def _compute_node_projections(self, coords, directions): """Compute inner products of coordinates with directions""" @@ -159,7 +178,7 @@ def _compute_directional_transform(simplex_projections_list, thresholds): """ num_dir = simplex_projections_list[0].shape[1] num_thresh = thresholds.shape[0] - result = np.empty((num_dir, num_thresh), dtype=np.int32) + result = np.empty((num_dir, num_thresh)) sorted_projections = [] for proj in simplex_projections_list: From 4f06b0863c4eebc92180703c0061f044fe89a7a7 Mon Sep 17 00:00:00 2001 From: yemeen Date: Sun, 30 Mar 2025 15:30:19 -0400 Subject: [PATCH 17/35] Refactor ECT class with simpler initialization, fix test failures, improve plotting --- src/ect/ect_graph.py | 140 ++++++++--------- src/ect/embed_cw.py | 44 ++++-- src/ect/embed_graph.py | 310 ++++++++++++++++++++++++++------------ src/ect/results.py | 2 +- src/ect/utils/examples.py | 24 +++ 5 files changed, 341 insertions(+), 179 deletions(-) diff --git a/src/ect/ect_graph.py b/src/ect/ect_graph.py index 747f82e..4874f0c 100644 --- a/src/ect/ect_graph.py +++ b/src/ect/ect_graph.py @@ -4,7 +4,6 @@ import numpy as np from numba import jit, prange from typing import Optional, Union -from sklearn.base import BaseEstimator, TransformerMixin from .embed_cw import EmbeddedCW from .embed_graph import EmbeddedGraph @@ -12,8 +11,7 @@ from .results import ECTResult - -class ECT(BaseEstimator, TransformerMixin): +class ECT: """ A class to calculate the Euler Characteristic Transform (ECT) from an input :any:`EmbeddedGraph` or :any:`EmbeddedCW`. @@ -30,52 +28,25 @@ class ECT(BaseEstimator, TransformerMixin): Either ``None``, or a positive radius of the bounding circle. """ - def __init__(self, - directions: Optional[Directions] = None, - num_dirs: Optional[int] = None, - num_thresh: Optional[int] = None, - bound_radius: Optional[float] = None, - dtype=np.int32): + def __init__( + self, + directions: Optional[Directions] = None, + num_dirs: Optional[int] = 360, + num_thresh: Optional[int] = None, + bound_radius: Optional[float] = None, + ): """Initialize ECT calculator with either a Directions object or sampling parameters - + Args: directions: Optional pre-configured Directions object num_dirs: Number of directions to sample (ignored if directions provided) num_thresh: Number of threshold values (required if directions not provided) bound_radius: Optional radius for bounding circle - dtype: numpy dtype for output (default: np.int32 to save memory) """ - super().__init__() - self.dtype = dtype - if directions is not None: - self.directions = directions - self.num_dirs = len(directions) - if num_thresh is None: - self.num_thresh = self.num_dirs - else: - self.directions = None - self.num_dirs = num_dirs or 360 - self.num_thresh = num_thresh or self.num_dirs - - self.bound_radius = None - self.thresholds = None - if bound_radius is not None: - self.set_bounding_radius(bound_radius) - - def fit(self, X, y=None): - if not all(isinstance(x, (EmbeddedGraph, EmbeddedCW)) for x in X): - raise ValueError("All elements must be EmbeddedGraph or EmbeddedCW instances") - return self - - def transform(self, X): - results = [] - for graph in X: - ect_result = self.calculate(graph) - results.append(ect_result) - return np.array(results) - - def fit_transform(self, X, y=None): - return self.fit(X, y).transform(X) + self.directions = directions + self.num_dirs = len(directions) if directions is not None else num_dirs + self.num_thresh = num_thresh or num_dirs + self.bound_radius = bound_radius @staticmethod def _ensure_valid_directions(calculation_method): @@ -83,42 +54,56 @@ def _ensure_valid_directions(calculation_method): Decorator to ensure directions match graph dimension. Reinitializes directions if dimensions don't match. """ + @wraps(calculation_method) def wrapper(ect_instance, graph, *args, **kwargs): if ect_instance.directions is None: ect_instance.directions = Directions.uniform( - ect_instance.num_dirs, dim=graph.dim) + ect_instance.num_dirs, dim=graph.dim + ) elif ect_instance.directions.dim != graph.dim: ect_instance.directions = Directions.uniform( - ect_instance.num_dirs, dim=graph.dim) + ect_instance.num_dirs, dim=graph.dim + ) return calculation_method(ect_instance, graph, *args, **kwargs) + return wrapper def set_bounding_radius(self, radius: Optional[float]): """Sets the bounding radius and updates thresholds""" if radius is not None and radius <= 0: - raise ValueError(f'Bounding radius must be positive, got {radius}') - + raise ValueError(f"Bounding radius must be positive, got {radius}") + self.bound_radius = radius if radius is not None: self.thresholds = np.linspace(-radius, radius, self.num_thresh) - def get_thresholds(self, graph: Union[EmbeddedGraph, EmbeddedCW], override_radius: Optional[float] = None): + def get_thresholds( + self, + graph: Union[EmbeddedGraph, EmbeddedCW], + override_radius: Optional[float] = None, + ): """Gets thresholds based on priority: override_radius > instance radius > graph radius""" if override_radius is not None: if override_radius <= 0: - raise ValueError(f'Bounding radius must be positive, got {override_radius}') - return override_radius, np.linspace(-override_radius, override_radius, self.num_thresh) - + raise ValueError( + f"Bounding radius must be positive, got {override_radius}" + ) + return override_radius, np.linspace( + -override_radius, override_radius, self.num_thresh + ) + if self.bound_radius is not None: return self.bound_radius, self.thresholds - + graph_radius = graph.get_bounding_radius() return graph_radius, np.linspace(-graph_radius, graph_radius, self.num_thresh) @_ensure_valid_directions - def calculate(self, graph: Union[EmbeddedGraph, EmbeddedCW], theta=None, bound_radius=None): + def calculate( + self, graph: Union[EmbeddedGraph, EmbeddedCW], theta=None, bound_radius=None + ): """Calculate Euler Characteristic Transform (ECT) for a given graph and direction theta Args: @@ -131,59 +116,67 @@ def calculate(self, graph: Union[EmbeddedGraph, EmbeddedCW], theta=None, bound_r """ radius, thresholds = self.get_thresholds(graph, bound_radius) - directions = self.directions if theta is None else Directions.from_angles([theta]) + directions = ( + self.directions if theta is None else Directions.from_angles([theta]) + ) simplex_projections = self._compute_simplex_projections(graph, directions) ect = self._compute_directional_transform(simplex_projections, thresholds) - return ECTResult(ect.astype(self.dtype), directions, thresholds) + return ECTResult(ect, directions, thresholds) def _compute_node_projections(self, coords, directions): """Compute inner products of coordinates with directions""" return np.matmul(coords, directions.vectors.T) - - def _compute_simplex_projections(self, graph: Union[EmbeddedGraph, EmbeddedCW], directions): + + def _compute_simplex_projections( + self, graph: Union[EmbeddedGraph, EmbeddedCW], directions + ): """Compute max projections of each simplex (vertices, edges, faces)""" simplex_projections = [] - node_projections = self._compute_node_projections(graph.coord_matrix, directions) - edge_maxes = np.maximum(node_projections[graph.edge_indices[:, 0]], - node_projections[graph.edge_indices[:, 1]]) - + node_projections = self._compute_node_projections( + graph.coord_matrix, directions + ) + edge_maxes = np.maximum( + node_projections[graph.edge_indices[:, 0]], + node_projections[graph.edge_indices[:, 1]], + ) + simplex_projections.append(node_projections) simplex_projections.append(edge_maxes) - + if isinstance(graph, EmbeddedCW) and len(graph.faces) > 0: node_to_index = {n: i for i, n in enumerate(graph.node_list)} face_indices = [[node_to_index[v] for v in face] for face in graph.faces] - face_maxes = np.array([np.max(node_projections[face, :], axis=0) - for face in face_indices]) + face_maxes = np.array( + [np.max(node_projections[face, :], axis=0) for face in face_indices] + ) simplex_projections.append(face_maxes) - - return simplex_projections + return simplex_projections @staticmethod @jit(nopython=True, parallel=True, fastmath=True) def _compute_directional_transform(simplex_projections_list, thresholds): """Compute ECT by counting simplices below each threshold - + Args: simplex_projections_list: List of arrays containing projections for each simplex type [vertex_projections, edge_projections, face_projections] thresholds: Array of threshold values to compute ECT at - + Returns: Array of shape (num_directions, num_thresholds) containing Euler characteristics """ num_dir = simplex_projections_list[0].shape[1] num_thresh = thresholds.shape[0] - result = np.empty((num_dir, num_thresh)) + result = np.empty((num_dir, num_thresh), dtype=np.int32) sorted_projections = [] for proj in simplex_projections_list: sorted_proj = np.empty_like(proj) - for i in prange(num_dir): + for i in prange(num_dir): sorted_proj[:, i] = np.sort(proj[:, i]) sorted_projections.append(sorted_proj) @@ -191,7 +184,7 @@ def compute_shape_descriptor(simplex_counts_list): """Calculate shape descriptor from simplex counts (Euler characteristic)""" chi = 0 for k in range(len(simplex_counts_list)): - chi += (-1)**k * simplex_counts_list[k] + chi += (-1) ** k * simplex_counts_list[k] return chi for j in prange(num_thresh): @@ -200,9 +193,8 @@ def compute_shape_descriptor(simplex_counts_list): simplex_counts_list = [] for k in range(len(sorted_projections)): projs = sorted_projections[k][:, i] - simplex_counts_list.append(np.searchsorted(projs, thresh, side='right')) + simplex_counts_list.append( + np.searchsorted(projs, thresh, side="right") + ) result[i, j] = compute_shape_descriptor(simplex_counts_list) return result - - - diff --git a/src/ect/embed_cw.py b/src/ect/embed_cw.py index 596e25b..49bde28 100644 --- a/src/ect/embed_cw.py +++ b/src/ect/embed_cw.py @@ -7,7 +7,7 @@ class EmbeddedCW(EmbeddedGraph): """ - A class to represent a straight-line-embedded CW complex. We assume that the coordinates for the embedding of the vertices are given, the 1-skeleton is in fact a graph (so not as general as a full CW complex) with straight line embeddings, and 2-Cells are the interior of the shape outlined by its boundary edges. + A class to represent a straight-line-embedded CW complex. We assume that the coordinates for the embedding of the vertices are given, the 1-skeleton is in fact a graph (so not as general as a full CW complex) with straight line embeddings, and 2-Cells are the interior of the shape outlined by its boundary edges. Faces should be passed in as a list of vertices, where the vertices are in order around the face. However, the ECT function will likely still work if the ordering is different. The drawing functions however might look strange. Note the class does not (yet?) check to make sure the face is valid, i.e. is a cycle in the graph, and bounds a region in the plane. @@ -29,8 +29,9 @@ def add_from_embedded_graph(self, G): G (EmbeddedGraph): The EmbeddedGraph object to add from. """ - nodes_with_coords = [(node, G.coord_matrix[G.node_to_index[node]]) - for node in G.nodes()] + nodes_with_coords = [ + (node, G.coord_matrix[G.node_to_index[node]]) for node in G.nodes() + ] self.add_nodes_from(nodes_with_coords) self.add_edges_from(G.edges()) @@ -54,11 +55,15 @@ def add_face(self, face, check=True): if not self.has_edge(u, v): raise ValueError(f"Edge ({u},{v}) missing") - polygon = np.array([self.coord_matrix[self._node_to_index[v]] for v in face]) + polygon = np.array( + [self.coord_matrix[self._node_to_index[v]] for v in face] + ) for node in self.nodes: if node in face: continue - if point_in_polygon(self.coord_matrix[self._node_to_index[node]], polygon): + if point_in_polygon( + self.coord_matrix[self._node_to_index[node]], polygon + ): raise ValueError(f"Node {node} inside face {face}") self.faces.append(tuple(face)) @@ -85,7 +90,9 @@ def plot_faces(self, theta=None, ax=None, **kwargs): fig = ax.get_figure() for face in self.faces: - face_coords = np.array([self.coord_matrix[self.node_to_index[v]] for v in face]) + face_coords = np.array( + [self.coord_matrix[self.node_to_index[v]] for v in face] + ) ax.fill(face_coords[:, 0], face_coords[:, 1], **kwargs) return ax @@ -94,16 +101,31 @@ def plot(self, bounding_circle=False, color_nodes_theta=None, ax=None, **kwargs) """ Plots the graph with the faces filled in. + Parameters: + bounding_circle (bool): + Whether to plot the bounding circle + color_nodes_theta (float, optional): + Angle to use for coloring nodes + ax (matplotlib.axes.Axes, optional): + The axes to plot on. If None, creates new axes + **kwargs: + Additional keyword arguments passed to plotting functions + Returns: - matplotlib.axes.Axes - The axes object with the plot. + matplotlib.axes.Axes: + The axes object with the plot """ if ax is None: fig, ax = plt.subplots() else: fig = ax.get_figure() - ax = self.plot_faces(0, facecolor='lightblue', ax=ax) - ax = super().plot(bounding_circle=bounding_circle, - color_nodes_theta=color_nodes_theta, ax=ax) + ax = self.plot_faces(ax=ax, facecolor="lightblue") + + ax = super().plot( + bounding_circle=bounding_circle, + color_nodes_theta=color_nodes_theta, + ax=ax, + **kwargs, + ) return ax diff --git a/src/ect/embed_graph.py b/src/ect/embed_graph.py index 60d65b9..ec463db 100644 --- a/src/ect/embed_graph.py +++ b/src/ect/embed_graph.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Dict, List, Tuple, Optional +from typing import Dict, List, Tuple, Optional, Union import networkx as nx import numpy as np @@ -9,7 +9,6 @@ from .utils.naming import next_vert_name - CENTER_TYPES = ["mean", "bounding_box", "origin"] TRANSFORM_TYPES = ["pca"] @@ -26,7 +25,7 @@ class EmbeddedGraph(nx.Graph): node_list : list a list of node names node_to_index : dict - a dictionary mapping node ids to their index in the coord_matrix + a dictionary mapping node ids to their index in the coord_matrix dim : int the dimension of the embedded coordinates @@ -65,14 +64,15 @@ def node_to_index(self): @property def position_dict(self): """Return a dictionary mapping node ids to their coordinates""" - return {node: self._coord_matrix[i] - for i, node in enumerate(self._node_list)} + return {node: self._coord_matrix[i] for i, node in enumerate(self._node_list)} @property def edge_indices(self): """Return edges as array of index pairs""" - edges = np.array([(self._node_to_index[u], self._node_to_index[v]) - for u, v in self.edges()], dtype=int) + edges = np.array( + [(self._node_to_index[u], self._node_to_index[v]) for u, v in self.edges()], + dtype=int, + ) return edges if len(edges) > 0 else np.empty((0, 2), dtype=int) # ====================================== @@ -81,9 +81,11 @@ def edge_indices(self): @staticmethod def _validate_coords(func): """Validates if coordinates are nonempty and have valid dimension""" + def wrapper(self, *args, **kwargs): - coords = next((arg for arg in args if isinstance( - arg, (list, np.ndarray))), None) + coords = next( + (arg for arg in args if isinstance(arg, (list, np.ndarray))), None + ) if coords is not None: coords = np.asarray(coords, dtype=float) if coords.ndim != 1: @@ -93,21 +95,24 @@ def wrapper(self, *args, **kwargs): if len(self._node_list) > 0: if coords.size != self._coord_matrix.shape[1]: raise ValueError( - f"Coordinates must have dimension {self._coord_matrix.shape[1]}") + f"Coordinates must have dimension {self._coord_matrix.shape[1]}" + ) return func(self, *args, **kwargs) + return wrapper @staticmethod def _validate_node(exists=True): """Validates if nodes exist or not already""" + def decorator(func): def wrapper(self, *args, **kwargs): # Handle both positional and keyword arguments if args: nodes = args[0] if isinstance(args[0], (list, tuple)) else [args[0]] else: - node_id = kwargs.get('node_id') or kwargs.get('node_id1') + node_id = kwargs.get("node_id") or kwargs.get("node_id1") nodes = [node_id] if node_id else [] for node_id in nodes: @@ -117,14 +122,16 @@ def wrapper(self, *args, **kwargs): if not exists and node_exists: raise ValueError(f"Node {node_id} already exists") return func(self, *args, **kwargs) + return wrapper + return decorator @_validate_coords @_validate_node(exists=False) def add_node(self, node_id, coord): """Add a vertex to the graph. - + Args: node_id: Identifier for the node coord: Array-like coordinates for the node @@ -135,14 +142,19 @@ def add_node(self, node_id, coord): self._coord_matrix = coord.reshape(1, -1) else: coord_reshaped = coord.reshape(1, -1) - self._coord_matrix = np.vstack( - [self._coord_matrix, coord_reshaped]) + self._coord_matrix = np.vstack([self._coord_matrix, coord_reshaped]) self._node_list.append(node_id) self._node_to_index[node_id] = len(self._node_list) - 1 super().add_node(node_id) - def add_nodes_from(self, nodes_with_coords): + def add_nodes_from_dict(self, nodes_with_coords: Dict[Union[str, int], np.ndarray]): + for node_id, coordinates in nodes_with_coords.items(): + self.add_node(node_id, coordinates) + + def add_nodes_from( + self, nodes_with_coords: List[Tuple[Union[str, int], np.ndarray]] + ): for node_id, coordinates in nodes_with_coords: self.add_node(node_id, coordinates) @@ -169,25 +181,23 @@ def set_coord(self, node_id, new_coords): def add_cycle(self, coord_matrix): """Add nodes in a cyclic pattern from coordinate matrix""" n = coord_matrix.shape[0] - new_names = next_vert_name( - self._node_list[-1] if self._node_list else 0, n) + new_names = next_vert_name(self._node_list[-1] if self._node_list else 0, n) self.add_nodes_from(zip(new_names, coord_matrix)) - self.add_edges_from([(new_names[i], new_names[(i+1) % n]) - for i in range(n)]) + self.add_edges_from([(new_names[i], new_names[(i + 1) % n]) for i in range(n)]) # ====================================== # Geometric Calculations # ====================================== - def get_center(self, method: str = 'bounding_box') -> np.ndarray: + def get_center(self, method: str = "bounding_box") -> np.ndarray: """Calculate center of coordinates""" coords = self._coord_matrix - if method == 'mean': + if method == "mean": return np.mean(coords, axis=0) - elif method == 'bounding_box': + elif method == "bounding_box": return (np.max(coords, axis=0) + np.min(coords, axis=0)) / 2 - elif method == 'origin': + elif method == "origin": return np.zeros(self.dim) raise ValueError(f"Unknown center method: {method}") @@ -195,16 +205,15 @@ def get_bounding_box(self): """Get (min, max) for each dimension""" return [(dim.min(), dim.max()) for dim in self._coord_matrix.T] - def get_bounding_radius(self, center_type: str = 'mean') -> float: + def get_bounding_radius(self, center_type: str = "mean") -> float: """Get radius of minimal bounding sphere""" center = self.get_center(center_type) coords = self._coord_matrix return np.max(np.linalg.norm(coords - center, axis=1)) - def get_normal_angle_matrix(self, - edges_only: bool = False, - decimals: Optional[int] = None - ) -> Tuple[np.ndarray, List[str]]: + def get_normal_angle_matrix( + self, edges_only: bool = False, decimals: Optional[int] = None + ) -> Tuple[np.ndarray, List[str]]: """ Optimized angle matrix computation using vectorized operations. @@ -233,8 +242,8 @@ def get_normal_angle_matrix(self, dx = coords[v_indices, 0] - coords[u_indices, 0] dy = coords[v_indices, 1] - coords[u_indices, 1] - angles = np.arctan2(dx, -dy) % (2*np.pi) - rev_angles = (angles + np.pi) % (2*np.pi) + angles = np.arctan2(dx, -dy) % (2 * np.pi) + rev_angles = (angles + np.pi) % (2 * np.pi) if decimals is not None: angles = np.round(angles, decimals) @@ -252,7 +261,7 @@ def get_normal_angle_matrix(self, dy = y[:, None] - y[None, :] # Compute angles and mask invalid pairs - angle_matrix = np.arctan2(dx, -dy) % (2*np.pi) + angle_matrix = np.arctan2(dx, -dy) % (2 * np.pi) angle_matrix[np.isclose(dx**2 + dy**2, 0)] = np.nan # Zero vectors if decimals is not None: @@ -263,10 +272,9 @@ def get_normal_angle_matrix(self, return angle_matrix, vertices - def get_normal_angles(self, - edges_only: bool = False, - decimals: int = 6 - ) -> Dict[float, List[Tuple[str, str]]]: + def get_normal_angles( + self, edges_only: bool = False, decimals: int = 6 + ) -> Dict[float, List[Tuple[str, str]]]: """ Optimized angle dictionary construction using NumPy grouping. @@ -298,9 +306,11 @@ def get_normal_angles(self, unique_angles, inverse = np.unique(valid_angles, return_inverse=True) for idx, angle in enumerate(unique_angles): - mask = (inverse == idx) - pairs = [(vertices[i], vertices[j]) - for i, j in zip(valid_rows[mask], valid_cols[mask])] + mask = inverse == idx + pairs = [ + (vertices[i], vertices[j]) + for i, j in zip(valid_rows[mask], valid_cols[mask]) + ] angle_dict[float(angle)].extend(pairs) return angle_dict @@ -329,7 +339,7 @@ def scale_coordinates(self, radius=1): """Scale coordinates to fit within given radius""" current_max = np.linalg.norm(self._coord_matrix, axis=1).max() if current_max > 0: - self._coord_matrix *= (radius / current_max) + self._coord_matrix *= radius / current_max def project_coordinates(self, projection_type="pca"): """Project coordinates using a function""" @@ -359,18 +369,19 @@ def validate_plot_parameters(func): """Decorator to validate plot method parameters""" def wrapper(self, *args, **kwargs): - bounding_center_type = kwargs.get( - 'bounding_center_type', 'bounding_box') + bounding_center_type = kwargs.get("bounding_center_type", "bounding_box") if self.dim not in [2, 3]: - raise ValueError( - "At least 2D or 3D coordinates required for plotting") + raise ValueError("At least 2D or 3D coordinates required for plotting") if bounding_center_type not in CENTER_TYPES: - raise ValueError(f"Invalid center type: {bounding_center_type}. " - f"Valid options: {CENTER_TYPES}") + raise ValueError( + f"Invalid center type: {bounding_center_type}. " + f"Valid options: {CENTER_TYPES}" + ) return func(self, *args, **kwargs) + return wrapper @validate_plot_parameters @@ -385,39 +396,57 @@ def plot( edge_color: str = "gray", elev: float = 25, azim: float = -60, - **kwargs + **kwargs, ) -> plt.Axes: """ Visualize the embedded graph in 2D or 3D """ ax = self._create_axes(ax, self.dim) - original_dim = self.dim - pos = {node: self._coord_matrix[i] - for i, node in enumerate(self._node_list)} + pos = {node: self._coord_matrix[i] for i, node in enumerate(self._node_list)} if self.dim == 2: - self._draw_2d(ax, pos, with_labels, - node_size, edge_color, **kwargs) + self._draw_2d(ax, pos, with_labels, node_size, edge_color, **kwargs) else: self._draw_3d(ax, pos, node_size, edge_color, elev, azim, **kwargs) if color_nodes_theta is not None: - self._add_node_coloring( - ax, pos, color_nodes_theta, node_size, self.dim, **kwargs) + # Calculate directional projection values + direction = np.array( + [np.sin(color_nodes_theta), -np.cos(color_nodes_theta)] + ) + node_colors = np.dot(self._coord_matrix, direction) + + self._add_node_coloring(ax, pos, node_colors, node_size, self.dim, **kwargs) if bounding_circle: self._add_bounding_shape(ax, bounding_center_type, self.dim) + # Configure axes with nice ticks + self._configure_axes(ax) + return ax - def _create_axes(self, ax): - """Create appropriate axes if not provided""" + def _create_axes(self, ax, dim=None): + """Create appropriate axes if not provided + + Parameters: + ax (matplotlib.axes.Axes, optional): + The axes to use. If None, creates new axes + dim (int, optional): + Dimension of the plot. If None, uses self.dim + + Returns: + matplotlib.axes.Axes: + The configured axes object + """ + if dim is None: + dim = self.dim + if ax is None: fig = plt.figure() - ax = fig.add_subplot( - 111, projection='3d' if self.dim == 3 else None) - elif self.dim == 3 and not hasattr(ax, 'zaxis'): + ax = fig.add_subplot(111, projection="3d" if dim == 3 else None) + elif dim == 3 and not hasattr(ax, "zaxis"): raise ValueError("For 3D plots, provide axes with 3D projection") return ax @@ -427,8 +456,14 @@ def _draw_2d(self, ax, pos, with_labels, node_size, edge_color, **kwargs): self, pos=pos, ax=ax, edge_color=edge_color, width=1.5, **kwargs ) nx.draw_networkx_nodes( - self, pos=pos, ax=ax, node_size=node_size, - node_color="lightblue", edgecolors="black", linewidths=0.5, **kwargs + self, + pos=pos, + ax=ax, + node_size=node_size, + node_color="lightblue", + edgecolors="black", + linewidths=0.5, + **kwargs, ) if with_labels: nx.draw_networkx_labels( @@ -441,8 +476,13 @@ def _draw_3d(self, ax, pos, node_size, edge_color, elev, azim, **kwargs): coords = np.array(list(pos.values())) ax.scatter3D( - coords[:, 0], coords[:, 1], coords[:, 2], - s=node_size, c='lightblue', edgecolors='black', linewidth=0.5 + coords[:, 0], + coords[:, 1], + coords[:, 2], + s=node_size, + c="lightblue", + edgecolors="black", + linewidth=0.5, ) for u, v in self.edges(): @@ -451,46 +491,89 @@ def _draw_3d(self, ax, pos, node_size, edge_color, elev, azim, **kwargs): z = [pos[u][2], pos[v][2]] ax.plot3D(x, y, z, color=edge_color, linewidth=1.5) - def _add_node_coloring(self, ax, pos, theta, node_size, **kwargs): - """Add node coloring based on directional projection""" - node_colors = self._calculate_node_colors(theta) + def _add_node_coloring(self, ax, pos, node_colors, node_size, dim=None, **kwargs): + """Add node coloring based on provided values + + Parameters: + ax (matplotlib.axes.Axes): + The axes to add coloring to + pos (dict): + Dictionary of node positions + node_colors (array-like): + Values to use for coloring nodes + node_size (int): + Size of nodes in visualization + dim (int, optional): + Dimension of the plot. If None, uses self.dim + **kwargs: + Additional keyword arguments for plotting + """ + if dim is None: + dim = self.dim - if self.dim == 2: + if dim == 2: nodes = nx.draw_networkx_nodes( - self, pos=pos, ax=ax, node_size=node_size, - node_color=node_colors, cmap=plt.cm.viridis, - edgecolors="black", linewidths=0.5, **kwargs + self, + pos=pos, + ax=ax, + node_size=node_size, + node_color=node_colors, + cmap=plt.cm.viridis, + edgecolors="black", + linewidths=0.5, + **kwargs, ) else: coords = np.array(list(pos.values())) nodes = ax.scatter3D( - coords[:, 0], coords[:, 1], coords[:, 2], - s=node_size, c=node_colors, cmap=plt.cm.viridis, - edgecolors='black', linewidth=0.5, **kwargs + coords[:, 0], + coords[:, 1], + coords[:, 2], + s=node_size, + c=node_colors, + cmap=plt.cm.viridis, + edgecolors="black", + linewidth=0.5, + **kwargs, ) norm = plt.Normalize(vmin=min(node_colors), vmax=max(node_colors)) sm = plt.cm.ScalarMappable(norm=norm, cmap=plt.cm.viridis) sm.set_array([]) cbar = plt.colorbar(sm, ax=ax, orientation="vertical", shrink=0.8) - cbar.set_label(f"Projection Value (θ={np.degrees(theta):.1f}°)") + cbar.set_label("Node Values") + + def _add_bounding_shape(self, ax, center_type="bounding_box", dim=None): + """Add bounding circle/sphere visualization + + Parameters: + ax (matplotlib.axes.Axes): + The axes to add the bounding shape to + center_type (str, optional): + Method to compute center ("mean", "bounding_box", or "origin") + dim (int, optional): + Dimension of the plot. If None, uses self.dim + """ + if dim is None: + dim = self.dim - def _add_bounding_shape(self, ax, center_type): - """Add bounding circle/sphere visualization""" center = self.get_center(center_type) radius = self.get_bounding_radius(center_type) - if self.dim == 2: + if dim == 2: circle = plt.Circle( - center[:2], radius, fill=False, linestyle="--", - color="darkred", linewidth=1.2, alpha=0.7 + center[:2], + radius, + fill=False, + linestyle="--", + color="darkred", + linewidth=1.2, + alpha=0.7, ) ax.add_patch(circle) padding = radius * 0.1 - ax.set_xlim(center[0] - radius - padding, - center[0] + radius + padding) - ax.set_ylim(center[1] - radius - padding, - center[1] + radius + padding) + ax.set_xlim(center[0] - radius - padding, center[0] + radius + padding) + ax.set_ylim(center[1] - radius - padding, center[1] + radius + padding) else: # sphere wireframe u = np.linspace(0, 2 * np.pi, 30) @@ -503,32 +586,73 @@ def _add_bounding_shape(self, ax, center_type): x, y, z, color="darkred", linewidth=0.5, alpha=0.3, rstride=2, cstride=2 ) padding = radius * 0.1 - ax.set_xlim3d(center[0] - radius - padding, - center[0] + radius + padding) - ax.set_ylim3d(center[1] - radius - padding, - center[1] + radius + padding) - ax.set_zlim3d(center[2] - radius - padding, - center[2] + radius + padding) + ax.set_xlim3d(center[0] - radius - padding, center[0] + radius + padding) + ax.set_ylim3d(center[1] - radius - padding, center[1] + radius + padding) + ax.set_zlim3d(center[2] - radius - padding, center[2] + radius + padding) def _configure_axes(self, ax): """Finalize plot appearance""" - if hasattr(ax, 'zaxis'): + if hasattr(ax, "zaxis"): + # 3D plot configuration ax.grid(True, linestyle=":", linewidth=0.5, alpha=0.7) ax.xaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) ax.yaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) ax.zaxis.set_pane_color((1.0, 1.0, 1.0, 0.0)) - ax.set_xlabel('X') - ax.set_ylabel('Y') - ax.set_zlabel('Z') + ax.set_xlabel("X") + ax.set_ylabel("Y") + ax.set_zlabel("Z") else: + # 2D plot configuration ax.set_aspect("equal") ax.grid(True, linestyle=":", linewidth=0.5, alpha=0.7) + xlim = ax.get_xlim() + ylim = ax.get_ylim() + + x_interval = self._get_nice_interval(xlim[1] - xlim[0]) + y_interval = self._get_nice_interval(ylim[1] - ylim[0]) + + ax.xaxis.set_major_locator(plt.MultipleLocator(x_interval)) + ax.yaxis.set_major_locator(plt.MultipleLocator(y_interval)) + + ax.xaxis.set_minor_locator(plt.MultipleLocator(x_interval / 2)) + ax.yaxis.set_minor_locator(plt.MultipleLocator(y_interval / 2)) + ax.tick_params( axis="both", which="both", bottom=True, left=True, labelbottom=True, - labelleft=True + labelleft=True, ) + + def _get_nice_interval(self, range_size): + """Calculate a nice interval for tick spacing + + Args: + range_size: Size of the axis range + + Returns: + float: Nice interval value for tick spacing + """ + # Calculate rough interval size (aim for ~5 major ticks) + rough_interval = range_size / 5 + + # Get magnitude + magnitude = 10 ** np.floor(np.log10(rough_interval)) + + # Normalize rough interval to between 1 and 10 + normalized = rough_interval / magnitude + + # Choose nice interval + if normalized < 1.5: + nice_interval = 1 + elif normalized < 3: + nice_interval = 2 + elif normalized < 7: + nice_interval = 5 + else: + nice_interval = 10 + + return nice_interval * magnitude diff --git a/src/ect/results.py b/src/ect/results.py index fd30196..2095591 100644 --- a/src/ect/results.py +++ b/src/ect/results.py @@ -9,7 +9,7 @@ class ECTResult(np.ndarray): Acts like a regular matrix but with added visualization methods """ def __new__(cls, matrix, directions, thresholds): - # allow float arrays for smooth transform + # allow float arrays for smooth transform otherwise int if np.issubdtype(matrix.dtype, np.floating): obj = np.asarray(matrix, dtype=np.float64).view(cls) else: diff --git a/src/ect/utils/examples.py b/src/ect/utils/examples.py index 59b1691..fec896c 100644 --- a/src/ect/utils/examples.py +++ b/src/ect/utils/examples.py @@ -75,3 +75,27 @@ def create_example_graph(centered=True, center_type='mean'): graph.center_coordinates(center_type) return graph + +def create_random_graph(n_nodes=100, n_edges=200, dim=2): + """Creates a random graph with random node positions in [0,1]^dim + + Args: + n_nodes: Number of nodes + n_edges: Number of random edges to add + dim: Dimension of embedding space + """ + G = EmbeddedGraph() + + coords = np.random.random((n_nodes, dim)) + nodes_with_coords = [(i, coords[i]) for i in range(n_nodes)] + G.add_nodes_from(nodes_with_coords) + + edges = set() + while len(edges) < n_edges: + u = np.random.randint(0, n_nodes) + v = np.random.randint(0, n_nodes) + if u != v: + edges.add(tuple(sorted([u, v]))) + + G.add_edges_from(edges) + return G From 8a74d1e3c6ae46b7b0e21c82c6a3ada74d16d939 Mon Sep 17 00:00:00 2001 From: yemeen Date: Sun, 30 Mar 2025 15:31:36 -0400 Subject: [PATCH 18/35] add new tutorials --- doc_source/notebooks/tutorial_cw.ipynb | 294 ++++++++++++++++++++++ doc_source/notebooks/tutorial_graph.ipynb | 281 +++++++++++++++++++++ 2 files changed, 575 insertions(+) create mode 100644 doc_source/notebooks/tutorial_cw.ipynb create mode 100644 doc_source/notebooks/tutorial_graph.ipynb diff --git a/doc_source/notebooks/tutorial_cw.ipynb b/doc_source/notebooks/tutorial_cw.ipynb new file mode 100644 index 0000000..4eada05 --- /dev/null +++ b/doc_source/notebooks/tutorial_cw.ipynb @@ -0,0 +1,294 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " # Tutorial: ECT for CW complexes\n", + "\n", + "\n", + "\n", + " This tutorial walks you through how to build a CW complex with the `EmbeddedCW` class, and then use the `ECT` class to compute the Euler characteristic transform" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from ect import ECT, EmbeddedCW\n", + "from ect.utils.examples import create_example_cw\n", + "import numpy as np\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " The CW complex is the same as the `EmbeddedGraph` class with that additional ability to add faces. Faces are added by passing in a list of vertices. Note that we are generally assuming that these vertices follow around an empty region (as in, no other vertex is in the interior) in the graph bounded by the vertices, and further that all edges are already included in the graph. However the class does not yet check for this so you need to be careful!" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "K = EmbeddedCW()\n", + "\n", + "# Add vertices with coordinates\n", + "K.add_node(\"A\", [0, 0])\n", + "K.add_node(\"B\", [1, 0])\n", + "K.add_node(\"C\", [1, 1])\n", + "K.add_node(\"D\", [0, 1])\n", + "\n", + "# Add edges to form a square\n", + "K.add_edges_from([(\"A\", \"B\"), (\"B\", \"C\"), (\"C\", \"D\"), (\"D\", \"A\")])\n", + "\n", + "# Add the square face\n", + "K.add_face([\"A\", \"B\", \"C\", \"D\"])\n", + "\n", + "K.center_coordinates()\n", + "K.plot()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " Just to have something a bit more interesting, let's make a more complicated example that's built into the class." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "K = create_example_cw()\n", + "K.plot(bounding_circle=True)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " As with the `EmbeddedGraph` class, we can initialize the `ECT` class by deciding how many directions and how many thresholds to use." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "ect = ECT(num_dirs=100, num_thresh=80)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " Then we can compute the ECC for a single direction. In this case, the $x$-axis will be computed for the `num_thresh=80` stopping points in the interval $[-1.2r,1.2r]$ where $r$ is the minimum bounding radius for the input complex." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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q90mNTVVPj4+jiUkJKQREwMAgMbk0pfSaqtHjx7q0aOH43F0dLSOHTum+fPn680336xym+TkZCUlJTke22w2yhEAANepJlWMqjJw4EBt37692vVWq1VWq7UREwEAALM0qUtpVcnOzlZQUJDZMQAAgBNw6TNGFy9e1NGjRx2Pjx8/ruzsbPn6+qpTp05KTk7WqVOn9MYbb0iSFixYoC5duujmm2/WlStX9Oqrr2rz5s365JNPzHoJAADAibh0McrMzNTw4cMdj3++F2j8+PFKS0tTbm6ucnJyHOtLSkr0+OOP69SpU/Ly8lLfvn316aefVtgHAABouly6GMXExMhut1e7Pi0trcLj6dOna/r06Q2cCgAAuKomf48RAADAzyhGAAAABooRAACAgWIEAABgoBgBAAAYKEYAAAAGihEAAICBYgQAAGCgGAEAABgoRgAAAAaKEQAAgIFiBAAAYKAYAQAAGChGAAAABooRAACAgWIEAABgoBgBAAAYKEYAAAAGihEAAICh+bVsXFpaqry8PF26dEl+fn7y9fWtr1wAAACNrtZnjAoLC7Vo0SINGzZM3t7e6ty5s2666Sb5+fkpNDRUkydP1p49exoiKwAAQIOqVTF68cUX1blzZy1btkyxsbFavXq1srOz9fXXXysjI0MpKSn68ccfNWLECN1+++365ptvGio3AABAvavVpbQ9e/Zo27Ztuvnmm6tcP3DgQD3wwANavHixli1bps8//1zdu3evl6AAAAANrVbF6O23367RPKvVqilTptQpEAAAgFmu6ebrf1VaWqotW7bIw8NDvXr1Urt27epr1wAAAI2i3orRv//7vysoKEjvv/++2rZtq0uXLqlPnz5av359fT0FAABAg6q3YpSTk6O1a9dq9+7dys7O1sKFC3XixIn62j0AAECDq7di5OHhIUlyd3dXSUmJEhMTFR0dXV+7BwAAaHD1VoweffRRnT9/XmPHjtWUKVM0ePBgnTt3rr52DwAA0OBq/QGPS5YsqXL8vvvuk6+vr5544gkNHTpUhw8f1sqVK685IAAAQGOp9Rmjxx9/XOHh4YqMjKx2zh133KEJEyZcSy4AAIBGV+szRs8884zGjh2rM2fOVLn+f//3fzVw4MBrDlYT27Zt05133qng4GBZLBatXr36V7fZsmWLbrnlFlmtVnXr1k1paWkNnhMAALiG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cePGaucDAICmxaVPUyQlJWn8+PEaMGCABg4cqAULFqioqEj333+/JCkhIUEdOnRQamqqJOmxxx7TsGHDNG/ePI0ePVrLly9XZmamli5daubLAAAATsKli9G4ceN09uxZzZo1S3l5eQoPD9f69esdN1jn5OTIze2fJ8Wio6OVnp6uJ598UjNnzlT37t21evVq9e7d26yXAAAAnIhLf46RGRric4wAAM6JzzG6ftT097fL3mMEAABQ3yhGAAAABooRAACAgWIEAABgoBgBAAAYKEYAAAAGihEAAICBYgQAAGCgGAEAABgoRgAAAAaKEQAAgIFiBAAAYKAYAQAAGChGAAAABooRAACAgWIEAABgoBgBAAAYKEYAAAAGihEAAICBYgQAAGCgGAEAABgoRgAAAAaKEQAAgIFiBAAAYKAYAQAAGChGAAAABooRAACAgWIEAABgoBgBAAAYKEYAAAAGihEAAIDBZYvR+fPndd9998nb21tt2rTRxIkTdfHixatuExMTI4vFUmGZMmVKIyUGAADOrrnZAerqvvvuU25urjZu3KjS0lLdf//9evDBB5Wenn7V7SZPnqzZs2c7Hnt5eTV0VAAA4CJcshh99dVXWr9+vfbs2aMBAwZIkl5++WXdcccdeuGFFxQcHFzttl5eXgoMDGysqAAAwIW45KW0jIwMtWnTxlGKJCk2NlZubm7atWvXVbd966231L59e/Xu3VvJycm6dOnSVecXFxfLZrNVWAAAwPXJJc8Y5eXlyd/fv8JY8+bN5evrq7y8vGq3u/feexUaGqrg4GB9+eWXeuKJJ3TkyBG9//771W6Tmpqqp59+ut6yAwAA5+VUxWjGjBn661//etU5X331VZ33/+CDDzr+u0+fPgoKCtKtt96qY8eO6YYbbqhym+TkZCUlJTke22w2hYSE1DkDAABwXk5VjB5//HFNmDDhqnO6du2qwMBAnTlzpsL4jz/+qPPnz9fq/qHIyEhJ0tGjR6stRlarVVartcb7BAAArsupipGfn5/8/Px+dV5UVJQuXLigrKws9e/fX5K0efNmlZeXO8pOTWRnZ0uSgoKC6pQXAABcX1zy5uubbrpJt99+uyZPnqzdu3friy++0NSpU3X33Xc73pF26tQp9ezZU7t375YkHTt2TP/1X/+lrKwsffvtt/rggw+UkJCgoUOHqm/fvma+HAAA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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "result = ect.calculate(K, theta=0)\n", + "result.plot()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " But of course it's easier to see this in a plot. This command calculates the ECC and immediately plots it." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "# Set bounding radius to control threshold range\n", + "radius = K.get_bounding_radius()\n", + "ect.set_bounding_radius(1.2 * radius)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " Similarly, we can compute the ECT and return the matrix. We make sure to internally set the bounding radius to use to control the $y$ axis of the plot." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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5eVp9+7drvm9e/dqy5Ytqlmzpho3bqyUlBQdOHBA7777riTpnnvu0YwZMzRhwgTdddddWrFihd5//3198skngQVeBr8SlYEDB+rmm2/WPffco2PHjqlz586KiorSL7/8ohdeeEH33ntvsON0ufXWW3X48GFNmjRJWVlZSkxM1NKlS4sV+AAAYBsmjEy7YcMGXX311a75c/WcQ4cO1Zw5c3Tw4EFlZGS4Xm/WrJk++eQTPfDAA3rxxRfVqFEjvfnmmyG9NVnyM1HZtGmTpk2bJkn64IMPFBcXp82bN+vDDz/UpEmTQpqoSNLo0aPp6gEAIAC9evWSYXjObkoadbZXr17FalZDza9E5cSJE6pWrZokadmyZbr55psVERGhrl27av/+/UENEACAsMezfjzya8C3li1bavHixcrMzNTnn3/uqks5dOhQSCqNAQAIZ+dqVAKdwpFfLSqTJk3S7bffrgceeEC9e/d2VfwuW7ZMl19+eVADtDpfB3jzFcW15UN5LohlgLfgKM9/G3wdAM4KxbPwnl+Jyp///Gf16NFDBw8eVPv27V3Le/furZtuuilowQEAUC4EcWTacONXoiKdHfil6CBrnTt3DjggAADKHWpUPPKrRkWSvvrqK/31r39VUlKSDhw4IEn617/+pTVr1gQtOAAAUL751aLy4Ycf6s4779Qdd9yhzZs3u57MmJOTo6eeeqrYyHXhJNQ1KWWhZiU8UJOCQPHb96zoe7P7L6+bFIn3zBjwzS78alF54oknNHPmTL3xxhtuDyTs3r27Nm3aFLTgAAAoF4wgTWHIrxaVXbt26aqrriq2vHr16jp27FigMQEAUL4E4/biME1U/GpRiYuLc3s+wDlr1qxR8+bNAw4KAABA8jNRGTlypO6//36tX79eDodDP//8s9577z2NGzcu5MPnAwAQduj68civrp+JEyfK6XSqd+/eOnHihK666ipFR0dr3Lhx+sc//hHsGFGKYBYmlqfivGAXdJan985XFM/6h++U/2z5neP2ZI98TlROnz6tfv36aebMmRo/frx2796tvLw8tWnTRlWrVg1FjAAAoJzyOVGJjIzUDz/8IEmKiopSmzZk/QAABILbkz3zq0blr3/9q956661gxwIAAODGrxqVM2fOaPbs2friiy/UsWNHValSxe31F154ISjBAQCA8s2vRGXbtm3q0KGD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+uuv17x587RhwwavejcCYZtEZfny5dq9e7d2796tRo0aub1mBJqFXkAU1/rP14K7cFaezhXlj6/fb34P/rn11lt1+PBhTZo0SVlZWUpMTNTSpUtdBbMZGRluPRfdunXT3Llz9cgjj+jhhx/WxRdfrMWLF6tt27YhjdM2icqwYcPKrGUBAMCWTHrWz+jRoz129axcubLYssGDB2vw4MG+HygAtklUAAAIV2Z0/dgF1agAAMCyaFFR8f7NCzngFTUrnlGTYg4zfw8on/gtS3IaZ6dA9xGGSFQAADCbSTUqdkDXDwAAsCxaVAAAMJlDQSimDUok1kOiAgCA2S7wyLR2QqJSAoprrYmCO3NQXItg47dcHLcne0aNCgAAsCxaVAAAMBt3/XhEogIAgMkchiFHgDUmgW5vVSQqXji/P/VC98+XNehZONWw7Hmmtdt8g3D93wObo2YF3qAOBcFCogIAgNmcv0+B7iMMkagAAGAyun48464fAABgWbSoAABgNu768YhExebsPEBc0eJZoDyze5EyxbMBYmRaj+j6AQAAlkWLCgAAJmMIfc9IVAAAMBtdPx6RqAAAYDKH8+wU6D7CEYmKj6xe8FbWSLZFBbP4tqxjU2wXnqz+m7Cy0n4Tvv5egv2+83uFVZCoAABgNrp+PCJRAQDAbIyj4hG3JwMAAMuiRSVAdu+f97WmxRf0cZdPdv9NhFIofxP83uyNZ/14RqICAIDZqFHxiK4fAABgWbSoAABgNkNSoOOghGeDCokKAABmo0bFMxKVICvPhYQU86Ek/CYABIJEBQAAsxkKQjFtUCKxHBIVAADMxl0/HpGoAABgNqekQHtFw/ShhNyeDAAALMtWicqNN96oxo0bKyYmRvXr19edd96pn3/+2eywStVgleE2hZNwPjeETjh/b8L53BBa5+76CXQKlSNHjuiOO+5QbGysatSooREjRigvL6/UbXr16iWHw+E23XPPPT4f21aJytVXX633339fu3bt0ocffqg9e/boz3/+s9lhAQAQmHM1KoFOIXLHHXdo+/btWr58uT7++GOtXr1af/vb38rcbuTIkTp48KBrevbZZ30+tq1qVB544AHXv5s0aaKJEydq0KBBOn36tCIjI02MDACA8LRjxw4tXbpU3333nTp16iRJevnll3Xddddp6tSpatCggcdtK1eurLi4uICOb6sW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W7c2OySf2fkcatSooe+//1579+7V3LlzlZ2dLelsf32rVq104sQJFRYW2qLpODs7WxUrVlT37t0VFxenX375RU6nU1deeaXlhw5fs2aNNm7cqI8++khPPfWUfvjhB0n2+hzC5buUm5urtWvX6rrrrnMts9s5OJ1OjRkzRt27d1fbtm0l2fv3EU7KVaISLn788Ue1adNG0tlHcPs6HLEVhMM51KtXT+3bt9dXX31l28Rr69atuuSSS1x/dL///nvVrVu3WDJvRQ0bNpQk1a9fX9ddd502bdpk28/B7t+lJUuW6Nprr1VMTIwke/6PyKhRo7Rt2zbNmzfPtczOv49wUq4Sldq1a6tChQqu/2s5Jzs72/VIajvYvn27LrvsMklSlSpVtGzZMv36668mR+Ubu55Ddna2jh8/LknKycnR6tWr1apVK9smXj/88IPatWvnmv/+++/d5q0qPz/f9Tnk5eVpxYoVuuyyy2z1OYTTd+n999/Xrbfe6pq32zmMHj1aH3/8sb788ks1atTItdyuv49wU64SlaioKHXs2FHp6emuZU6nU+np6UpKSjIxMt/cddddmjJliiTplltu0datW213p4Bdz2H//v268sor1b59e1155ZX6xz/+oXbt2tk28frhhx+UkJDgmv/+++/d5q0qOztbPXr0UPv27dW1a1clJyfriiuusNXnEC7fpZycHH377bfq27eva5ldzsEwDI0ePVqLFi3SihUr1KxZM7fX7fr7CDfl8vbkoUOH6vXXX1fnzp01ffp0vf/++9q5cyfNeShXnE6nqlWrpvnz5+uGG26QJMXHx2v8+PG67777TI4OCL3/9//+n+bOnaslS5aoVatWruXVq1dXdHQ0vw+LKHeJiiTNmDFDzz33nLKyspSYmKiXXnpJXbp0MTssrzkcjlJft8NHGg7nYHc//fSTLrnkEu3fv1+NGzeWJF133XVat26dPv74Y3Xv3t3kCIHQ8vR36O2331b37t35fVhEuUxUwsWJEyfUunVrDR48WFOnTjU7HJ9kZmbqzjvv1KFDh1SxYkU9+uijGjx4sNlhlamsBMsTfmbBFQ6fA+cAeKdc1aiEmyeffFJdu3Y1Owy/VKxYUdOnT9ePP/6oZcuWacyYMcrPzzc7rDIZZ8cecpvy8/PVuHFjPfjggyW+zh/l4AuHz+H8uDIyMtSzZ0+1bt1a7dq10/vvv2+7c7Dr5wDrI1GxqZ9++kk7d+5U//79zQ7FL/Xr11diYqIkKS4uTrVr19aRI0fMDcpPdk4Yw4mdPwe7Ju4lsfPnAGsiUbGpcePGKS0tzewwgmLjxo0qLCxUfHy82aH4zO4JY7iw++cQLom73T8HWBOJig0tWbJEl1xyieXHJvDGkSNHlJycrFmzZpkdil/CKWG0s3D6HOycuIfT5wDrIFGxoW+++Ubz5s1T06ZNNW7cOL3xxht67LHHzA7LZwUFBRo0aJAmTpyobt26mR2Oz8IpYbSzcPoc7Jy4h9PnAGupaHYA8F1aWprr/1rmzJmjbdu2adKkSSZH5RvDMDRs2DBdc801uvPOO80Oxy/nEsYFCxYoLy9Pp0+fVmxsrO0+C7sLl8/B7ol7uHwOsB5uT7a5c4mK3W5PXrNmja666iq3UR7/9a9/2XZ4art+DuHGrp+DYRi6/fbb1apVK02ePNnscAJm188B1kSLis0NGzbM7BD80qNHDzmdTrPDACzh66+/1vz585WQkKDFixdLsnfiDgQTLSoAAMCyKKYFAACWRaICAAAsi0QFAABYFokKAACwLBIVAABgWSQqAADAskhUAACAZZGoAAAAyyJRAQAAlkWiAgAALItEBQAAWBaJClCCXr16acyYMeXu2L769ddfVbduXe3bty8k+z//vTDjfbntttv0/PPPX9BjAnDHQwlRrgwbNkzvvPOOJKlixYqqWbOmEhISNGTIEA0bNkwREWdz9yNHjigyMlLVqlULaTy9evVSYmKipk+f7lp2oY4dDGPHjtXx48f1xhtvhGT/578/Zrwv27Zt01VXXaW9e/eqevXqF+y4AP5AiwrKnX79+ungwYPat2+fPvvsM1199dW6//77dcMNN+jMmTOSpJo1a5Z6QTx16lTI4ivr2FZx4sQJvfXWWxoxYoTHdYL5PpnxvrRt21YtWrTQv//97wt6XAB/IFFBuRMdHa24uDg1bNhQHTp00MMPP6wlS5bos88+05w5cyQV72bo1auXRo8erTFjxqh27drq27evJMnpdCotLU3NmjVTpUqV1L59e33wwQeu7ZxOp5599lm1bNlS0dHRaty4sZ588klJZ1t3Vq1apRdffFEOh0MOh0P79u0rduyCggLdd999qlu3rmJiYtSjRw999913bufUq1cv3XfffZowYYJq1qypuLg4TZ48ucz34t5771WPHj1KfK1Ro0Z6+umnPW776aefKjo6Wl27di3zfVq6dKl69OihGjVqqFatWrrhhhu0Z88et/3l5+crOTlZVatWVf369Yt1uRR9X7zZpzfvywcffKB27dqpUqVKqlWrlvr06aP8/HzX6wMGDNC8efM8vg8AQotEBZB0zTXXqH379lq4cKHHdd555x1FRUXp66+/1syZMyVJaWlpevfddzVz5kxt375dDzzwgP76179q1apVkqSUlBQ9/fTTevTRR/Xjjz9q7ty5qlevniTpxRdfVFJSkkaOHKmDBw/q4MGDio+PL3bcCRMm6MMPP9Q777yjTZs2qWXLlurbt6+OHDlSLL4qVapo/fr1evbZZ/XYY49p+fLlHs9n+/btmjVrlp599tkSX2/durW2bNnicfuvvvpKHTt29Op9ys/P19ixY7Vhwwalp6crIiJCN910k5xOp2u78ePHa9WqVVqyZImWLVumlStXatOmTR6P780+y3pfDh48qCFDhuiuu+7Sjh07tHLlSt188806v0e8c+fO+vbbb1VQUOAxFgAhZADlyNChQ42BAweW+Nqtt95qtG7d2jAMw+jZs6dx//33u17r2bOncfnll7utf/LkSaNy5crG2rVr3ZaPGDHCGDJkiJGbm2tER0cbb7zxhsd4ih6n6LK8vDwjMjLSeO+991yvnzp1ymjQoIHx7LPPum3To0cPt/1cccUVxkMPPeTx2EOHDjW6dOni8fW//OUvRs+ePT2+PnDgQOOuu+4qFnvR96kkhw8fNiQZW7duNQzDMI4fP25ERUUZ77//vmudX3/91ahUqZLrvSjpvSptn+e2Ke192bhxoyHJ2Ldvn8f9fv/992WuAyB0aFEBfmcYhhwOh8fXi7Ye7N69WydOnNCf/vQnVa1a1TW9++672rNnj3bs2KGCggL17t3b75j27Nmj06dPq3v37q5lkZGR6ty5s3bs2OG2bkJCgtt8/fr1dejQoRL3e+bMGS1cuFC33HKLa9nf//53vfXWW67548ePq1KlSh5j++233xQTE1NseUmtLD/99JOGDBmi5s2bKzY2Vk2bNpUkZWRkuM7z1KlT6tKli2ubmjVrqlWrVh6PX9Y+zyntfWnfvr169+6tdu3aafDgwXrjjTd09OhRt/XPvQcnTpzwGAuA0KlodgCAVezYsUPNmjXz+HqVKlXc5vPy8iRJn3zyiRo2bOj2WnR0tI4dOxb0GEsTGRnpNu9wOIp1g5yzZ88eHT9+XO3atZN0tpZmwYIFbknVDz/8oFtvvdXj8WrXrl3soi4Vf5+ks3UeTZo00RtvvKEGDRrI6XSqbdu2ARXbervP0t6XChUqaPny5Vq7dq2WLVuml19+Wf/85z+1fv1613fhXBdbnTp1/I4VgP9oUQEkrVixQlu3bnVrYShLmzZtFB0drYyMDLVs2dJtio+P18UXX6xKlSopPT3d4z6ioqJUWFjo8fUWLVq46j3OOX36tL777ju1adPG61iLOpdEVa1aVZL0+eef6+jRo64Wkm+++UYHDhzQTTfd5HEfl19+uX788ccyj/Xrr79q165deuSRR9S7d2+1bt26WILTokULRUZGav369a5lR48e1X/+8x+/9+kth8Oh7t27a8qUKdq8ebOioqK0aNEi1+vbtm1To0aNVLt2bb/2DyAwtKig3CkoKFBWVpYKCwuVnZ2tpUuXKi0tTTfccIOSk5O93k+1atU0btw4PfDAA3I6nerRo4dycnL09ddfKzY2VkOHDtVDDz2kCRMmKCoqSt27d9fhw4e1fft21y29TZs21fr167Vv3z5VrVpVNWvWdDtGlSpVdO+992r8+PGqWbOmGjdurGeffVYnTpwo9bbgsjRp0kQOh0P/8z//oypVqmjcuHG6/vrrtWTJEsXHx+uee+5Rnz59PN4RJEl9+/ZVSkqKjh49qosuusjjehdddJFq1aqlWbNmqX79+srIyNDEiRPd1qlatapGjBih8ePHq1atWqpbt67++c9/usa18Wef3li/fr3S09N17bXXqm7dulq/fr0OHz6s1q1bu9b56quvdO211/q8bwDBQaKCcmfp0qWqX7++KlasqIsuukjt27fXSy+9pKFDh3q8MHry+OOPq06dOkpLS9P//d//qUaNGq5bniXp0UcfVcWKFTVp0iT9/PPPql+/vu655x7X9uPGjdPQoUPVpk0b/fbbb9q7d2+xYzz99NNyOp268847dfz4cXXq1Emff/55qclBWeLi4vTkk0/q6aef1ocffqinnnpKHTt21MCBAzV//nwNGDBAr776aqn7aNeunTp06KD3339ff//73z2uFxERoXnz5um+++5T27Zt1apVK7300kvq1auX23rPPfec8vLyNGDAAFWrVk0PPvigcnJyAtpnWWJjY7V69WpNnz5dubm5atKkiZ5//nn1799fknTy5EktXrxYS5cu9Wm/AIKHkWkB+O2TTz7R+PHjtW3bNp+TPDt47bXXtGjRIi1btszsUIByixYVAH67/vrr9dNPP+nAgQMljgFjd5GRkXr55ZfNDgMo12hRAQAAlhV+bbUAACBskKgAAADLIlEBAACWRaICAAAsi0QFAABYFokKAACwLBIVAABgWSQqAADAskhUAACAZf1/M9YIbwkR2X4AAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "result = ect.calculate(K)\n", + "result.plot()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " We can also look at the Smooth ECT:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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P/zwTuvGjBmDa665pstt//KXvyCRSOCQQw6xnqfHH38chx9+OIYMGYJhw4bhK1/5Ct59911Pf62trTjjjDMwaNAgjB492lg24fPSnT67c15+//vfY+LEiRgwYACGDRuGGTNmoLW1taP+xBNPxL333tvleRBCFA4NSoQAcPTRR2Py5Ml46KGHumxzzz33IB6P4+9//ztuu+02AEB9fT1+85vf4LbbbsMbb7yB888/H9/+9rexbNkyAMCCBQtwzTXX4Cc/+QnefPNNLFmyBKNGjQIA3HjjjZg2bRrOOussrF+/HuvXr8fYsWON/V588cV48MEHcc8992DVqlXYe++9MXPmTGzcuNGIb+DAgVixYgWuvfZaXHnllXjqqae6PJ433ngDt99+O6699tpO68ePH4/Vq1d3uf3f/vY3TJ06tVvnqbW1FfPnz8fLL7+MpUuXIhwO42tf+xocx+nY7qKLLsKyZcvw6KOP4sknn8Szzz6LVatWdbn/7vRpOy/r16/Haaedhu985zt466238Oyzz+KUU05B7qr2QQcdhBdffBHJZLLLWIQQBcIVooKYPXu2e9JJJ3Vad+qpp7rjx493Xdd1jzzySPeHP/xhR92RRx7pfv7zn/e0b2trc6urq91//OMfnu/nzp3rnnbaaW5LS4ubSCTcO+64o8t4eD/83ZYtW9xYLOb+7ne/66hPpVLurrvu6l577bWebQ4//HBPP1/4whfcSy65pMt9z5492z344IO7rP/P//xP98gjj+yy/qSTTnK/853vGLHzeeqMjz/+2AXgvv76667ruu7mzZvdeDzu3n///R1t/v3vf7sDBgzoOBednSu/Pnds43deVq5c6QJw33///S77ffXVV61thBCFQTMlQmzHdV2EQqEu63lW4J133sHWrVvxpS99CYMGDer4/OY3v8G7776Lt956C8lkEsccc0yPY3r33XeRTqdx2GGHdXwXi8Vw0EEH4a233vK0nTRpkqc8evRobNiwodN+M5kMHnroIcyaNavju//6r//CnXfe2VHevHkzBgwY0GVs27ZtQ1VVlfF9Z7Mn//rXv3Daaadhzz33RE1NDXbffXcAwLp16zqOM5VK4eCDD+7YZujQodh333273L+tzx34nZfJkyfjmGOOwcSJE/H1r38dd9xxBz799FNP+x3nYOvWrV3GIoQoDNFiByBEqfDWW29hjz326LJ+4MCBnvKWLVsAAH/+85/xmc98xlOXSCSwadOmgsfoRywW85RDoZCxlLGDd999F5s3b8bEiRMBtGtfHnjgAc8A6rXXXsOpp57a5f6GDx9u/AMOmOcJaNdl7Lbbbrjjjjuw6667wnEc7L///nkJYbvbp995iUQieOqpp/CPf/wDTz75JG6++Wb86Ec/wooVKzruhR3LZCNGjOhxrEKI7qGZEiEA/PWvf8Xrr7/umTmwMWHCBCQSCaxbtw5777235zN27Fh89rOfxYABA7B06dIu+4jH48hms13W77XXXh36jB2k02m89NJLmDBhQrdjZXYMmAYNGgQAeOKJJ/Dpp592zHwsX74cH374Ib72ta912cfnP/95vPnmm9Z9/fvf/8batWvx4x//GMcccwzGjx9vDGb22msvxGIxrFixouO7Tz/9FP/85z973Gd3CYVCOOyww7Bw4UK88soriMfjePjhhzvq16xZgzFjxmD48OE96l8I0X00UyIqjmQyicbGRmSzWTQ1NeHxxx9HfX09vvKVr+CMM87odj+DBw/GhRdeiPPPPx+O4+Dwww9Hc3Mz/v73v6OmpgazZ8/GJZdcgosvvhjxeByHHXYYPv74Y7zxxhsdr9HuvvvuWLFiBd5//30MGjQIQ4cO9exj4MCBOPvss3HRRRdh6NChGDduHK699lps3brV91VcG7vtthtCoRD+7//9vxg4cCAuvPBCnHDCCXj00UcxduxYfP/738eMGTO6fDMHAGbOnIkFCxbg008/xS677NJlu1122QXDhg3D7bffjtGjR2PdunW49NJLPW0GDRqEuXPn4qKLLsKwYcMwcuRI/OhHP+rwjelJn91hxYoVWLp0KY499liMHDkSK1aswMcff4zx48d3tPnb3/6GY489NnDfQojgaFAiKo7HH38co0ePRjQaxS677ILJkyfjpptuwuzZs7v8R7Ar/vu//xsjRoxAfX09/t//+38YMmRIx2vGAPCTn/wE0WgUl112GT766COMHj0a3//+9zu2v/DCCzF79mxMmDAB27Ztw3vvvWfs45prroHjODj99NOxefNmHHjggXjiiSd8BwI26urqcPXVV+Oaa67Bgw8+iJ/+9KeYOnUqTjrpJNx333048cQT8atf/cq3j4kTJ+KAAw7A/fffj//6r//qsl04HMa9996LH/zgB9h///2x77774qabbsL06dM97X7+859jy5YtOPHEEzF48GBccMEFaG5uzqtPGzU1NXjuuedwww03oKWlBbvttht+8Ytf4PjjjwcAtLW14ZFHHsHjjz8eqF8hRM+Qo6sQosf8+c9/xkUXXYQ1a9YEHtCVA7feeisefvhhPPnkk8UORYiKQDMlQogec8IJJ+Bf//oXPvzww049VsqdWCyGm2++udhhCFExaKZECCGEECVB/5tvFUIIIURZokGJEEIIIUoCDUqEEEIIURJoUCKEEEKIkkCDEiGEEEKUBBqUCCGEEKIk0KBECCGEECWBBiVCCCGEKAk0KBFCCCFESfD/A8HZ1gkMYsk0AAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Calculate SECT and plot\n", + "smooth = result.smooth()\n", + "smooth.plot()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "dataexp", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/doc_source/notebooks/tutorial_graph.ipynb b/doc_source/notebooks/tutorial_graph.ipynb new file mode 100644 index 0000000..330e0c2 --- /dev/null +++ b/doc_source/notebooks/tutorial_graph.ipynb @@ -0,0 +1,281 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " # Tutorial : ECT for embedded graphs\n", + "\n", + "\n", + "\n", + " This jupyter notebook will walk you through using the `ect` package to compute the Euler characteristic transform of a 2D embedded graph. This tutorial assumes you already know what an ECT is; see [this paper](https://arxiv.org/abs/2310.10395) for a more thorough treatment of details." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ect import ECT, EmbeddedGraph\n", + "from ect.utils.examples import create_example_graph\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import networkx as nx\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " ## Constructing the embedded graph\n", + "\n", + "\n", + "\n", + " We assume our input is an undirected graph $G$ with an embedding in 2D given by a map on the vertices $f: V(G) \\to \\mathbb{R}^2$. A graph can be constructed as follows.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Construct an example graph\n", + "# Note that this is the same graph that is returned by:\n", + "# G = create_example_graph()\n", + "\n", + "G = EmbeddedGraph()\n", + "\n", + "G.add_node(\"A\", [1, 2])\n", + "G.add_node(\"B\", [3, 4])\n", + "G.add_node(\"C\", [5, 7])\n", + "G.add_node(\"D\", [3, 6])\n", + "G.add_node(\"E\", [4, 3])\n", + "G.add_node(\"F\", [4, 5])\n", + "\n", + "G.add_edge(\"A\", \"B\")\n", + "G.add_edge(\"B\", \"C\")\n", + "G.add_edge(\"B\", \"D\")\n", + "G.add_edge(\"B\", \"E\")\n", + "G.add_edge(\"C\", \"D\")\n", + "G.add_edge(\"E\", \"F\")\n", + "\n", + "G.plot()\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " The coordinates of all vertices, given as a dictionary, can be accessed using the `coord_matrix` attribute." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "G.coord_matrix\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " Because of the rotational aspect of the ECT, we often want our graph to be centered, so you can use the `center_coordinates` method shift the graph to have the average of the vertex coordinates be 0. Note that this does overwrite the coordinates of the points." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "G.center_coordinates(center_type=\"mean\")\n", + "print(G.coord_matrix)\n", + "G.plot()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " To get a bounding radius we can use the `get_bounding_radius` method." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# This is actually getting the radius\n", + "r = G.get_bounding_radius()\n", + "print(f\"The radius of bounding circle centered at the origin is {r}\")\n", + "\n", + "# plotting the graph with it's bounding circle of radius r.\n", + "G.plot(bounding_circle=True)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " We can also rescale our graph to have unit radius using `scale_coordinates`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "G.scale_coordinates(radius=1)\n", + "G.plot(bounding_circle=True)\n", + "\n", + "r = G.get_bounding_radius()\n", + "print(f\"The radius of bounding circle centered at the origin is {r}\")\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "myect = ECT(num_dirs=16, num_thresh=20)\n", + "\n", + "# The ECT object will automatically create directions when needed\n", + "print(f\"Number of directions: {myect.num_dirs}\")\n", + "print(f\"Number of thresholds: {myect.num_thresh}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " We can set the bounding radius as follows. Note that some methods will automatically use the bounding radius of the input `G` if not already set. I'm choosing the radius to be a bit bigger than the bounding radius of `G` to make some better pictures." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "myect.set_bounding_radius(1.2 * G.get_bounding_radius())\n", + "\n", + "print(f\"Internally set radius is: {myect.bound_radius}\")\n", + "print(f\"Thresholds chosen are: {myect.thresholds}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " If we want the Euler characteristic curve for a fixed direction, we use the `calculate` function with a specific angle. This returns an ECTResult object containing the computed values." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "result = myect.calculate(G, theta=np.pi / 2)\n", + "print(f\"ECT values for direction pi/2: {result[0]}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " To calculate the full ECT, we call the `calculate` method without specifying theta. The result returns the ECT matrix and associated metadata." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "result = myect.calculate(G)\n", + "\n", + "print(f\"ECT matrix shape: {result.shape}\")\n", + "print(f\"Number of directions: {myect.num_dirs}\")\n", + "print(f\"Number of thresholds: {myect.num_thresh}\")\n", + "\n", + "# We can plot the result matrix\n", + "result.plot()\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " ## SECT\n", + "\n", + "\n", + "\n", + " The Smooth Euler Characteristic Transform (SECT) can be calculated from the ECT. Fix a radius $R$ bounding the graph. The average ECT in a direction $\\omega$ defined on function values $[-R,R]$ is given by\n", + "\n", + " $$\\overline{\\text{ECT}_\\omega} = \\frac{1}{2R} \\int_{t = -R}^{R} \\chi(g_\\omega^{-1}(-\\infty,t]) \\; dt. $$\n", + "\n", + " Then the SECT is defined by\n", + "\n", + " $$\n", + "\n", + " \\begin{matrix}\n", + "\n", + " \\text{SECT}(G): & \\mathbb{S}^1 & \\to & \\text{Func}(\\mathbb{R}, \\mathbb{Z})\\\\\n", + "\n", + " & \\omega & \\mapsto & \\{ t \\mapsto \\int_{-R}^t \\left( \\chi(g_\\omega^{-1}(-\\infty,a]) -\\overline{\\text{ECT}_\\omega}\\right)\\:da \\}\n", + "\n", + " \\end{matrix}\n", + "\n", + " $$" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + " The SECT can be computed from the ECT result:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sect = result.smooth()\n", + "\n", + "sect.plot()\n" + ] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": 3 + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From b0209f2be9af2c8cc555632c36d1ec9de0e11a40 Mon Sep 17 00:00:00 2001 From: yemeen Date: Sun, 30 Mar 2025 15:34:46 -0400 Subject: [PATCH 19/35] run make clean --- src/ect/__init__.py | 4 ++-- src/ect/directions.py | 1 + src/ect/ect_graph.py | 15 ++++++++----- src/ect/embed_graph.py | 45 ++++++++++++++++++++++++++------------- src/ect/results.py | 11 +++++----- src/ect/utils/examples.py | 9 ++++---- 6 files changed, 53 insertions(+), 32 deletions(-) diff --git a/src/ect/__init__.py b/src/ect/__init__.py index 750fd5e..c45984d 100644 --- a/src/ect/__init__.py +++ b/src/ect/__init__.py @@ -16,8 +16,8 @@ __all__ = [ 'ECT', - 'EmbeddedGraph', + 'EmbeddedGraph', 'EmbeddedCW', 'Directions', 'examples', -] +] diff --git a/src/ect/directions.py b/src/ect/directions.py index 620ea81..1fae987 100644 --- a/src/ect/directions.py +++ b/src/ect/directions.py @@ -3,6 +3,7 @@ import numpy as np + class Sampling(Enum): UNIFORM = "uniform" RANDOM = "random" diff --git a/src/ect/ect_graph.py b/src/ect/ect_graph.py index 4874f0c..f469fc2 100644 --- a/src/ect/ect_graph.py +++ b/src/ect/ect_graph.py @@ -117,12 +117,15 @@ def calculate( radius, thresholds = self.get_thresholds(graph, bound_radius) directions = ( - self.directions if theta is None else Directions.from_angles([theta]) + self.directions if theta is None else Directions.from_angles([ + theta]) ) - simplex_projections = self._compute_simplex_projections(graph, directions) + simplex_projections = self._compute_simplex_projections( + graph, directions) - ect = self._compute_directional_transform(simplex_projections, thresholds) + ect = self._compute_directional_transform( + simplex_projections, thresholds) return ECTResult(ect, directions, thresholds) @@ -148,9 +151,11 @@ def _compute_simplex_projections( if isinstance(graph, EmbeddedCW) and len(graph.faces) > 0: node_to_index = {n: i for i, n in enumerate(graph.node_list)} - face_indices = [[node_to_index[v] for v in face] for face in graph.faces] + face_indices = [[node_to_index[v] for v in face] + for face in graph.faces] face_maxes = np.array( - [np.max(node_projections[face, :], axis=0) for face in face_indices] + [np.max(node_projections[face, :], axis=0) + for face in face_indices] ) simplex_projections.append(face_maxes) diff --git a/src/ect/embed_graph.py b/src/ect/embed_graph.py index ec463db..6f38f19 100644 --- a/src/ect/embed_graph.py +++ b/src/ect/embed_graph.py @@ -70,7 +70,8 @@ def position_dict(self): def edge_indices(self): """Return edges as array of index pairs""" edges = np.array( - [(self._node_to_index[u], self._node_to_index[v]) for u, v in self.edges()], + [(self._node_to_index[u], self._node_to_index[v]) + for u, v in self.edges()], dtype=int, ) return edges if len(edges) > 0 else np.empty((0, 2), dtype=int) @@ -110,7 +111,8 @@ def decorator(func): def wrapper(self, *args, **kwargs): # Handle both positional and keyword arguments if args: - nodes = args[0] if isinstance(args[0], (list, tuple)) else [args[0]] + nodes = args[0] if isinstance( + args[0], (list, tuple)) else [args[0]] else: node_id = kwargs.get("node_id") or kwargs.get("node_id1") nodes = [node_id] if node_id else [] @@ -142,7 +144,8 @@ def add_node(self, node_id, coord): self._coord_matrix = coord.reshape(1, -1) else: coord_reshaped = coord.reshape(1, -1) - self._coord_matrix = np.vstack([self._coord_matrix, coord_reshaped]) + self._coord_matrix = np.vstack( + [self._coord_matrix, coord_reshaped]) self._node_list.append(node_id) self._node_to_index[node_id] = len(self._node_list) - 1 @@ -181,9 +184,11 @@ def set_coord(self, node_id, new_coords): def add_cycle(self, coord_matrix): """Add nodes in a cyclic pattern from coordinate matrix""" n = coord_matrix.shape[0] - new_names = next_vert_name(self._node_list[-1] if self._node_list else 0, n) + new_names = next_vert_name( + self._node_list[-1] if self._node_list else 0, n) self.add_nodes_from(zip(new_names, coord_matrix)) - self.add_edges_from([(new_names[i], new_names[(i + 1) % n]) for i in range(n)]) + self.add_edges_from( + [(new_names[i], new_names[(i + 1) % n]) for i in range(n)]) # ====================================== # Geometric Calculations @@ -369,10 +374,12 @@ def validate_plot_parameters(func): """Decorator to validate plot method parameters""" def wrapper(self, *args, **kwargs): - bounding_center_type = kwargs.get("bounding_center_type", "bounding_box") + bounding_center_type = kwargs.get( + "bounding_center_type", "bounding_box") if self.dim not in [2, 3]: - raise ValueError("At least 2D or 3D coordinates required for plotting") + raise ValueError( + "At least 2D or 3D coordinates required for plotting") if bounding_center_type not in CENTER_TYPES: raise ValueError( @@ -403,10 +410,12 @@ def plot( """ ax = self._create_axes(ax, self.dim) - pos = {node: self._coord_matrix[i] for i, node in enumerate(self._node_list)} + pos = {node: self._coord_matrix[i] + for i, node in enumerate(self._node_list)} if self.dim == 2: - self._draw_2d(ax, pos, with_labels, node_size, edge_color, **kwargs) + self._draw_2d(ax, pos, with_labels, + node_size, edge_color, **kwargs) else: self._draw_3d(ax, pos, node_size, edge_color, elev, azim, **kwargs) @@ -417,7 +426,8 @@ def plot( ) node_colors = np.dot(self._coord_matrix, direction) - self._add_node_coloring(ax, pos, node_colors, node_size, self.dim, **kwargs) + self._add_node_coloring( + ax, pos, node_colors, node_size, self.dim, **kwargs) if bounding_circle: self._add_bounding_shape(ax, bounding_center_type, self.dim) @@ -572,8 +582,10 @@ def _add_bounding_shape(self, ax, center_type="bounding_box", dim=None): ) ax.add_patch(circle) padding = radius * 0.1 - ax.set_xlim(center[0] - radius - padding, center[0] + radius + padding) - ax.set_ylim(center[1] - radius - padding, center[1] + radius + padding) + ax.set_xlim(center[0] - radius - padding, + center[0] + radius + padding) + ax.set_ylim(center[1] - radius - padding, + center[1] + radius + padding) else: # sphere wireframe u = np.linspace(0, 2 * np.pi, 30) @@ -586,9 +598,12 @@ def _add_bounding_shape(self, ax, center_type="bounding_box", dim=None): x, y, z, color="darkred", linewidth=0.5, alpha=0.3, rstride=2, cstride=2 ) padding = radius * 0.1 - ax.set_xlim3d(center[0] - radius - padding, center[0] + radius + padding) - ax.set_ylim3d(center[1] - radius - padding, center[1] + radius + padding) - ax.set_zlim3d(center[2] - radius - padding, center[2] + radius + padding) + ax.set_xlim3d(center[0] - radius - padding, + center[0] + radius + padding) + ax.set_ylim3d(center[1] - radius - padding, + center[1] + radius + padding) + ax.set_zlim3d(center[2] - radius - padding, + center[2] + radius + padding) def _configure_axes(self, ax): """Finalize plot appearance""" diff --git a/src/ect/results.py b/src/ect/results.py index 2095591..1383958 100644 --- a/src/ect/results.py +++ b/src/ect/results.py @@ -70,25 +70,24 @@ def plot(self, ax=None): ax.set_ylabel(r'Threshold $a$') return ax - def smooth(self): """Calculate the Smooth Euler Characteristic Transform""" # convert to float for calculations data = self.astype(np.float64) - + # get average for each direction direction_avgs = np.average(data, axis=1) - + # center each direction's values centered = data - direction_avgs[:, np.newaxis] - + # compute cumulative sum to get SECT sect = np.cumsum(centered, axis=1) - + # create new ECTResult with float type return ECTResult(sect.astype(np.float64), self.directions, self.thresholds) - + def _plot_ecc(self, theta): """Plot the Euler Characteristic Curve for a specific direction""" plt.step(self.thresholds, self.T, label='ECC') diff --git a/src/ect/utils/examples.py b/src/ect/utils/examples.py index fec896c..a9f6db0 100644 --- a/src/ect/utils/examples.py +++ b/src/ect/utils/examples.py @@ -76,26 +76,27 @@ def create_example_graph(centered=True, center_type='mean'): return graph + def create_random_graph(n_nodes=100, n_edges=200, dim=2): """Creates a random graph with random node positions in [0,1]^dim - + Args: n_nodes: Number of nodes n_edges: Number of random edges to add dim: Dimension of embedding space """ G = EmbeddedGraph() - + coords = np.random.random((n_nodes, dim)) nodes_with_coords = [(i, coords[i]) for i in range(n_nodes)] G.add_nodes_from(nodes_with_coords) - + edges = set() while len(edges) < n_edges: u = np.random.randint(0, n_nodes) v = np.random.randint(0, n_nodes) if u != v: edges.add(tuple(sorted([u, v]))) - + G.add_edges_from(edges) return G From 2c5302a60ce2290c858cd534b234643993f935f3 Mon Sep 17 00:00:00 2001 From: yemeen Date: Sun, 30 Mar 2025 15:38:00 -0400 Subject: [PATCH 20/35] Refactor ECT class methods for better readability --- src/ect/ect_graph.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/ect/ect_graph.py b/src/ect/ect_graph.py index f469fc2..4874f0c 100644 --- a/src/ect/ect_graph.py +++ b/src/ect/ect_graph.py @@ -117,15 +117,12 @@ def calculate( radius, thresholds = self.get_thresholds(graph, bound_radius) directions = ( - self.directions if theta is None else Directions.from_angles([ - theta]) + self.directions if theta is None else Directions.from_angles([theta]) ) - simplex_projections = self._compute_simplex_projections( - graph, directions) + simplex_projections = self._compute_simplex_projections(graph, directions) - ect = self._compute_directional_transform( - simplex_projections, thresholds) + ect = self._compute_directional_transform(simplex_projections, thresholds) return ECTResult(ect, directions, thresholds) @@ -151,11 +148,9 @@ def _compute_simplex_projections( if isinstance(graph, EmbeddedCW) and len(graph.faces) > 0: node_to_index = {n: i for i, n in enumerate(graph.node_list)} - face_indices = [[node_to_index[v] for v in face] - for face in graph.faces] + face_indices = [[node_to_index[v] for v in face] for face in graph.faces] face_maxes = np.array( - [np.max(node_projections[face, :], axis=0) - for face in face_indices] + [np.max(node_projections[face, :], axis=0) for face in face_indices] ) simplex_projections.append(face_maxes) From ff6e42afbabf8e98510bd46ca5a6fa6c8351b33d Mon Sep 17 00:00:00 2001 From: yemeen Date: Sun, 30 Mar 2025 15:39:19 -0400 Subject: [PATCH 21/35] make html --- docs/_images/notebooks_tutorial_cw_13_1.png | Bin 0 -> 18212 bytes docs/_images/notebooks_tutorial_cw_15_1.png | Bin 0 -> 29973 bytes docs/_images/notebooks_tutorial_cw_3_1.png | Bin 0 -> 5381 bytes docs/_images/notebooks_tutorial_cw_5_1.png | Bin 0 -> 37620 bytes docs/_images/notebooks_tutorial_cw_9_2.png | Bin 0 -> 12899 bytes .../_images/notebooks_tutorial_graph_11_1.png | Bin 0 -> 31211 bytes .../_images/notebooks_tutorial_graph_18_2.png | Bin 0 -> 18704 bytes .../_images/notebooks_tutorial_graph_21_1.png | Bin 0 -> 16650 bytes docs/_images/notebooks_tutorial_graph_3_1.png | Bin 0 -> 18813 bytes docs/_images/notebooks_tutorial_graph_7_2.png | Bin 0 -> 19172 bytes docs/_images/notebooks_tutorial_graph_9_2.png | Bin 0 -> 33118 bytes docs/_modules/ect/ect_graph.html | 550 +++----- docs/_modules/ect/embed_cw.html | 211 +-- docs/_modules/ect/embed_graph.html | 1169 +++++++++-------- docs/_modules/index.html | 24 +- .../Tutorial-ECT_for_CW_Complexes.ipynb.txt | 20 +- ...Tutorial-ECT_for_embedded_graphs.ipynb.txt | 7 +- docs/_sources/notebooks/tutorial_cw.ipynb.txt | 294 +++++ .../notebooks/tutorial_graph.ipynb.txt | 281 ++++ docs/_static/css/badge_only.css | 2 +- docs/_static/css/theme.css | 2 +- docs/_static/fonts/Lato/lato-bold.eot | Bin 256056 -> 0 bytes docs/_static/fonts/Lato/lato-bold.ttf | Bin 600856 -> 0 bytes docs/_static/fonts/Lato/lato-bold.woff | Bin 309728 -> 0 bytes docs/_static/fonts/Lato/lato-bold.woff2 | Bin 184912 -> 0 bytes docs/_static/fonts/Lato/lato-bolditalic.eot | Bin 266158 -> 0 bytes docs/_static/fonts/Lato/lato-bolditalic.ttf | Bin 622572 -> 0 bytes docs/_static/fonts/Lato/lato-bolditalic.woff | Bin 323344 -> 0 bytes docs/_static/fonts/Lato/lato-bolditalic.woff2 | Bin 193308 -> 0 bytes docs/_static/fonts/Lato/lato-italic.eot | Bin 268604 -> 0 bytes docs/_static/fonts/Lato/lato-italic.ttf | Bin 639388 -> 0 bytes docs/_static/fonts/Lato/lato-italic.woff | Bin 328412 -> 0 bytes docs/_static/fonts/Lato/lato-italic.woff2 | Bin 195704 -> 0 bytes docs/_static/fonts/Lato/lato-regular.eot | Bin 253461 -> 0 bytes docs/_static/fonts/Lato/lato-regular.ttf | Bin 607720 -> 0 bytes docs/_static/fonts/Lato/lato-regular.woff | Bin 309192 -> 0 bytes docs/_static/fonts/Lato/lato-regular.woff2 | Bin 182708 -> 0 bytes .../fonts/RobotoSlab/roboto-slab-v7-bold.eot | Bin 79520 -> 0 bytes .../fonts/RobotoSlab/roboto-slab-v7-bold.ttf | Bin 170616 -> 0 bytes .../fonts/RobotoSlab/roboto-slab-v7-bold.woff | Bin 87624 -> 0 bytes .../RobotoSlab/roboto-slab-v7-bold.woff2 | Bin 67312 -> 0 bytes .../RobotoSlab/roboto-slab-v7-regular.eot | Bin 78331 -> 0 bytes .../RobotoSlab/roboto-slab-v7-regular.ttf | Bin 169064 -> 0 bytes .../RobotoSlab/roboto-slab-v7-regular.woff | Bin 86288 -> 0 bytes .../RobotoSlab/roboto-slab-v7-regular.woff2 | Bin 66444 -> 0 bytes docs/_static/js/html5shiv-printshiv.min.js | 4 + docs/_static/js/html5shiv.min.js | 4 + docs/_static/js/versions.js | 224 ---- docs/_static/pygments.css | 1 + docs/_static/searchtools.js | 7 +- docs/citing.html | 24 +- docs/contributing.html | 24 +- docs/doctrees/citing.doctree | Bin 4072 -> 4040 bytes docs/doctrees/contributing.doctree | Bin 22511 -> 22479 bytes docs/doctrees/ect_on_graphs.doctree | Bin 132974 -> 65870 bytes docs/doctrees/embed_cw.doctree | Bin 185996 -> 98074 bytes docs/doctrees/embed_graph.doctree | Bin 334211 -> 176046 bytes docs/doctrees/environment.pickle | Bin 817625 -> 131742 bytes docs/doctrees/index.doctree | Bin 15376 -> 15344 bytes docs/doctrees/installation.doctree | Bin 5902 -> 5870 bytes docs/doctrees/license.doctree | Bin 4148 -> 4166 bytes docs/doctrees/modules.doctree | Bin 2911 -> 2879 bytes .../Tutorial-ECT_for_CW_Complexes.ipynb | 20 +- .../Tutorial-ECT_for_embedded_graphs.ipynb | 7 +- .../nbsphinx/notebooks/tutorial_cw.ipynb | 294 +++++ .../nbsphinx/notebooks/tutorial_graph.ipynb | 561 ++++++++ .../nbsphinx/notebooks_tutorial_cw_13_1.png | Bin 0 -> 18212 bytes .../nbsphinx/notebooks_tutorial_cw_15_1.png | Bin 0 -> 29973 bytes .../nbsphinx/notebooks_tutorial_cw_3_1.png | Bin 0 -> 5381 bytes .../nbsphinx/notebooks_tutorial_cw_5_1.png | Bin 0 -> 37620 bytes .../nbsphinx/notebooks_tutorial_cw_9_2.png | Bin 0 -> 12899 bytes .../notebooks_tutorial_graph_11_1.png | Bin 0 -> 31211 bytes .../notebooks_tutorial_graph_18_2.png | Bin 0 -> 18704 bytes .../notebooks_tutorial_graph_21_1.png | Bin 0 -> 16650 bytes .../nbsphinx/notebooks_tutorial_graph_3_1.png | Bin 0 -> 18813 bytes .../nbsphinx/notebooks_tutorial_graph_7_2.png | Bin 0 -> 19172 bytes .../nbsphinx/notebooks_tutorial_graph_9_2.png | Bin 0 -> 33118 bytes .../notebooks/CodingFiguresFern.doctree | Bin 147997 -> 147965 bytes .../notebooks/Matisse/Matisse_ECT.doctree | Bin 31174 -> 31142 bytes .../Tutorial-ECT_for_CW_Complexes.doctree | Bin 34045 -> 39892 bytes .../Tutorial-ECT_for_embedded_graphs.doctree | Bin 61469 -> 61497 bytes docs/doctrees/notebooks/tutorial_cw.doctree | Bin 0 -> 25607 bytes .../doctrees/notebooks/tutorial_graph.doctree | Bin 0 -> 45707 bytes docs/doctrees/tutorials.doctree | Bin 2926 -> 2894 bytes docs/ect_on_graphs.html | 188 +-- docs/embed_cw.html | 115 +- docs/embed_graph.html | 554 ++++---- docs/genindex.html | 127 +- docs/index.html | 28 +- docs/installation.html | 24 +- docs/license.html | 24 +- docs/modules.html | 30 +- docs/notebooks/CodingFiguresFern.html | 30 +- docs/notebooks/Matisse/Matisse_ECT.html | 30 +- .../Tutorial-ECT_for_CW_Complexes.html | 48 +- .../Tutorial-ECT_for_CW_Complexes.ipynb | 20 +- .../Tutorial-ECT_for_embedded_graphs.html | 35 +- .../Tutorial-ECT_for_embedded_graphs.ipynb | 7 +- docs/notebooks/tutorial_cw.html | 354 +++++ docs/notebooks/tutorial_cw.ipynb | 294 +++++ docs/notebooks/tutorial_graph.html | 506 +++++++ docs/notebooks/tutorial_graph.ipynb | 561 ++++++++ docs/objects.inv | Bin 2526 -> 2427 bytes docs/py-modindex.html | 24 +- docs/search.html | 24 +- docs/searchindex.js | 2 +- docs/tutorials.html | 28 +- 107 files changed, 4676 insertions(+), 2107 deletions(-) create mode 100644 docs/_images/notebooks_tutorial_cw_13_1.png create mode 100644 docs/_images/notebooks_tutorial_cw_15_1.png create mode 100644 docs/_images/notebooks_tutorial_cw_3_1.png create mode 100644 docs/_images/notebooks_tutorial_cw_5_1.png create mode 100644 docs/_images/notebooks_tutorial_cw_9_2.png create mode 100644 docs/_images/notebooks_tutorial_graph_11_1.png create mode 100644 docs/_images/notebooks_tutorial_graph_18_2.png create mode 100644 docs/_images/notebooks_tutorial_graph_21_1.png create mode 100644 docs/_images/notebooks_tutorial_graph_3_1.png create mode 100644 docs/_images/notebooks_tutorial_graph_7_2.png create mode 100644 docs/_images/notebooks_tutorial_graph_9_2.png create mode 100644 docs/_sources/notebooks/tutorial_cw.ipynb.txt create mode 100644 docs/_sources/notebooks/tutorial_graph.ipynb.txt delete mode 100644 docs/_static/fonts/Lato/lato-bold.eot delete mode 100644 docs/_static/fonts/Lato/lato-bold.ttf delete mode 100644 docs/_static/fonts/Lato/lato-bold.woff delete mode 100644 docs/_static/fonts/Lato/lato-bold.woff2 delete mode 100644 docs/_static/fonts/Lato/lato-bolditalic.eot delete mode 100644 docs/_static/fonts/Lato/lato-bolditalic.ttf delete mode 100644 docs/_static/fonts/Lato/lato-bolditalic.woff delete mode 100644 docs/_static/fonts/Lato/lato-bolditalic.woff2 delete mode 100644 docs/_static/fonts/Lato/lato-italic.eot delete mode 100644 docs/_static/fonts/Lato/lato-italic.ttf delete mode 100644 docs/_static/fonts/Lato/lato-italic.woff delete mode 100644 docs/_static/fonts/Lato/lato-italic.woff2 delete mode 100644 docs/_static/fonts/Lato/lato-regular.eot delete mode 100644 docs/_static/fonts/Lato/lato-regular.ttf delete mode 100644 docs/_static/fonts/Lato/lato-regular.woff delete mode 100644 docs/_static/fonts/Lato/lato-regular.woff2 delete mode 100644 docs/_static/fonts/RobotoSlab/roboto-slab-v7-bold.eot delete mode 100644 docs/_static/fonts/RobotoSlab/roboto-slab-v7-bold.ttf delete mode 100644 docs/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff delete mode 100644 docs/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff2 delete mode 100644 docs/_static/fonts/RobotoSlab/roboto-slab-v7-regular.eot delete mode 100644 docs/_static/fonts/RobotoSlab/roboto-slab-v7-regular.ttf delete mode 100644 docs/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff delete mode 100644 docs/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff2 create mode 100644 docs/_static/js/html5shiv-printshiv.min.js create mode 100644 docs/_static/js/html5shiv.min.js delete mode 100644 docs/_static/js/versions.js create mode 100644 docs/doctrees/nbsphinx/notebooks/tutorial_cw.ipynb create mode 100644 docs/doctrees/nbsphinx/notebooks/tutorial_graph.ipynb create mode 100644 docs/doctrees/nbsphinx/notebooks_tutorial_cw_13_1.png create mode 100644 docs/doctrees/nbsphinx/notebooks_tutorial_cw_15_1.png create mode 100644 docs/doctrees/nbsphinx/notebooks_tutorial_cw_3_1.png create mode 100644 docs/doctrees/nbsphinx/notebooks_tutorial_cw_5_1.png create mode 100644 docs/doctrees/nbsphinx/notebooks_tutorial_cw_9_2.png create mode 100644 docs/doctrees/nbsphinx/notebooks_tutorial_graph_11_1.png create mode 100644 docs/doctrees/nbsphinx/notebooks_tutorial_graph_18_2.png create mode 100644 docs/doctrees/nbsphinx/notebooks_tutorial_graph_21_1.png create mode 100644 docs/doctrees/nbsphinx/notebooks_tutorial_graph_3_1.png create mode 100644 docs/doctrees/nbsphinx/notebooks_tutorial_graph_7_2.png create mode 100644 docs/doctrees/nbsphinx/notebooks_tutorial_graph_9_2.png create mode 100644 docs/doctrees/notebooks/tutorial_cw.doctree create mode 100644 docs/doctrees/notebooks/tutorial_graph.doctree create mode 100644 docs/notebooks/tutorial_cw.html create mode 100644 docs/notebooks/tutorial_cw.ipynb create mode 100644 docs/notebooks/tutorial_graph.html create mode 100644 docs/notebooks/tutorial_graph.ipynb diff --git a/docs/_images/notebooks_tutorial_cw_13_1.png b/docs/_images/notebooks_tutorial_cw_13_1.png new file mode 100644 index 0000000000000000000000000000000000000000..10f26c3068f52510ac56271cf42c010fff693d95 GIT binary patch literal 18212 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 WmUw4-;KBt4BPA{?mLqb{`~LxNJ)tT9 literal 0 HcmV?d00001 diff --git a/docs/_images/notebooks_tutorial_cw_15_1.png b/docs/_images/notebooks_tutorial_cw_15_1.png new file mode 100644 index 0000000000000000000000000000000000000000..65973705f9d8ba7359767a27ff42761d47b7a978 GIT binary patch literal 29973 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

xAb>>0p<}}BZi_Aq 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 KDJL&p{r>=3BDi}1 literal 0 HcmV?d00001 diff --git a/docs/_images/notebooks_tutorial_graph_21_1.png b/docs/_images/notebooks_tutorial_graph_21_1.png new file mode 100644 index 0000000000000000000000000000000000000000..aa843aa0780d5f34bc975e53e4c9f1964069461e GIT binary patch literal 16650 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 NaZ%~JnIbxW{U7XWu;l;% literal 0 HcmV?d00001 diff --git a/docs/_images/notebooks_tutorial_graph_3_1.png b/docs/_images/notebooks_tutorial_graph_3_1.png new file mode 100644 index 0000000000000000000000000000000000000000..4f9a69c8243f9a84c5e7ceddcd7c4eda8d74702b GIT binary patch literal 18813 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

WNYR7NL~> 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
  • m`bPKU!qy`ys2w*ro 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 literal 0 HcmV?d00001 diff --git a/docs/_images/notebooks_tutorial_graph_9_2.png b/docs/_images/notebooks_tutorial_graph_9_2.png new file mode 100644 index 0000000000000000000000000000000000000000..53e1474c568bf4c029724e464d2ad11dcb78739c GIT binary patch literal 33118 zcmbTe1yq&W*EhQ9?ha`IK~kg}q*Ou#q(elyySp0^lnw=?rMp9EkZzFfZur)Q^FQzR ze)k>U9rrTi9LIL==ULBOYt1!(F?Wcfyfg+XDJld4!H|`ad;@{NWP#rlWJK^v(+c?$ z@IOBL7w_zqt&HrQ^ld*tUhCUin_JnNn;1|zez3JOv9f%|%E!w7l*-uN-rA0zjm_fU zH?UgSeq`fGkYd*WHwzhv5;hq!qs&WDQ*2z^ zR?FBvqd4=uqboRWo)2Xwz5Ttv+*28%VpD{UJP-T(>+Dv0`EUdEiG%Ndtb;#oK_u^g z+70Mvz)vfm0S7J)_*o<`B@ThU$vW~~`29P0rJqVc-$Ij2OGpcS3r;^ySPb+n$ZPO+ zkGz?;9R>c{@);NyP{qhpV@g9m)c%q<&_^mu%+-}Yd$_3B%UL@dyyfT5pVgQW_y4Y7 zYHAw&<;&#CR{c|{j*bpSR@T5#Qv_>S8gA~-%ejKm-`-GjaKvn}2a6}+SqMWsT0x@Z zQ_03hFza@>9_LuH3XOsWQA9frzhjI?|X?M-YZKl zxVX7#1UYbUgj}niYKe;qu0n%eN?hvwCQq_+#j3nqG$~GiGEiUf=4fE>w;}XTx8dOE zSgU1)!P4*e;GX`#QN89b#f`8K=uN<-l!V-9csX&=+eB$%sEQOQ(W`G9;iV<1!2Mfr zaH0eKskr!S9dy-~yJ#D&biMVP+0nNN(FzdZGF?y2XTMA9P-Ih6myn{DtlP6c=aFz( zf_K$%`LMFLUsJ5oeU?9`Gjb!u{q_}zbN_ly~ z{k@WJ2U6GFwhDvgWPN5$11c*C7ehjjL&DL|=eriEYG$mw^v^7b$;kBI=B3B?WW@Dk z*<@8u;v9VgxI&qr+)y zKl;=am9;p*!9^|-xqfUPu33o=QEb$QBaNhZ;XdD3sD=!t81;84?cCpBed2WjRIW4u zr1#2rwmAw&kmSqlXP>+-9z&=EsbOUOkZ4`@^|H~)#)_udv5|KY?T{>ly`4X_yR}=1 z&I~(Wa$(*crLi-{7dC}8%&@Fb8TAsQCkrKo?Z0bFBA2DZmHhUdhUt05?B3O@l+cN} zh75iWQjD(1#idlC>gtbJm#>ln7mwE9Ao$WzG)ydoBW|*^+3)-DKmzH$&pENNvCs_R zZf`)0*tgc7ak?P(=aFDAG^k9^31Xf+L5>U-g{}sdZfv@hHQJ}FMYC=f>&t9($uzFV zqN@|7-Q)s<5WO+ubU_Y@`$PtlJonQ_~MJR+059>F7FNa}00WlV|Tk42c(lqBL<2Ijwq{s&i zWl0Onb{cZF7;;wJY8I*@F$reAD^vwfE1#a+ON!C&afD&&xZ}HfsPTkDUA>!FfDlAA z8BxF2oe=aFan6O|aJO-K+3|cy0u`KGKjjMs zW}L3iZ6u|oX`entw_EG)SnW%SNk~|;pZ?JEnVOZg=!9YG@EN&O7LIKVxno^}!xzmj zuMo>7(F4R`3RS~aSB-MtG7+OA{uv!@F|DYa9y2ISFDk-xZJ=O^M}7VJ_27nmZgFu? zhk;2|VV{wo7Hd$8p6L7cv>F;3=|jeYgM*N`Uw;h4#cW8JL9Or*0wXCe6X@UU-Zr&Nwj{;27@r-XmT$0hPd zGxPF(=*uO&tFgZc59FnP8VZfsD|us=1T)T$w%el1nv zPQ8q(xX{8QD!Dk%vlpe=8j(c-E z;XNEu`^K{$gn|Mm--_b$s_8Z?&Atf}o=Pa2VxuZ+@e!(7=C$TP^h10+O>OV81 z}u=e8y=r47;NMjq)3Iy;Jk!FZ}VM*{Rg4!J1$Z z|M0pciJ$jC0x6mf7v*Ph6GdBCs74h|Y!t)beGaH|lfv9~bgRp!B+V-<{I2kF^*ei~mXRagd|hb*RD|7zGaYndb_gDWe0zpOkRYq1c&Cj-6n(t| z=axO=-6r$({(|56vLBY>!A=!~^)>5Pr?c5)A{q|q>o$&U0bW6J7q1&;^NkJO+k>w2 zTOTCr%cBAPF=O7yK@tB{%8#H; zi38GGCW1HWsgj(7&YOSUsE3HWl*5jT4}d8%qPCmi&FNvYe(lsBqi8&2I`nb#?QyCg z378=qTbBzgNbY1A5}2jJm%hj_Q-^TT4omauzOb3_$1n$~u>JjYVevQgN58kk;q9ZyQBsQB zp~qZHRum26Q}iy8TVj#El(5Z50?iXUFp+}-_g9404=@z-fk$5|Em?YjGlOAPH*P7i|J zmONXIvXQV>Z%B;T`JG;pjvkTbRa6Fx2t=KsG$|6npTEUS4V349)Kp<1nQz{(yDy5r z(kMgp+iTK|4nR(4lLine_0nJJh5n$>8C8arE+m1X;PzlKNa=PZq2x=SdEh!h`3e%% z78Ed~o4kbo9sZR+3P!>BYWvrln?cv3b!i#rORTSQnn&Ox zG{W@LLF(3+3q*?x82Iwx{3hfRKH4v@=+8|=j^!RldL82!x}DK5a^ahJU7(>_)pV={ z44)%n@NELXb9)ZPoqV-Q_Q~r20o-a>DzbgB#tvs=Q7-@6w{H_a9mwRO$bICkyd{|s z-dE8wal!_)1ucKx;K?j08O%@oXI(PLogiiq(q`D{ugPB@v>=tNpL7#HL%hHeW;|!6 zj@b>#D8=czyRm}SMj;&?mQl;G5lJNmqo&!0Yngh(T!vV^($0p4TU$~M6e=qWil;}8 zj6az6*RNd?pW7i$lln?Ml_IA(wh~>@^$7TFG0pDY0-GH{3?q6j|0n|r!$Pv7O7l<_ zQBWY>wQWH-aq*+I;X)8DE-t-Y-vDHVU(Lrzp!lNsng3mbgXDPP@_>n$fV05G>v8O? zmc!6zO3`}GB_Y*(f)>GKhjeC8-2|iv3?2Pu#vvp^s_R3{ADY;N-0r>n zk35|hpqcb=uZhy*`)dgYhv&QXA2E%DO5yqZ0-a}#R(`!_bXj{|aCa=SzGLv&2JAR! z85Q!Tj#=(o>=w45RPkGb`Il@N$Foj0n`HwKE!W7bWiDoM+e7$!1Us>h5)s;(`!Vf> z@hkMmBAtjr1wo(by-Vg@H+0N^kuy zhu<8h^5XdIHdOUk1T6i1au0AzSK>I$iRlT6vOr>VKDwf`zKR>5eM~#j#QltuV=Lrw zVx69T!t}qJrPLHscl}FX_*Rj${H-F5#VR_f?_f69C-a%V#}4;d2MXZ#Jxufg{+0EK9Cdpxry z(vxa;tbvsJfcpqndZ-p?L?qpfoy3Q;%^WM@s21q#96!JReCC!VslvkYYq>!EbY53{ za1e;m#2fsv8=$7+99~9weX;%*>)|;!lr=O$KW%GOWv!a4GIJNXU^Q7@k&3mnAfuv+ z&$qPM>@BU~5Ri?VT$KdN(4F@Zeo@ zpvn$-BmmK>joIq!opQH3Bzl;t<4LenQ0ZUW)y@7`OKq>g^U|A4m|vQ;@@hHk6VYEE zT~0SK**z2ri2JVJGiH(EsqpU|!1*xXfLxf%rqz7wbqzN)rID@cKrmcX`#Z<+{I4YJ zKR0+H;EL}wZwuzpG=JdzJl6b@pDrdf|L&Wo0F=G|UYnXB?4~Cyd}peiGe2^l<^T+W z#lXPO3W7j#g`BbLQe9D917IcYPIx(MWi!m{SoBX;Q_v-+UtvEb1EKEsD3q7f?(}7XAB`BwW#Z^dd-h*g(K`-2M5rGbi zVq4A4-I@mFo)a>^lzB(-YN5d6;iy@OS5i{Kkx$_dkP!T0F|KLINit3i1?^d%pSJir z%^lQX!(9K@XPtjCnaj`V{ZUmDAIYW}76!SkoB!Y^F-l5#IhXutz8cfPR_tTrydp#h z-P&4Jl2;F9wXFf#Kfwx#4D}!C>pLr-^ROQMM5c6o9XFV#3DF{rzr4blR#a1-UAYQ( z70S%wcY+!I&zS&fWY&c%bAC6%x>RRajIJmNmu=#M(;907y#LGrF*iC$PcxQk$D7mL z6wyH+Et_znUQ?EQ(DB2QG}p?$tWwC%M*0LYQ0uj4xJzhT4pIH1Bm%<5v+Tc%Qz5&G@f zI-<2(M!#R8r<0UfI#Gd}q4HSuu*dgD4&Vwzjg5_)Pros-Nxu_^E%Lnh&~Eghv@%VU zAK_v3$y_qV%WZ?|fk8gm`~7G#8NEU)4`pN2WR&o0a}iAu*3S1wYXTk-mDdnMz=hF? z5(mP(uaY3e_s@tD^z{M9PU{o*ba}~k-DH9c7&yd*DYN1i8XBZVb2bc23KT~!7r~Wp zQ+W}~eiAz(J?PKo+cWK@)ma*X?lfO3Qr8ddE^r3(vN z#aRHVXGf4C)QxT)r+*NPZDfDuhM*{rzj`elhP*y>lvHGd`~?ZrNDO+|U@R zW;BO5JCt>PvyxyldNe@G`?S}JvJvo%Po$CagvjpAFbs}AJwdjYUsFjzqOF~dvAjOIueR(u|Viyz==Qr_c z(406K$MF8#7okAm<1o&!yq3g0QOY2Ei%;DpJXWX~w7F?&`0*nxCqL<4@hwEQ!A_Ca zg!CuUDy`t5SHQ2_@vI-$@N!;rm>(P-Q>2I#&kE55>RWH?-ubF$wZw-JR$L{6UDN!G zL1AEdDIN!{d&M?$-uoB>!#J-%GA&P;!~E7=9@F^WJ2O*yftX4JVP@3Uk^&mI8i4nh z=xFcBXCE`m%kk&UuQ1cRDlj!tC;(9wJ{!57D}#?o8MzjEyJ~-4DM`~3-E@PYm-uo| zpBT`SV5z@Jp!DA6V9+5!fd}9zm_jJ`%IGo85=M3?HUwbb0`)RP!Z3u9@2}gsV~Qxf zPYAnI-n>*H=zOXjy0CAZxr2rP9XX)zL=6r_lRA7Q0P6f+_7MgWBP*LsNHaz6k`1jY z@(QDt^+#2}1e_l%0~Qbt5xsf-_LOJFWi3UA2*hN+k7vHwpp;5LfQ(iuoa)i-V>(<$ zh2-YF;(A{+6_xF6KM5HMI|H6aXmpPsUz~V$T6Qh2^iWS%VF4OOW_>(L(Xes37;iou zdbcM}o`kH&D{AsP4wN3eP@sv?>u$sHE1guln01PFzkI@(=7RVp^fM)cOYhK;+9Hl= z1?jI}zl0kS^ohx}%MgEhTt&O|JtsaKlADI>$L>1Db|r(_m}(}faq-#K`11AXjtmJuet?1? z&c2eJ_F@zsC3KIp zYu;^X)$TXubwtk*zCu5!sUfZ~lx?OkK7rRwIY$L_T}eu^HpSGmmi>T3dkmnbt(`AyjZICl>+PSsNqO4NfDfki zx|&*y1kfoIR{1r~ICZJ+xYBc8X&u4WU1SW@qDfd(=nPhY#&O4sKirB~FeyVMeR0Bw zXc+~`k#yWJ`ql=J1PR|n_u}Mt7*GEEK+ItP4^*1y*jQgp0GB`ZC+piK)KfiwKAN@D ztHI(4QasQA3?s?9Y~S84=EW(3C9_Z!$2NcxbGTUR)-R@+HlfqNe%V2!U!_rcM|tO+ z_bnhU@jBoyN0%wbRD&B&XlQ7O*PZ9}@rXjnyQhhqA5i0&DJnbS92R%D$)Ggp;O`aU z!~HfH67KMu=4w9?T+A5d*A>p37w7f2R% zrhrx4?+IzQ6Q^@O9~4?^{-OY|u%enui&3dHcFbM0;3_P?cgD)$H)9YMDkm~)L(VTf zbYGs6{@I?3pm1>^=C(?uqVTp`qvHZ|cD^WisITJXaXu`xmd>2kf+a*3Z0G$9%o0oz z_u(2{f-Xnz>DNqbU`-KxyoM>THPe` z4t=18WyNrI-uRZle=}#yB@WoHj{~W>g@rGqq!2;T6B`#dG&2+4-7N$7!-*Y7ptz-f z`-UJZD+@R|t6A>Hj}amwB7!3#0)~wJC27QrjTy@|Sa9+10CA>KwKG}9u(`Dbk|(4= zCu(6qPv+Yj;s~ks&dy3!#>a8LqEk~bYqTP>v*~h2P5lD`GD=HvfFKtf8Y-@=tZX(D zvbb2Ss2(CL5hVV;jGFT)dS72($@C5~7FLi(;V!mUg-$B__s4NcrEkZ zhJvB3pSZSnb!BB_w3y8Br(kAeWI$Lw_%?{EmW7Iz2CDoPqWbRg!VP@M+X7IW?_z*T;jK@EUCI5^meRQGFv0Ae*na(K5)RZ}wz zuEX3HukexvDBTJo+U0a)7)<$5i!=951W+)lT}(u6%~XCgl{(*nXdH#gU@ zschQ(e5Yn@@OUjP@fxhTH+pdz{C2pUAlvyk7})LdA~jx?G;sMBJ1;4_tz&z7U3{QB z-U>3iyHJ=@_^}y+$5s>$uxx7pTB*yC(e_kD4_KXpyPAH{Htx$NFE0{w#a#~RY^`c# ztpkv)cj*klPJ&&=52pFsOhhRhk-8PkAZ$V_m3~*CeqNRh4>xc!_BV**X`i=EU)%C=C(exy*PX=7ojC3RSE6C*OLVvxmQaOv9=MZ+qPROgK>YI|xZ43fWSX9(7dZXX_D_uW*x1v5`mEOPJFr5DF(d~w#2w-SeT zP^w3f5kELi4ktA+c%g*7g@^`H*d}4(!%f*N!XQjQjg)%1-{98o!*&5QTd3ysm6<7; zT@Mm_bfg~I&m-d*341!2&*sce7i~4sYqagY%>iUM>)E=!Lr>@LGG_r%FS^Oj&#jqF z%5C`^pK1l~`@_F-zw>}c+xE=KS+lW0K_TQV*N5XGVxuv;b~Z(gc}2g>51$F;de{CA zz<>8?#iOLQAK=?RNeq)$kiPXe6I0WT!ZRC>#rUlNZokf}e26^ySW&+U3wUZr79&NbxDRAKtmylM5RBs?&Z$$@Zyk#0H`>0UVMuiBf1TzL|5RxKye|Fa*+FNov zfO-+4gaWk{gi#vA#dtaAsW12dvmnM)Y1zQF`Jq2~tYX&$h$WFgln30z!i;P%n2&sN zlspj9rY`IFnn z(`Gmf@KM?H@}b6vee=z$AvxgVqZ2Fne_+aOOXV1ZnLZjh6^5GhsCBwbx zO71ggDPl;IRFpxcZ@d%sWgV#PM|cQRGmz8L8?9k3umrsaa~a{4Iem**%K2;-m4Djc zK?xJUb>P4H8dzu5NVaH^s>8E+lFKCG^C4f@KXa2rkl%~Ks4sa@X_^Z}vfq}jeU}d@ zJF+G{8vD56!ifCttjWf4gdV;ANSwxafZQX}?I90?&#$QQI!alpD& zn{+cnuB8URe;LTX>wE^1btZFUq@=vzp^uF1Q2iAoB;cSFAC!Jm3&>>wG~YsF!sq44 zra824Z#E9~N6m_A0>6sGmk-25*R7F93$Mlop^E{1F;~bM@;iOlsH0} z)6-LzTp83!J_PD4?}%$j`o|eSFR@V#2EG|JDNkUKAWkYAxwdnxOR3EmEaxlTd1kp} znBYgSs>6dzosofzf)?~EU0QYiw)!N42L0*!rJ)&^+r!eJ600*ddEFci=^!l1&eNS~ z99&#qm!nk_%RzC?iQXtF`nC`S;T9;N{N+>oZ{g&{kT}3f(P-3=H-CG=pZwZ?^PNr# zy|JCaNDjb(PbO=$32BhR=G6VFs;ygoV9c)XLO8@|L8RsV8i zVcauRW48t`1d@UIs z#}7py62A$=kT{GkvU(L{%PWMWIk?*XJxVplY5K()x`O3=ptO{D{s^F5K9loNSXNA$ zzXiEkPe3FD4w35SM?MumnLkAC3>|baWuVL%0TB@`8(Wl9!Yaev5hW;A#OzkBAB7@E z#hg3GUCtb2b#L-SSDXfjP7m2_Y;4pEAnm8r_b|_nC49Dh6gs|tn9It<)S9Q9V+|gm zNW$cW%O?IN<|A>izGc>@$5$%pHDrXLqcm**cTyvzgz!e2{#h}dT8qk7WgSfU|eJ*4QM#KA|&Q!o$6*kN3{*! z1aLEOdt_$O=>OT8e(k#%UX_+O&0j9?tco)AYN2Ueq`Y~>?Vid@u3|nWcSv?)6%tBW z4(`Uyy_VGVJnbFY=`j*xfLgJzLN+JyXo=WPji-S^y(ugMT@A|Y=EOgKE9mKojr(bh zZ40g|PeTO=Uia#easOo6k62Y>V`HdeT_ire3U)6LeybLU&Iz5Ruf?nVtOOpl`t9$_ zWT9Eo`c;#HbE%yh>d>{k29-y)*LDiZwJ(nxXE<-9a*9#-r2AJ>@qR`7a&&4zRA$O?Y@>lezrHK{>q{aGkeOOY36yL)?PsUE~`6x?XPa4hV$x;mWAZ?9Q(i3}pO`h*xHDb?Voa^`!#M9a{f$93e> zLM?cS)LqjT$UgAHGgu_>l*3>7Fpzg1lh3(D=z^B}N95~(dkcCOkWmg>yOQ2Yc`pC+au%mf$v6R+lkZ-4T0JBfy~rY(ZUe zSJ2J=2f_6lE|KR20x)p^@p5g{O;bbTU1+GlecHCQeCo%RP44oO%Rky(&<8E;d`9b)W)&tN&&q~{Wf^RYorvHA{Grva%JsBi`L5Sk1TI`E(ZABWswfHC3JN;L`pASX5t*P@x6OxM zojDDNi;#pyNVMl_a5j)2YpI^H_Gck!rRbk6qPRCoK+jWv4NGlq5*R0|QJB-d24&Cj zF(z|iOu`*a3lW?B*@6(eQ=3CJdhtRjy>~7<#=g!fuRx&I)8d&y(Cbq&NJQE_EEQ} zBOr{3Iz}!An=&eKo$((EZfs@WLJgT(dahuG37*nwVYth(Lfm5!7T8_H0oV7RyD)m} z2^oLjga#1!b#$6UhWohK#r?S%SUphD(3IZ2LoS;P2mTcpNUF!FdecWyLh(q!jZ{0T z`)e|Q0yO#jFWy_r>A>x+VumqoX{dQ~&}a3xT<-6l7;e)~P{r$OQqnKP4me||_i~{r zE(bjN`R+1{)}=o{(cnzW`1S+!$DyskPAssADj_j7U136^?b|+<=zoXaAfcZOsS-?7CE?53?S}2D!*6c= zFq9tGK3Kv^z(qz={gxMMR?WpwU+$zfo8fJmUaYlB#CV8AP8=IF66?!Y?gv9-W0^}y zR)atUZ(sX+KKj{tAQ-{i=viqcFbFKWGk=HT`3PFIj9oO~hTitb+QqjS*uwW5iUJ5S%NbelqLG=H;nhYJT zVFV=>u=$ZjUR1TC$TFyg#mMKh4Q5CH&8c_Q@_MzEtqD?n{OKn!H+!8=3o*0`N7`uv zl%3o;M5v0y{wj$Z12Det?T9?zS7}fKqNn|IH==smoW|l(pb!mEm(aVOsExb_#BJgTZaqGsZ z=D+w96<2UbaPJ`oq*EqzmfU4pdD6trX5eJ#JTXq_H`?=I(0e|!NN8zkZ7Ha#5<;2* z55$_#W?5AgX_oNIkUgmnAiqJ=3S&#lK)G+ez$~6sr&$Hg_~@P$c4Bm6b8|_+fpC#xfD#;7v;eYaM+f8*7&>?%T{Rzv zoE-gsa%&NuoSXpmZ>O?iddG6vv3;7u5?F13eGd3^`6H%{xmtl&tg(?oqmU4gBRWum z1-(!e4H(PHCTBj($bG{Ftg~swl*vrwS#{{NQJKSU4-XF^dwy-R89;{Q=I3u6^Vqjr z0NN%JT#Y|MlQlsgLNh~AG;2r{_`{i)PyzoBm@X%>s^vt7&u+j~7S=J$3P!Q40xmzi z^YL}oQ2ABC=-60gXW^SX>-_~Wz;T5?5+DQw5=Do$rsniUq6z@ck&(N{>Tx|QJHlqE zojU`3nfcWXLei2iUO0p%_1H-&suH&TEXx?(a2J*aG?ajlkw7Y~pjayqONKss(!|J= z07_)Wb*oU@a(VkVBQUPBu&@By?E?!pa6q;qW9UFw9|vG5XvTpYCVU>9T9NdUAyB`> za+d$Cni?%&{Lkjxisywe+`DmP&)45qO#(v>zN?lELUzlX(lD*?9RKGWnxR>qA+*uE&g! zh3ktW=wDd1s+(6Wg;r(;w4tavbLRlqdjVBaZ9~Hcisbq;KCW*Fv)bh`^iE~Oljg+N&2a5O_5#2& zVDL?w^*)VISClrz42!Uk)2nS z`xPmte}%`sjku)OL)z44P@A>;e>Vhec?^H1ZM>q>6=r~ve}8)L2&(b*4WwT)3p<4SlW3dIVN^)%f8Z}-VhZ=3{f`93fhSlR^Bzk-%W!ZB0y^Uu zggmi8JXHYKDRwkj7Toho43H;gS9$sQ62N3>wqpnLa4TNEtyD80bwP>Fm4z^4+|58Pz<9r6tf zjm`aEDcIJ3NF=a~!>K{rc5cxtxc7V(mzhjWG@3VR7JBVl$ejYuodDu^f@{j7) z0P+5R;U8=@!Nu??!&)H9FTIpJCLi7o6U%O!cH&h3>f}70Sn^Fc57kKtq18kDu#!0KlhZuCo{U}_B=&98G$sU$~fkp|rzfH($DL%nUs zs_8iUO>Q{iV^jp-Q{D0-f=~e(>cj*7Vb^yOSTC`gV&IN+5XtL$Z}z^m!ol+QL_eklJ0E7d2jHVnwhBsv&j8!1oW_)-GGUMYn8S= z=A5SiYihj%F7y}kx_ox$^xn5~EUycW2=N0b%I+iM)@v?Mx2$@+(ca_spN_6RBib<| zLpf9^M`aATw73ww`&4}Ys0(Rv`88{FIt8mW>nYE5bYD1(3S02uhJplh;*4MpP;Wga z(|>UR1{OLt2B()2&YVAfQ`VZMfw26_*_h#bYd(z_ zsIO%yJsgtIA+zqxx%YYS;q5#O3ZDamfpbS`ub#P2_W{&H{W-MM7g`gga9#!K%cqSv zV5cjEgLB6ki0>JK4op;6W|o$i-JY;0_nW+t4?r%nmJ<}5@NFSZu(1)>b!H~-$d9OF4HQpa4-cIrWDro?~^FjsT z72;2AhX>p)Pw9-X-V+n;t`QcawQDN?j+abg61FopDfrxhvHn90UDk&<2rwwru1y^R z^CQ%!=p|v^H5L_iFmYX!L!1ew^e|+72oA8{h&<2o!LA_e=pLL12CfEw_;pTonS|xk z`=&haHThJa%zpp?kDbX7B%a=4;zw`)ca(nr7=Q_-!y*f0zwe4!+frIH(tq{Y=}JP3$j8VY z3vgEH!~)3G-Egt$y~FnS@ngL!RQ!j)uKu++)6xVe_h7|qTz_Xc$GuVf2gr$LcUBx@ zUYC@>BvCW-`JYpDQ@CSYYK_)$0_w~9_Zrns02vk00-f*NQ5nTk_YOr)wQ=Jh9T6n% z*mx_0J2kUXha;(~myvMCD>BByz!hPAp5mlua}Z7<%b+8VL}%~Mxf8_mu+XP|rR~;4 z(lIc+U%q5hf*wM`&$0fy9R{m}^u+aTe46OSz;s4IQG3?WT1~z5--sw|oUaNmKRjmL zo(Z`H=4-m)3%_4^NzY4s-0wv5i6xe71oE;1}qr^U= zr_i>l{q3>=8ZNFs#f~ODF@x??gjq${dn7JC>RB)l|Bg;X^E7dF$sV%%lw`dt`O}Sc z@cw50I>I0M`!o8niN=p$^IISgWXuI(uZ*W`+kkxs$~~`>L6#e<;={l`$5>_Yy;fU&m#Y~!GPBg+kobM?q6T7MJ$-w^ON&`ln!L!er(Y$)13DeQ`nYXDi@820v zfreG&apW2|d4EdK-k{$tfrVi1^@k{H`-yTi=qzf5J1-oZC#!M?h4_@a_o=)%kkmkF z35wTlex>=>L~&n2%9XOP@tfhq01s=f>vLZjdEfD&0Qn3CWUkDC-JG<}QY?|}Lx))T zKe{C95mGXKe$*J4$)*O7wHCC8^{Hi>nFlTEP==(z0iIq`!m7)#A$b!&1hw;6c-@$Z zp5-C$+r9R=Ka;&*%5yzbYvRW!k-^q{Un8@XMMITrY&d?FmEllOL@H%TEiSb9`Ao1t znvFOsFqP2p#a(|-Dc^^?nkz*F6hFW9$uX zp5B~JTP-gJQFh*(%}3SO3qnm%F)=acXH9o~r$uZn?4sUvOs=QfB!f4=<_DkvpZz*E zNb9)|g}XnraMvOPBwzs{Eue0Ohu^&V%C+Go@JWutWSH*y_G&MslIP1S&~&roc^weO zOiO)=QRm0a@YS6>YYp)2_t#O5gy>(Q*MeRP+tTJ#y*f|~R5&ysRG>Oy40nU74cm&$2qo#(`po0}J7x4W% z{klCrXgO*#C@nV|H!2eYpj(eU9e5xBwi`6&0yalrDm1NPX=9HDF#O9a=nEQ!Qi_V$ zpX45+Bl>mOY@J*>+6YuuXcVR_jhR-+FIWEm1j;{8!h*}HAMM!?RP`B|RTa`&rpS+z zI=y>G3;;g=4aOrIGIYeKWzf7Nzx=spMSfWoPy)tWAe>OMhKvj{uzAdO?rbMI*bk@| zs)C5oh=?>j{~v*Jn1HY_#hW~Q)PU9xGZrFd|F$c2cX$79?Mg-cI2Lzv=Bc#cwBzsS zJG1i_dWI(yh_!cEMo31sb)2~49JvpV(#9Sxt$7LhN2P&d1&BJ(=C`Wb+YPXVc4zBs z7Mgt=r;}dMCr-^gRn8yn{{34VP!R?m$?I>6-@-y@W($~PV*$Z_ygo#$cZCLD&Uerf z)c$lF35%lrABm`Lw-ya#aR01h^5HVaBWh_n;49njg*K40vi9$|B##DJfzC>7e0=}Q z%S*?mV!#(njS~|R2C@^qpYouNe$g#v4M{V(KUWqcdfE!L#Uvu9Xe858qC_y+(Tm*n zgmQ}G>j1~q(`fLet?<+`)c5{2nO{#lf4>%%^)pB~*w^C`PBS)0fS%c33S-Lz{djGG7;R_sUh^i3 zaGLM|0X6Q%VHitGOR%*~qM#`{+M{+8=ml}lBq?`GNh5--ON~Ie;jP8P`mO{rIywqg zFR%3iL@I)mAb1w`eBPe$Y2?Vd|KVXe3Mjv1bA^k5-0(wLEm(Nw{c~b1)+V3|IHx4) z;F9COfOgAS1C$Bj0N$M-z>(wrhywMV|K=ZpOsp!vk5m$)nDSE<*<4d=zdn^F6_dYO zM4UqF8Vsrb-k8A-`{ztfsE$@7TCL%wG; zqO)OtqQ0ZmUBhs5KSwZ6Gb-bIs8?+68xVk~xTt7C!us0~*q0|RriCS?Uu0UJTSJ8* ze0+RoU@j5u#c9I%8nuk(o*2d^c0ahqgu7_aL+icsY^vj!L>$OqtnPB$maU;G`7yC6 zVZO2>)&qS`w1S*JPaJ@y8`Qb)DmNvx7)5+$<{H>}Mdp{|H9N=;$TBVxpOZrFwzO># zL44{qpcV&GJUC@Yarj$&N0i}Je3`VsK*ZkqWqI?t3ZkPq4Zpfnolv0PPZQ8UkfXxe zn|))dw>G{xXF5N6rG#?6GCr^8z+q;q*?xa@^fVDlo`g?S*=;scg!hLljOgKD2J2mV zHqMg9*=~B@6GdUrrd)lL$_GW604y#YT(#2B9E|6rO8`ERGBgdPDIw)bXuCZThl5ow|DO+e-Cv@mX!kr#JV( z90<^^OXs)iT;T>jUqNYOJQ`O(ef#Wxsner|Ht7J+>sb*O8xsORddmz#QIlc_^wx)^ znuWaAW175IC*!PDc%ZQbQSZ_O#+){@7>6$l`Isia%|X z6Q=LSf5@TLi0Z^spqb1#`Vt)jBX_P8PDnaZ5_nFwu3pN^8+lOr0Q&Et$px?w8;2(~ z;}McF#*E^i5a37}S_^gWb^G9wBZgs7vynkl;B3kYb#5Fxfc8Tv8R4%zFX*TG;-RF6 z&4<6|ex`88ec3fNd=C%?h?);=Fa9s8DY(i2gmd27N;}vc(;hT?e*WReHDNmFg(OXl z6D#ONO>Sge4W38-)n!u@ZlfFy5O9=%z021lQG%>)NaPH(GWsvc{NPkIjD+3~W~g*G z!I~9FQveRzU4BiZ^`xbvEDTbU0#v~Z4ph)o{SHvy-x#KyFSUZB0jhbO_A^Tw`czfZ z4L9vtf-YNhg1nBz*>9e7zN(wtaRb)#tmAH+oZ^Q$KnV+zaJMY#LDGTw2}MaT2}O~x z4j&Awrs003i2{6+B&?A#XF>F6zsa(;qA5+(R*L?%?pmd44<#zxUvRnNSl?Uz1qFtM^q>FA_NAKAFboql=(?fXlFkYnSpj)fC3L7$X1#`UZe6&15n zMcJ}FX5RB4-ve9;+MrQ@#}@W)2TKqpj#=4Rwk$Eb+2?TvR9#~e6a9xd<@RvDXtguA zv+7=t+futSh&+!_bT6-{VDvcC=1S&Wbkqd6&%oG86Bs3phh@EF)4 zM9Qd;urAG$^oc}}!8&bJNp11vrK^~b#SY4UQ@LBZS*z5`j?xt@)BvPe++UQ}#j|U- z@?RPO-&_Hq5+1*a=Lay!aimmC<}H3N3yb2-1;f@AZY#_hFdCI^*!3KfDS_@h)hX5+ z7XiyJo>?Q~_j%wPDA-iIf-^hMq`Y|_MJoV3r~PaP?d0O9q)f2=jm(IrGb-3Om-vK) z;z}woaV;0QJh~j4C&sx&qGDn`BjeCqMSTuz34nfSU-|27@0D`@To7#V4HxEwXyCvG zMBC%X0S}y5r3YwCz|s`ac~Rb1NJ+F@dT~_et_=;2Z&XnwAz=z85b<2fg=BMfRl-@% zeE2UVCo?&`^M50v0)xWo7tmJt^~XTQQv1Dl{h!toAYMaf4BOq8mL}Tk?nZ`EXew(5 zurT=se{)aGiLxdp-lK1Wj*kWXrK)vj$vsn%}dI~#U)`n6q>5`4^BP#?|DzqtsRSmB?Z3YW9upd z0}Sra2A?LWFA%ia=-pmloI8$4T{qt1eJXGtSr)^f_oH~sJl}p>JZ7+VHf?hHSmT}!vVZ|<}@(<@LamR7ky`2=W`-9+Blu7c8wNT$4xN*%VN?^)94sGO59fb!CzN+UIP z6F0{r)k|z%Ed)-p9lwQvF*@^I<0UFrIbY1_#t-a!x&zuV3Ve45eU*Fr7iwGSqZ>fd zZkgBt%#tKn@Xkf+Rw;p2FTcI1LXZXPWw9>m@bFmVmxQ2hO5#0LjRMS^puZdH14I3M z8nbRRh|mc4%BIYaPIu)x=ah=H$+X~$HXDWU>2?k)+8H}IPRE5e}t zDmqYJnXiD__Yka@!-Gofi?A#z3{tM#5uy7l3_!yT=<%4rCK2DZRzXinFsEwa5Gs>> zJ<#x<3pxS5d1So0S_IHLW*^0#+ICPy3>R$L^KIZ&_USV|{tkl)K) z>L$9#V85Y(_TEctm0)sSW*H=`OYRFMU7VMH$ z6cMF6r9)r?BBFpGBA`Qw2pdUhDFFcy>F#b2_^t&r^UO2v_a4Xh%`x-G5R0|fy4St# z`@GKccb(LBbo|sb85I!Z+KhiZp(k8U!aAU^@W<>X$kWejL#(K%sE!Cr+auLj#j!71 zQ6i$E{b+hVt{O7|A%qYEc2h;zYZ_L>!Oh`Ug==+>@755wx+rb*a=A+!QLL|98uVDp z!8isxpl(Bt><&21v=UYa-#5TIK*<9_<*#4<{+bOj+CZiIL+>NyUNW-u#aql)8UtHP zWfW3`q@?rqKd}_k-|F>WgE_^CfSltDXLV}rnzZ8g&;rx7nao_%<`=>t;o-`jo)`2= zmcx=b)nJUut8C%&ts86crYI!DWS|w#Dt|f6eY=&NC}P3A8UN|?#!CQPYKIb7@_;5B zr~=dTs1Cw(!ZrgJ3Y~`~FiqcetCUOwg=BKEZ4+amkx|5yOLE-0#M&-!ts_#yzIB^& zh1@AIa-TkZVY!By-gEoqDKu3wKx}H1I}J1l&jx(34n=%+p~a%IlERF2Ju@9> z+re27w2S2POkj@qwH^-D;Z94Z5>|0oj3ZPT#+M-6liR?S+|+6r$`|_Egn1OlUcCx1 zj4JLBu0ILZZ{wQlnhGHM2@89;*U;FAV>_*V?Wm@WUgiT%h?M5vN*~A#=w$zJ?$rehNLQCaZnsjJHk1H~APcbEZ5Wnm^q7U;*<-d)Jb@HA zCO`rKdq2EL5i}V%EujQo;>-}_d*zIO!(B!ALZ{_VvqB6=+goS*1H z!U8JRv|(xeh5uI)39e7AsR9(KVYY~mk1u^v^7%6Z)VIOnZ=V0T34kE?X6Y#J+sO=! z@>(af{5ZP_!x#`L*>>DqT%bMXbPO)R?fO~l#0Tq@oT3rpD=$UxkNa2N>jPTXgFgGu zeGecn@aB3SY`dYR&Yb_v-X_0?E%p+esP4f4W5jVNn|EYrs(E{(VF&8{dsz*3kx)Ya zN4eW6{6Aax(V~YBAIh-g!wEtvfBtg!@Z(jzJha$IKA;ut`>pHKuO=XY38=zn%(8EX!RY-68CFy)dN*tE zVZURr8RoJxdS4QF3Bn5z5}t-!t<>B{=r`=7U2fgD?Dkkp(PH_ozI9AY%;?pac|sBr zr5ALfbx#5E^``t2P5aB2Xi}Rwb*R<(zS{!Xki{#&Fa*^twQ^UMt6X7Mxc>A^JR`Ee z08>lFt%s+l$8Jg(Kxytc8S~Ui@M0!|bn53fI28w@tdbR8GSM4=vx|1#0yLMEmj#cq z&n`33{3)uIdgkuVxnKAwp#2QwI;%_PIk!$hG~&-9ISSsFscMl({T!SbkLJJMr`p+6 zgB82LLXXnev7ZpB!O>bK`IX3w{zSv^B_E9BZ0+vNcBIuph-C->XKW3v?cbof&8LbK z$b-mPF=?0MApc{!wKPnR_LL-JMK}Kt@KS}s7Py`06XM%!=UxoX=gAUBkytorXLj8< z$?;{CnJA5|-L-l7*iAPnD0hkEmi@>TkcQ>w!&-~^Uj@eW=^d@Ea)Km1Rd%0eiagf| z1QtBL^o@*Q@I}O(_j8yp+cvY3JS@5`DeKF*s$Mwqf{HKJ=5O0W zZd5i_bEm0%g%zzY+#1Xuien^-Jn9@{4X_!O@ZMm6;_7FzG!|1~OQmlS5*+t;93l8r z@k=;E&1DuV`&2RspI95Ixl8YZ2WR}D4xg-TiGH@#@X2~8p4*hD4eH7AG zw)en+f|oi>py)CqsbeztGY`2w70k%=^%)&1ghd>~DDyuN9HPLluLkl=R%wsUkw?hM%GVwTf`k810( z2TfKYB!=tsY^uWq%MfWc*%Vv@`a54Y=9OUG4h~w*UI7K}T=RJ{v7~PsjmI<4do;kw z8U5q*kUh9? zyx+rSc4aK-%{3lKHdblh?PCKOCS!~8NuF@J4iVVvf5WO^M{O~$q|-`Pv7^o?BVZ|J?KxxFw-@<{q(nzp_}%lR$0)?8Q9 z+WM~ztRvTD{t*P2b#AZ(^GRSU5DOK%H0AQdYoY7}j@OixlEtMxugw(jV?+*`ZJM;< z4i4wX2jhFhOf z(V+K>dgr%4zTv#MH8HkW?G<9-<)K0OctCtDXAErw22QsXZ12W?M_}) zHN9a%8LhtXDhw(hAd)${gDC+2yIQ%6Abkl7u1+a0Vi>Reii;Sopn`qKYq6QQg5Ar7 zw|A$k>^E+1D!xxE4Z$>zq^x?1N%3RS-9F8~aryDO_2l*`tbyc%Uuhe!XgJ|Ys3(_W z2cB$s?;*veLqoQ6huhcJ9*Zg?Uwn-piDP@euN|}hLdUIrIdHW$nCp>LEQ{>SP2c69 zIp1v_S^FBKB+L_qhMH`{_O^1yzFm+c3k3lJLbS6f9d4fq`E=C`XQ?{$20PByuo7co z;wR~{`c~>cm5{eTSYz6U>~|qJW8vhUk=y<}Renn>SuRqx6&?2c58fY}_&{p9Ct(1E zB_ME^tPb&VfkT@LSc;InQfC%=-DJb^{{8!7m~gKmXO24|s0FW|KK$IFovVvk1I`_v zk=i?c)g<^Yt|dthcO=G_%R}w`wyKJXiloyF4H=7cjT6r+$<6idpJlGXGfj=feRciS zo{9mNxxWVzQaFr}K1%+1Si#Sm@30-go4I^BTFRPvS!$}-?=7ezl*tW66O0Ie0{+C5 zrEZ0MYbqTf&^S^lj+tUg*`;}@RLfR##2&+TKLlexfUmBHfrtS8aqjM%KrtRd1f=zF zcbX7+PTVfu)uysf*Hi<(695<^JFLRbT`j9TtuL8(GXy29HgN68ukZTFs@_Ajesd90 zA+W0itMUTSVeD(tHRpYCuf3A;2NN;!@;*AQ;ehCf46b>LA|Td=YJL@{Go2@{WJyvq z_rN2e!}}H5qKFqFwGyP4J{SU7DMauT!0LfY()|~AqXoIeAy<=OEf1(s<1Z{O9}3A4 z##e5*CmT@C+`#Ab6(Cs{XR3)Z0ez@X9V{sU zQtIQ;0mGsr#{fF%jqMMbdw*S~EFGeyAt44h<1`_MC;9we{(bN6>nGZubBcRju{;kd z3CwZ=7}wneHYD)C8m*p&xYt7LKYqAj>QnIw&rexR2v_$2Pkbeq)|(ly zPM=Awq)e+bKo*~aI)gceSOU2%+eBvARG7i8c`QBN8;7Mvb;wU&E68wi$Tvp|A{?bl z18$_mFYi+ycL^LZB}M-4;egS!yG*0K@~@1XPc#JtWNZT51Swit%Do%FdovYYq0U$J zk2%;T2eeUtvCm1Xju)soY5Zd3^gP+6q!^6$U07hf)HuoawlSvu!^Q!v*4E+!`8oc-dm-<{o*L zyvM7^J-`F;W6kxNKO~wO8YS`$G7-`Sr~Br@{Dv&tv*|k103-VmS`3W(O zPlXI*KQ|y75Gc<8;IL|zU}#R9$(dgl52#OdVCgf>&u3pZB|gb+jO`7Tv4f%N>{)cC zP2)6^gvt2KdG(oB{#6${qGW|whocWXe7gQk*k~P>eS?8_kv1CkWHNLD%6Bb!&*bP6 zJrHuyoN^r*n|9Mbry2kStC}2@Fp7>$y5s0QF%gkHz4(_lP%nL(kwN`1vPlsyYI7J@ zUn=(b^N|Fr!4yzbVMbAZs||HyNYH3WdP;&~XBJnDHBivQsBeS1fMCDeG0dT-a0#eF znq4l~A@o@E+NBWSR<0$>GEHJ_)ClisVsPaZ6>;(ki{OQOhqe4m>~#|pc923sv6qnp zK}y27t!$I(L6edCL`c#@<5NOQ7#X4WMf3{Kyh2d&nikd~Xc`WmMDk=P$E-bwEKWbJ z_9RS-sc!zn)5Fvz$OwcW`r6tGiHR`G-Qw$Jr{sJgUm<2hOetR%Yy)+fo!{gTp6u~h zOqmI0eO)#+*yjE;syP~X6|cvCyTM@nGJQiqv38It*OOP-=Kzhk1==ScBgj4Afwpb_ z&_h0Cnbv$p7dBz^$B&)Zz1?V@&zbwwb0LlnM}kGJ38o261unR zz!KjonRp?vHIQ&=K#dT&R{F5BQlz4(X{re1)<{aKujS*U1?MbHnw-m_b;wL4WfS~x-iI4)*8#iwT60mwwcPyPHTg|q|mv6=@&YhnUUH*>9!#e>H}lM2cpiw4(|HEYayssuK?J7^>@_+Y^P1` zAhfzEPRZbUHLxobMAWiQrmn0&k>44iR7yeueZXy!1+gbwetyp6VO(it4;CMS0N!^# z5kCGsYs9QPCYR)iOLd7IXKMEcY1?F{ftN;(UT`|_mLG3CLRdx2v5$eI@lZ4N>MQA! z*L$=4;$vd`MelzzhV-~jxu=v~$vFPE^g8meNHyiiQ}1?$34mj97=omDxmc92V5AQD z_eD;yRLo$|2+`8IiFWO4Q#HtpCw&fw432y2yR@|0@kSpo^H>%X_eWCL(PAOv5R#Fk zsH{zeDLp{nxc|)-IbLI68!_e3FEuCxclLY6^YPh%0|`@ z?NxPSEZ_21&Pd14EwHDiprh;FiE-Md>iRn`kdw^*|EsOQ`sc{u|G$O>to{`iFe%W5 ztTm_=ziL@%>4N(LNqj#D&FxNc@(3C}iuf<31u_EG*pi4|N}Kwk^NgfGjBWe*^XFOX zq3?7=K+%AjgGGU^*!lAcY1+eU(;bLt0;XI;!&Bz1N&PeJ;tP97!kJOCH+ z{dvb`UK(QcwD13LKmf26`GOsGFUuh7J>n9!rxj4P1!=Qr+Z6)}pi}&A;UuBvWbUfF z#inTM1%HVF*XnWc49(=aFXCn+_e2;Xv)Kx2PQS`rr6so?q1q4N0TkEvF zQ#8xZg*`gNr(M?arq&4`V?><&n5?>cWNoaFF%geetJg=2y|zhMEW;2=0GxSJ@FJy(dF zs9Bv=-VzYpsCyxteK4qP5Ftta++^Zup@yQMvJZsP8DVB1NN!v#uYP~9PY2+raQ)I3 z{)f+mP9~Yi%M+vZk!rbyWgk4Z?IGBTfQFr%oF2_Tm4IR3Ryq%F(>#Y^#m-aGHKxk@ zqqc8SD2@$`tMh;XuLRTQ&{HDPK9=;t6HtEuR)s z1;7|^02}6QnBp zoFDn1f@+>%E70|X9aY+5DQ=n~`_DVOC4TU`(9QsqA#bb}z;ECEEIt`L=*{Gu5FvlV zaIUvO_RWhIfKvJ*&ShUC0VXtL#68Q_@eQlkf702ff5K~w(lX}>>Z24@$+Qm8v9|i_ zfEp5@l~z@2^f*wNw-yLu3RFHOIR>P+*oJGk!N&*P7>dcOg(N@PpZ>#TPQI0rQ)sDt z7iWcmjEn1#l))w}uLU?cN=;dOSOoyxG71krWht*-NA`t5MEvOT>&vjRK-w`(p>;q1 z{(e%+gk4p3_nfX6V~!FBSp#$X&2%?Kz}G}O!%BmGd#wZ60?;`xC<79i@Sd${^=Y~cKqx;*M`UQ81z%PNfIC=tz{{T7zsE(h%AxwH-V06kdG_-!FPj#o~k0-_O72{pyM8*XG>yvhAqHZ5gKS3+?@nKC%jz=yCqK1n4@(0++iccc#p~-*Cs>{Z z1N9n9{^{d%4gS&RYN)pq86IM?dA-oa{x*U+G#aMR*HKZygPrf%!s2rbJc{{89>HWPqZk_tWCSlcb63%#A3a8l~GX%a(J3qTlC3Z1(owR zmOfo=IRuo(T=PUw?Kj59r{o;Y0Kl}gNfEL9zY%rr{fZZ#vekQV zC+}A1hCLOmm;%{kr{uxmV0{t^E4%(mf9uJ3YLK#`O7A1T4m~aI7VH7pmLln?!dr@C z^6;SuuU=AWsJm)lU0rw`-1`WswiO)#)U_?{WFHfHb$`DL@oUdo(dCMV5Wz1SH~an+ zS_H7d76nBmuLYkZ>y2q8NXXs@IGK6dWxF^k=ZY*qVxS*N`HueY_1nKUey<{I?KZc( zByuB&j3UyI4rC5L{&0Tq;H>sXhki_aZ;2O){Yuc#7tLOiw&}h?Cg5d^4Sa^&o5PND zf`&eVtVyOG^aNZ`1I+UTfDj5$RvQJRq>`h1asA?C0yz<)>;ghjpVti#raUjiTIQW) zE|u40lhd&;HfsGW@d{J3qWe1skv^V$YzOJYvNdMbKAU&Yb=z(tGZ$%Fai(-noN8x% z^$(=~07wX4k6BKAnYh~cr$;2Nw=;2>6%Xi0VD#)g+#GWBM3X^Z`-2BXKnS-Tf1?6;?)CTl5C8-Z z7&t&cgjrcx5i+eI%u!kn-P&^f@yl7_A-2o3a>3@(huK-u7aslUG*=mL^DBK=PABhn zq5r#uE2 z?R@vqYT0`_`KxR|DG0eYi#6SpU@}=Zo{!G?b-yE&3?@eqdnObTx?~ilG{pm7U#CdC_ z!4EOMoy4LgE#NY-M53P*D+RYbtirqEx@k~z*PQA>!k@Iy8k|QOyIM!GjN|ss>cI|; zMBl1$#-QNf8Ydt&{F@3h8#R%+5VS4v*dsWs?}k=)=TP_khNM9gkUIX<^9+|C^;~--FZ;5HaTB(Zzn>-$n38VOZ-U_T9xE%%^C?0zjA5w7*QUX zKO)~Eo5GwNOhF+D2?@-KJ`M8DcQN2UP;$Ztyl-Opx4-5_Kn?&mi-+Og7SIi77cBJ&sZk!;wL{S1 zU!~h60qJ zOE6H4kcFNMtH+7r9rv+*FA0D#MoVnU0#NvA}zj-3Al6{eQ&WM5^2TZYRZvQG%K){Bo^9TACC)@lIsM#;q5|nvnQnSMjXev zqA#_>gmYzk2=|2B6g!mlUac0U;AF+Ysr9t9Er;skV|}iiI|KG4RU;!mi95`S9WYxMK>L2SDOaeqsi?GH z=BT2k7T)qDh;ko~)eoMa`!trpkC0YsaBgMB0#R)=vtw#98Ci%UU_j)F05nC} zmK}&W!(Q+qs>`MZ+Nq3=J>K^R0~H?hVGxmG=((Q30lg`pc#Q4U;6X#|@8~&R_URXe z;OZ7czl6-2UkH#u+dE5%)1R*eEB@%}(vo23(9;0KO)A(|-wE<5A>n$MV?dub9_!HL zT#bJ?w3Vamt|2I_<_j4cu%9V(BtL$$*dl?8g0>UUBA${+wOcl~l#1&8Y6-sQ+A;yG z6rgR9-2FAk7O}v_5j+$Gz#Z`6*DG}VJpZ?{6&s6>5@L)1Arg?I*_yu_?}~ts2wD)L z8A(WhJqWO!h=2gCh58Et;0!`S+w3~*VgbGDr;(uqCZ2P&mrJay#F^mgo;Z%n$i&qC z{W}gIEMT>SP+faPPT)drD5fC*z5u(mnwlE<_cy8zX`qI=E{xbN%Xu2?txP9IY`--y zC^!c&b`QlEkw!gvd0^&28AG7R$cP-;C_$Picf=Y99k((A6;phA9xbd%e9A~O*&3;( z-~J{uwF!ll#wI0jCNYAL=K@2H8j2a;_LtRWJ@jj>@_VGm%fXi<{VM1DLO=i>SfHGC zn50L5RsbfU-(?Z1oRB$!ngUp|W(NV2f!%-ATbF%}M;c*V%!*sP)Mwq`wW7nSrY`eZ z17B_lqAF7I_{nr$-K3~lYK7uhZ_1AeuNgxGfdV2^c`+|q*w~n) z0n^?{Z5RZMs>;1~h1}OHu)9<5a%i_TnfnsWE^m*?KP(Pmj$n=c` zsy~og7Ull2z;K$Io3p2$QWHb))BcQG$1G?*| zp9NWdcO>kCMFBLlke`XY%>j++l+)9Tf71#;uQR}lA`e^taP>Pu{=hC{6zXm`ORsJ? zf*y>9MheNr@#%2L_GSL!U!jl>T5YD|Bg8wgmMC zrtvX$C27*yGo(h5Qbw?ZWt*+h6=uBPD&)M-*KljKG^jq)ek+_JvC7me8u+Fn^%$#7 zkCL8nK!YLR6lfxZLMc3W@ReDNFfdZ~_|SLD6m~7GQeB2lz0!v0esllybfjk=JCr2> z<%y~`-W;igDhA32niAeMdMu0F!F#^!1D^y#Sv6FkpB@xo!)eKz)5F&Oda@K%rW=oAz-0j$Fz;*JcoG zOH6bd4b)D+Ji@=wyO5LjG(P-)?_vr0^QIWL!h0(-T_Fw40l$)tMN8})cBF=bpyewMrJ zqchhZrr0|A2P&%+(5AxRwHw>plzk$6Z_5@!)m7Gc@({fR-9W`w84rR4h`J5v-J{M0 z9N_9c0Yd-XyXL?Ip=57wf2B$hL^MUh(pH9%N&yw9RD_Dsgy^v85Me??XR&h(M7_cO zv+r_#OegUQuT`1u)8ho3k{mdBFuK5~exlkZuNObRv@k?n4^&^W>6{L4!ch4w(3S-F z3kJ-m9^SS8*p15p7lrE-4bs8vD9HUvpyuQ%P z7isT!7-K(dlN)@bG}*eXs2?F8{&^5}uSAjL)+2VtpIv&}H@kI1_le|Bo|))X8Drz>)*g*@j|2`y$;;{DU zBprQwNCP|k%&sHI(q0|v3;%8(92$o^7;erzpx&bf1HtKB6DuZ zY{00KM1O4zX@cSO!Z^WX zEF2Vfx?z!W_ujpf^z@+hU47prki!4$E4)mOmJ)`>va0@TYmRZ7ajrdAC*^r#i?iMi zP=O=rgD*v1US8HBUlh`S`A~fgMVD6sM8kY@;TA4E;Qql`&vW>V!=}n$pkuqjZRpZL zM43XS=5mzIKe~;du49Wui1DDpq*Ls8_HfG-NdJ$I_#MRW=%c36A-Dh6e$yef5R6n- z4sd{N0*Az)rzkY?3j~1}l$_xtj|E?Sfl29e$?yO8qspHg9fJ1Sf!548pFe*_{wBOp zx>L5BGjq$I2%FWhiHK-_;IpFzVkvO8fGb9S-jasw)P=83>S#!b{2MXnr0$*fs7D03 zsKB~czj(1S8w_1g{4Y4ouIWGCTZg;{Y?`RS=ng$kR6sK9PN6W_T<8b%an=6b?sA8w95l!?ZhUqY zUas(yqAy>*blqF;)&iqvbujs8@oY{tKeo>=hFf!|TZofNsPIuw6}nug8Z=bi3;#mYra4p%<851XihZ|Fm+`g6!tv20%q!bxF}?)9JnZRNGv03mq5iz zlreOoY;odIzNoHljNX~ztoTKd8;%2OysG=qO%A#ETwDROwjr>t_JcmFIn`6oVUhmq z`E%3nZ!cz7#9T^4nuvHG?75I}QcpjDawqEbgs545&(b*HrH_{fo##>Hksowq?15Rfgrcc|QAjqzNyyD0r{`_39K@11{`ujTY_9 zWJV_^w>n9B+x=e~qTWsb8`0DINp8ofLTkn~AsRKi5k@Ci2Nz;#G*XU(hldb>wDqc_ z<3wOJP@v2RInCT?hOiR3+;1VZXqkFCR!Yjkvv6BNskxC40HoVNq%9=0AgJ}lB@lv) zM)ONif((sUE16dT$tL)~!U9^QrZ!pG+uKtxGX9$0#MI(!&i77kp60(;>h!c}oL6;o3VYFgSl&?H5n!R|c)!_u!#7U)&DUP7qEeZp8cH_7#F7!XBd zEoQfJ^c&V@y26M^N$;s*m>cE>E0SlUJSXCeyzSy6;YV#*;x!%D-H>Mye*XR`=nR#fMwR8V3v+utSlXyUI)j(yU@-$U63jVpMqM@9lXzcfY01v{>>Hq)$ literal 0 HcmV?d00001 diff --git a/docs/_modules/ect/ect_graph.html b/docs/_modules/ect/ect_graph.html index 7823890..c855076 100644 --- a/docs/_modules/ect/ect_graph.html +++ b/docs/_modules/ect/ect_graph.html @@ -1,23 +1,25 @@ - - ect.ect_graph — ect 0.1.5 documentation - - + + - - - - - - + + + + + + + + @@ -54,12 +56,10 @@
  • 2. Modules