|
22 | 22 | import numpy as np |
23 | 23 | from grid.basegrid import Grid |
24 | 24 | import itertools |
25 | | -from numbers import Number |
| 25 | +from itertools import islice |
26 | 26 |
|
27 | 27 |
|
28 | | -class Ngrid(Grid): |
| 28 | +class MultiDomainGrid(Grid): |
29 | 29 | r""" |
30 | | - Grid class for integration of N argument functions. |
| 30 | + Grid class for integrating functions of multiple variables, each defined on a different grid. |
31 | 31 |
|
32 | | - This class is used for integrating functions of N arguments. |
| 32 | + This class facilitates the numerical integration of functions with :math:`N` arguments |
| 33 | + over a corresponding :math:`N`-dimensional domain using grid-based methods. |
| 34 | +
|
| 35 | + .. math:: |
| 36 | + \int \cdots \int f(x_1, x_2, \ldots, x_N) \, dx_1 dx_2 \cdots dx_N |
| 37 | +
|
| 38 | + The function to integrate must accept arguments :math:`\{x_i\}` that correspond to |
| 39 | + the dimensions (point-wise) of the respective grids. Specifically: |
| 40 | +
|
| 41 | + - Each argument :math:`x_i` corresponds to a different grid. |
| 42 | + - The dimensionality of each argument must match the dimensionality of a point of its |
| 43 | + associated grid. |
| 44 | +
|
| 45 | + For example: |
| 46 | +
|
| 47 | + - For a function of the form :code:`f([x1, y1, z1], [x2, y2]) -> float`, |
| 48 | + the first argument corresponds to a 3-dimensional grid, and the second argument |
| 49 | + corresponds to a 2-dimensional grid. |
33 | 50 |
|
34 | | - ..math:: |
35 | | - \idotsint f(x_1, x_2, ..., x_N) dx_1 dx_2 ... dx_N |
36 | 51 |
|
37 | 52 | The function to integrate must have all arguments :math:`\{x_i\}` with the same dimension as the |
38 | 53 | points of the corresponding grids (i.e. each of the arguments corresponds to a different grid). |
39 | 54 | For example for a function of the form f((x1,y1,z1), (x2,y2)) -> float the first argument must |
40 | 55 | be described by a 3D grid and the second argument by a 2D grid. |
41 | 56 | """ |
42 | 57 |
|
43 | | - def __init__(self, grid_list=None, n=None, **kwargs): |
| 58 | + def __init__(self, grid_list=None, num_domains=None): |
44 | 59 | r""" |
45 | | - Initialize n particle grid. |
46 | | -
|
47 | | - At least one grid must be specified. If only one grid is specified, and a value for n bigger |
48 | | - than one is specified, the same grid will copied n times and one grid will used for each |
49 | | - particle. If more than one grid is specified, n will be ignored. In all cases, The function |
50 | | - to integrate must be a function with all arguments with the same dimension as the grid |
51 | | - points and must depend on a number of particles equal to the number of grids. For example, a |
52 | | - function of the form |
53 | | - f((x1,y1,z1), (x2,y2,z2), ..., (xn, yn, zn)) -> float where (xi, yi, zi) are the coordinates |
54 | | - of the i-th particle and n is the number of particles. |
| 60 | + Initialize the MultiDomainGrid grid. |
55 | 61 |
|
56 | 62 | Parameters |
57 | 63 | ---------- |
58 | 64 | grid_list : list of Grid |
59 | | - List of grids, one Grid for each particle. |
60 | | - n : int |
61 | | - Number of particles. |
62 | | - """ |
63 | | - # check that grid_list is defined |
64 | | - if grid_list is None: |
65 | | - raise ValueError("The list must be specified") |
| 65 | + A list of Grid objects, where each Grid corresponds to a separate argument |
| 66 | + (integration domain) of the function to be integrated. |
| 67 | +
|
| 68 | + - At least one grid must be specified. |
| 69 | + - The number of elements in `grid_list` should match the number of arguments |
| 70 | + in the target function. |
| 71 | +
|
| 72 | + num_domains : int, optional |
| 73 | + The number of integration domains. |
| 74 | +
|
| 75 | + - This parameter is optional and can only be specified when `grid_list` contains |
| 76 | + exactly one grid. |
| 77 | + - It must be a positive integer greater than 1. |
| 78 | + - If specified, the function to integrate is considered to have `num_domains` arguments, |
| 79 | + all defined over the same grid (i.e., the same set of points is used for each |
| 80 | + argument). |
| 81 | +
|
| 82 | + Returns |
| 83 | + ------- |
| 84 | + MultiDomainGrid |
| 85 | + A MultiDomainGrid object. |
66 | 86 |
|
67 | | - # check that grid_list is not empty |
| 87 | + """ |
| 88 | + if not isinstance(grid_list, list): |
| 89 | + raise ValueError("The grid list must be defined") |
68 | 90 | if len(grid_list) == 0: |
69 | 91 | raise ValueError("The list must contain at least one grid") |
70 | | - |
71 | | - # check that grid_list contains only Grid objects |
72 | 92 | if not all(isinstance(grid, Grid) for grid in grid_list): |
73 | | - raise ValueError("The Grid list must contain only Grid objects") |
74 | | - |
75 | | - if n is not None: |
76 | | - # check that n is non negative |
77 | | - if n < 0: |
78 | | - raise ValueError("n must be non negative") |
79 | | - # check that for n > 1, the number of grids is equal to n or 1 |
80 | | - if len(grid_list) > 1 and len(grid_list) != n: |
81 | | - raise ValueError( |
82 | | - "Conflicting values for n and the number of grids. \n" |
83 | | - "If n is specified, the number of grids must be equal to n or 1." |
84 | | - ) |
| 93 | + raise ValueError("Invalid grid list. The list must contain only Grid objects") |
| 94 | + if num_domains is not None: |
| 95 | + if len(grid_list) != 1: |
| 96 | + raise ValueError("The number of grids must be equal to 1 if grids_num is specified") |
| 97 | + if not isinstance(num_domains, int) or num_domains < 1: |
| 98 | + raise ValueError("grids_num must be a positive integer bigger than 1") |
85 | 99 |
|
86 | 100 | self.grid_list = grid_list |
87 | | - self.n = n |
| 101 | + self._num_domains = num_domains |
88 | 102 |
|
89 | | - def integrate(self, callable, **call_kwargs): |
90 | | - r""" |
91 | | - Integrate callable on the N particle grid. |
| 103 | + @property |
| 104 | + def num_domains(self): |
| 105 | + """int: The number of integration domains.""" |
| 106 | + return self._num_domains if self._num_domains is not None else len(self.grid_list) |
92 | 107 |
|
93 | | - Parameters |
94 | | - ---------- |
95 | | - callable : callable |
96 | | - Callable to integrate. It must take a list of arguments (one for each particle) with |
97 | | - the same dimension as the grid points and return a float (e.g. a function of the form |
98 | | - f([x1,y1,z1], [x2,y2,z2]) -> float). |
99 | | - call_kwargs : dict |
100 | | - Keyword arguments that will be passed to callable. |
| 108 | + @property |
| 109 | + def size(self): |
| 110 | + """int: the total number of points on the grid.""" |
| 111 | + if len(self.grid_list) == 1 and self.num_domains is not None: |
| 112 | + return self.grid_list[0].size ** self.num_domains |
| 113 | + else: |
| 114 | + return np.prod([grid.size for grid in self.grid_list]) |
101 | 115 |
|
102 | | - Returns |
103 | | - ------- |
| 116 | + @property |
| 117 | + def weights(self): |
| 118 | + """ |
| 119 | + Generator yielding the combined weights of the multi-dimensional grid. |
| 120 | +
|
| 121 | + Because the multi-dimensional grid is formed combinatorially from multiple lower-dimensional |
| 122 | + grids, the combined weights are returned as a generator for efficiency. |
| 123 | +
|
| 124 | + For a MultiDomainGrid formed from two grids, [(x11, y11), (x12, y12) ... (x1n, y1n)] and |
| 125 | + [(x21, y21), (x22, y22) ... (x2m, y2m)], the combined weights are calculated as follows: |
| 126 | + For each combination of points (x1, y1) from the first grid and (x2, y2) from the second |
| 127 | + 2D grid, the combined weight `w` is: |
| 128 | + w = w1 * w2 |
| 129 | + where `w1` and `w2` are the weights from the individual grids corresponding to |
| 130 | + (x1, y1) and (x2, y2), respectively. |
| 131 | +
|
| 132 | + Yields |
| 133 | + ------ |
104 | 134 | float |
105 | | - Integral of callable. |
| 135 | + The product of the weights from each individual grid that make up a single |
| 136 | + point in the multi-dimensional grid. |
106 | 137 | """ |
107 | | - # check that grid_list is not empty |
108 | | - if len(self.grid_list) == 0: |
109 | | - raise ValueError("The list must contain at least one grid") |
110 | 138 |
|
111 | | - if len(self.grid_list) == 1 and self.n is not None and self.n > 1: |
112 | | - return self._n_integrate(self.grid_list * self.n, callable, **call_kwargs) |
| 139 | + if len(self.grid_list) == 1 and self.num_domains is not None: |
| 140 | + # Single grid repeated for multiple domains |
| 141 | + weight_combinations = itertools.product( |
| 142 | + self.grid_list[0].weights, repeat=self.num_domains |
| 143 | + ) |
113 | 144 | else: |
114 | | - return self._n_integrate(self.grid_list, callable, **call_kwargs) |
| 145 | + weight_combinations = itertools.product(*[grid.weights for grid in self.grid_list]) |
| 146 | + |
| 147 | + # Yield the product of weights for each combination |
| 148 | + return (np.prod(combination) for combination in weight_combinations) |
115 | 149 |
|
116 | | - def _n_integrate(self, grid_list, callable, **call_kwargs): |
| 150 | + @property |
| 151 | + def points(self): |
| 152 | + """Generator: Combined points of the multi-dimensional grid. |
| 153 | +
|
| 154 | + Due to the combinatorial nature of the grid, the points are returned as a generator. Each |
| 155 | + point is a tuple of the points of the individual grids. |
| 156 | +
|
| 157 | + For a MultiDomainGrid formed from two grids, [(x11, y11), (x12, y12) ... (x1n, y1n)] and |
| 158 | + [(x21, y21), (x22, y22) ... (x2m, y2m)], the combined points are calculated as follows: |
| 159 | + For each combination of points (x1, y1) from the first grid and (x2, y2) from the second |
| 160 | + 2D grid, the combined point is a tuple of (x1i, y1i), (x2j, y2j) where (x1, y1) and (x2, y2) |
| 161 | + are the points from the individual grids respectively. |
| 162 | + """ |
| 163 | + if len(self.grid_list) == 1 and self.num_domains is not None: |
| 164 | + # Single grid repeated for multiple domains |
| 165 | + points_combinations = itertools.product( |
| 166 | + self.grid_list[0].points, repeat=self.num_domains |
| 167 | + ) |
| 168 | + else: |
| 169 | + points_combinations = itertools.product(*[grid.points for grid in self.grid_list]) |
| 170 | + |
| 171 | + return points_combinations |
| 172 | + |
| 173 | + def integrate(self, integrand_function, non_vectorized=False, integration_chunk_size=6000): |
117 | 174 | r""" |
118 | | - Integrate callable on the space spanned domain union of the grids in grid_list. |
| 175 | + Integrate callable on the N particle grid. |
119 | 176 |
|
120 | 177 | Parameters |
121 | 178 | ---------- |
122 | | - grid_list : list of Grid |
123 | | - List of grids for each particle. |
124 | | - callable : callable |
125 | | - Callable to integrate. It must take a list of arguments (one for each particle) with |
126 | | - the same dimension as the grid points and return a float (e.g. a function of the form |
127 | | - f([x1,y1,z1], [x2,y2,z2]) -> float). |
128 | | - call_kwargs : dict |
129 | | - Keyword arguments for callable. |
| 179 | + integrand : callable |
| 180 | + Integrand function to integrate. It must take a list of arguments (one for each domain) |
| 181 | + with the same dimension as the grid points used for the corresponding domain and return |
| 182 | + a float (e.g. a function of the form f([x1,y1,z1], [x2,y2,z2]) -> float). |
| 183 | + integration_chunk_size : int, optional |
| 184 | + Number of points to integrate at once. This parameter can be used to control the |
| 185 | + memory usage of the integration. Default is 1000. |
| 186 | + non_vectorized : bool, optional |
| 187 | + Set to True if the integrand is not vectorized. Default is False. If True, the integrand |
| 188 | + will be called for each point of the grid separately without vectorization. This implies |
| 189 | + a slower integration. Use this option if the integrand is not vectorized. |
| 190 | + integration_chunk_size : int, optional |
| 191 | + Number of points to integrate at once. This parameter can be used to control the |
| 192 | + memory usage of the integration. Default is 6000. Values too large may cause memory |
| 193 | + issues and values too small may cause accuracy issues. |
130 | 194 |
|
131 | 195 | Returns |
132 | 196 | ------- |
133 | 197 | float |
134 | 198 | Integral of callable. |
135 | 199 | """ |
| 200 | + integral_value = 0.0 |
| 201 | + |
| 202 | + if non_vectorized: |
| 203 | + chunked_weights = _chunked_iterator(self.weights, integration_chunk_size) |
| 204 | + # elementwise evaluation of the integrand for each point |
| 205 | + values = (integrand_function(*point) for point in self.points) |
| 206 | + chunked_values = _chunked_iterator(values, integration_chunk_size) |
136 | 207 |
|
137 | | - # if there is only one grid, perform the integration using the integrate method of the Grid |
138 | | - if len(grid_list) == 1: |
139 | | - vals = callable(grid_list[0].points, **call_kwargs) |
140 | | - return grid_list[0].integrate(vals) |
| 208 | + # calculate the integral in chunks to mitigate accuracy loss of sequential summation |
| 209 | + for chunk_weights, chunk_values in zip(chunked_weights, chunked_values): |
| 210 | + weights_array = np.array(list(chunk_weights)) |
| 211 | + values_array = np.array(list(chunk_values)) |
| 212 | + integral_value += np.sum(values_array * weights_array) |
141 | 213 | else: |
142 | | - # The integration is performed by integrating the function over the last grid with all |
143 | | - # the other coordinates fixed for each possible combination of the other grids' points. |
144 | | - # |
145 | | - # Notes: |
146 | | - # ----- |
147 | | - # - The combination of the other grids' points is generated using a generator so that |
148 | | - # the memory usage is kept low. |
149 | | - # - The last grid is integrated using the integrate method of the Grid class. |
150 | | - |
151 | | - # generate all possible combinations for the first n-1 grids |
152 | | - data = itertools.product(*[zip(grid.points, grid.weights) for grid in grid_list[:-1]]) |
153 | | - |
154 | | - integral = 0.0 |
155 | | - for i in data: |
156 | | - # Add a dimension to the point (two if it is a number) |
157 | | - to_point = lambda x: np.array([[x]]) if isinstance(x, Number) else x[None, :] |
158 | | - # extract points (convert to array, add dim) and corresponding weights combinations |
159 | | - points_comb = (to_point(j[0]) for j in i) |
160 | | - weights_comb = np.array([j[1] for j in i]) |
161 | | - # define an auxiliar function that takes a single argument (a point of the last |
162 | | - # grid) but uses the other coordinates as fixed parameters i[0] and returns the |
163 | | - # value of the n particle function at that point (i.e. the value of the n particle |
164 | | - # function at the point defined by the last grid point and the other coordinates |
165 | | - # fixed by i[0]) |
166 | | - aux_func = lambda x: callable(*points_comb, x, **call_kwargs) |
167 | | - |
168 | | - # calculate the value of the n particle function at each point of the last grid |
169 | | - vals = aux_func(grid_list[-1].points).flatten() |
170 | | - |
171 | | - # Integrate the function over the last grid with all the other coordinates fixed. |
172 | | - # The result is multiplied by the product of the weights corresponding to the other |
173 | | - # grids' points (stored in i[1]). |
174 | | - # This is equivalent to integrating the n particle function over the coordinates of |
175 | | - # the last particle with the other coordinates fixed. |
176 | | - integral += grid_list[-1].integrate(vals) * np.prod(weights_comb) |
177 | | - return integral |
| 214 | + # trivial case of one domain and vectorized integrand (use the grid's integrate method) |
| 215 | + if self.num_domains == 1: |
| 216 | + values = integrand_function(self.grid_list[0].points) |
| 217 | + return self.grid_list[0].integrate(values) |
| 218 | + |
| 219 | + # find the possible combinations of arguments but the last one |
| 220 | + if len(self.grid_list) == 1: |
| 221 | + pre_weights_combinations = itertools.product( |
| 222 | + self.grid_list[0].weights, repeat=self.num_domains - 1 |
| 223 | + ) |
| 224 | + pre_points_combinations = itertools.product( |
| 225 | + self.grid_list[0].points, repeat=self.num_domains - 1 |
| 226 | + ) |
| 227 | + else: |
| 228 | + pre_weights_combinations = itertools.product( |
| 229 | + *[grid.weights for grid in self.grid_list[:-1]] |
| 230 | + ) |
| 231 | + pre_points_combinations = itertools.product( |
| 232 | + *[grid.points for grid in self.grid_list[:-1]] |
| 233 | + ) |
| 234 | + |
| 235 | + # collapse the weights combinations into single weights |
| 236 | + pre_weights = (np.prod(combination) for combination in pre_weights_combinations) |
| 237 | + |
| 238 | + for pre_points_combination, pre_weight in zip(pre_points_combinations, pre_weights): |
| 239 | + # transform the integrand to a partial integrand with the first N-1 arguments fixed |
| 240 | + partial_integrand = lambda x: integrand_function(*pre_points_combination, x) |
| 241 | + # calculate the values of the partial integrand for all points of the last grid |
| 242 | + values = np.array(partial_integrand(self.grid_list[-1].points)) |
| 243 | + integral_value += pre_weight * self.grid_list[-1].integrate(np.array(values)) |
| 244 | + |
| 245 | + return integral_value |
| 246 | + |
| 247 | + def get_localgrid(self, center, radius): |
| 248 | + raise NotImplementedError( |
| 249 | + "The get_local grid method is not implemented for multi-domain grids." |
| 250 | + ) |
| 251 | + |
| 252 | + def moments( |
| 253 | + self, |
| 254 | + orders: int, |
| 255 | + centers: np.ndarray, |
| 256 | + func_vals: np.ndarray, |
| 257 | + type_mom: str = "cartesian", |
| 258 | + return_orders: bool = False, |
| 259 | + ): |
| 260 | + raise NotImplementedError( |
| 261 | + "The computation of moments is not implemented for multi-domain grids." |
| 262 | + ) |
| 263 | + |
| 264 | + |
| 265 | +def _chunked_iterator(iterator, size): |
| 266 | + """Yield chunks from an iterator.""" |
| 267 | + iterator = iter(iterator) |
| 268 | + while True: |
| 269 | + chunk = list(islice(iterator, size)) |
| 270 | + if not chunk: |
| 271 | + break |
| 272 | + yield chunk |
0 commit comments