Skip to content

Commit 6c8dea4

Browse files
authored
Merge pull request #268 from marco-2023/refactor_ngrid
Refactor ngrid
2 parents 834ecaa + f157937 commit 6c8dea4

2 files changed

Lines changed: 310 additions & 150 deletions

File tree

src/grid/ngrid.py

Lines changed: 205 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -22,156 +22,251 @@
2222
import numpy as np
2323
from grid.basegrid import Grid
2424
import itertools
25-
from numbers import Number
25+
from itertools import islice
2626

2727

28-
class Ngrid(Grid):
28+
class MultiDomainGrid(Grid):
2929
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.
3131
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.
3350
34-
..math::
35-
\idotsint f(x_1, x_2, ..., x_N) dx_1 dx_2 ... dx_N
3651
3752
The function to integrate must have all arguments :math:`\{x_i\}` with the same dimension as the
3853
points of the corresponding grids (i.e. each of the arguments corresponds to a different grid).
3954
For example for a function of the form f((x1,y1,z1), (x2,y2)) -> float the first argument must
4055
be described by a 3D grid and the second argument by a 2D grid.
4156
"""
4257

43-
def __init__(self, grid_list=None, n=None, **kwargs):
58+
def __init__(self, grid_list=None, num_domains=None):
4459
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.
5561
5662
Parameters
5763
----------
5864
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.
6686
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")
6890
if len(grid_list) == 0:
6991
raise ValueError("The list must contain at least one grid")
70-
71-
# check that grid_list contains only Grid objects
7292
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")
8599

86100
self.grid_list = grid_list
87-
self.n = n
101+
self._num_domains = num_domains
88102

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)
92107

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])
101115

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+
------
104134
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.
106137
"""
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")
110138

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+
)
113144
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)
115149

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):
117174
r"""
118-
Integrate callable on the space spanned domain union of the grids in grid_list.
175+
Integrate callable on the N particle grid.
119176
120177
Parameters
121178
----------
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.
130194
131195
Returns
132196
-------
133197
float
134198
Integral of callable.
135199
"""
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)
136207

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)
141213
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

Comments
 (0)