Skip to content

Commit 9ed23fc

Browse files
committed
Add periodic parameter support for 2D plots
- Add convolve2D_periodic() function for 2D periodic convolution - Support periodic_x, periodic_y, periodic_both convolution modes - Update get2DDensity() to detect parameter periodicity and select appropriate convolution mode - Fix edge mask handling to skip boundary corrections on periodic axes - Add comprehensive test case for periodic 2D density calculations - Ensure perfect boundary continuity for periodic parameters while preserving edge effects for non-periodic parameters
1 parent d9e06ba commit 9ed23fc

3 files changed

Lines changed: 211 additions & 25 deletions

File tree

getdist/convolve.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,126 @@ def convolve1D(x, y, mode, largest_size=0, cache=None, cache_args=(1, 2)):
203203

204204

205205
def convolve2D(x, y, mode, largest_size=0, cache=None, cache_args=(1, 2)):
206+
if mode in ("periodic", "periodic_both"):
207+
return convolve2D_periodic(x, y, cache, cache_args, periodic_x=True, periodic_y=True)
208+
elif mode == "periodic_x":
209+
return convolve2D_periodic(x, y, cache, cache_args, periodic_x=True, periodic_y=False)
210+
elif mode == "periodic_y":
211+
return convolve2D_periodic(x, y, cache, cache_args, periodic_x=False, periodic_y=True)
206212
return convolveFFTn(x, y, mode, largest_size, cache, cache_args=cache_args)
207213

208214

215+
def convolve2D_periodic(x, y, cache=None, cache_args=(1, 2), periodic_x=True, periodic_y=True):
216+
"""
217+
2D convolution with periodic boundary conditions in specified dimensions.
218+
219+
This implements periodic convolution by extending the 1D approach to 2D.
220+
For periodic dimensions, the input is made circular by averaging boundary values.
221+
222+
:param x: 2D input array (data)
223+
:param y: 2D kernel array
224+
:param cache: optional cache for FFTs
225+
:param cache_args: which arrays to cache (1=x, 2=y)
226+
:param periodic_x: whether to use periodic boundaries in x (second) dimension
227+
:param periodic_y: whether to use periodic boundaries in y (first) dimension
228+
:return: convolved array of same size as x
229+
"""
230+
if x.ndim != 2 or y.ndim != 2:
231+
raise ValueError("convolve2D_periodic requires 2D arrays")
232+
233+
ny, nx = x.shape
234+
ky, kx = y.shape
235+
236+
# Create the circular version of x based on periodicity
237+
if periodic_x and periodic_y:
238+
# Both dimensions periodic
239+
x_circ = x[:-1, :-1].copy() # Remove last row and column
240+
x_circ[0, :] += x[-1, :-1] # Add last row to first row
241+
x_circ[:, 0] += x[:-1, -1] # Add last column to first column
242+
x_circ[0, 0] += x[-1, -1] # Add corner element
243+
N_y, N_x = x_circ.shape
244+
245+
elif periodic_x and not periodic_y:
246+
# Only x dimension periodic
247+
x_circ = x[:, :-1].copy() # Remove last column
248+
x_circ[:, 0] += x[:, -1] # Add last column to first column
249+
N_y, N_x = x_circ.shape
250+
251+
elif periodic_y and not periodic_x:
252+
# Only y dimension periodic
253+
x_circ = x[:-1, :].copy() # Remove last row
254+
x_circ[0, :] += x[-1, :] # Add last row to first row
255+
N_y, N_x = x_circ.shape
256+
257+
else:
258+
# Neither periodic (shouldn't happen, but handle gracefully)
259+
return convolveFFTn(x, y, "same")
260+
261+
# Prepare kernel for circular convolution
262+
hpad = np.zeros((N_y, N_x), dtype=float)
263+
hpad[:ky, :kx] = y
264+
# Roll kernel to center it properly for convolution
265+
hpad = np.roll(hpad, -(ky // 2), axis=0)
266+
hpad = np.roll(hpad, -(kx // 2), axis=1)
267+
268+
# Cache keys
269+
if cache is not None:
270+
if 1 in cache_args:
271+
key_x = ("2D_circ", N_y, N_x, periodic_x, periodic_y, id(x))
272+
if 2 in cache_args:
273+
key_y = ("2D_kernel", N_y, N_x, id(y))
274+
275+
# Get or compute FFT of kernel
276+
yfft = None
277+
if cache is not None and 2 in cache_args:
278+
yfft = cache.get(key_y)
279+
if yfft is None:
280+
yfft = np.fft.rfftn(hpad)
281+
if cache is not None and 2 in cache_args:
282+
cache[key_y] = yfft
283+
284+
# Get or compute FFT of data
285+
xfft = None
286+
if cache is not None and 1 in cache_args:
287+
xfft = cache.get(key_x)
288+
if xfft is None:
289+
xfft = np.fft.rfftn(x_circ)
290+
if cache is not None and 1 in cache_args:
291+
cache[key_x] = xfft
292+
293+
# Perform convolution in frequency domain
294+
result_fft = xfft * yfft
295+
result = np.fft.irfftn(result_fft, (N_y, N_x), axes=(0, 1))
296+
297+
# Extend result back to original size for periodic dimensions
298+
if periodic_x and periodic_y:
299+
# Both periodic: append first row/column as last
300+
final_result = np.empty((ny, nx))
301+
final_result[:-1, :-1] = result
302+
final_result[-1, :-1] = result[0, :] # Last row = first row
303+
final_result[:-1, -1] = result[:, 0] # Last column = first column
304+
final_result[-1, -1] = result[0, 0] # Corner element
305+
return final_result
306+
307+
elif periodic_x and not periodic_y:
308+
# Only x periodic: append first column as last
309+
final_result = np.empty((ny, nx))
310+
final_result[:, :-1] = result
311+
final_result[:, -1] = result[:, 0] # Last column = first column
312+
return final_result
313+
314+
elif periodic_y and not periodic_x:
315+
# Only y periodic: append first row as last
316+
final_result = np.empty((ny, nx))
317+
final_result[:-1, :] = result
318+
final_result[-1, :] = result[0, :] # Last row = first row
319+
return final_result
320+
321+
else:
322+
# Neither periodic (fallback)
323+
return result
324+
325+
209326
def convolve1D_periodic(x, y, cache=None, cache_args=(1, 2)):
210327
"""
211328
Circular (periodic) 1D convolution of x with kernel y. Returns same-length result.

getdist/mcsamples.py

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,25 +1700,31 @@ def get1DDensityGridData(self, j, paramConfid=None, meanlikes=False, **kwargs):
17001700
return density1D
17011701

17021702
def _setEdgeMask2D(self, parx, pary, prior_mask, winw):
1703-
if parx.has_limits_bot:
1704-
prior_mask[:, winw] /= 2
1703+
# Only apply edge masks on non-periodic axes; periodic axes have no boundaries
1704+
if not parx.periodic:
1705+
if parx.has_limits_bot:
1706+
prior_mask[:, winw] /= 2
1707+
prior_mask[:, :winw] = 0
1708+
if parx.has_limits_top:
1709+
prior_mask[:, -(winw + 1)] /= 2
1710+
prior_mask[:, -winw:] = 0
1711+
if not pary.periodic:
1712+
if pary.has_limits_bot:
1713+
prior_mask[winw, :] /= 2
1714+
prior_mask[:winw:] = 0
1715+
if pary.has_limits_top:
1716+
prior_mask[-(winw + 1), :] /= 2
1717+
prior_mask[-winw:, :] = 0
1718+
1719+
def _setAllEdgeMask2D(self, prior_mask, winw, periodic_x=False, periodic_y=False):
1720+
# Zero out margins only along non-periodic axes
1721+
if not periodic_x:
17051722
prior_mask[:, :winw] = 0
1706-
if parx.has_limits_top:
1707-
prior_mask[:, -(winw + 1)] /= 2
17081723
prior_mask[:, -winw:] = 0
1709-
if pary.has_limits_bot:
1710-
prior_mask[winw, :] /= 2
1724+
if not periodic_y:
17111725
prior_mask[:winw:] = 0
1712-
if pary.has_limits_top:
1713-
prior_mask[-(winw + 1), :] /= 2
17141726
prior_mask[-winw:, :] = 0
17151727

1716-
def _setAllEdgeMask2D(self, prior_mask, winw):
1717-
prior_mask[:, :winw] = 0
1718-
prior_mask[:, -winw:] = 0
1719-
prior_mask[:winw:] = 0
1720-
prior_mask[-winw:, :] = 0
1721-
17221728
def _getScaleForParam(self, par):
17231729
# Also ensures that the 1D limits are initialized
17241730
density = self.get1DDensity(par)
@@ -1878,14 +1884,29 @@ def get2DDensityGridData(
18781884
start = time.time()
18791885
cache = {}
18801886
convolvesize = xsize + 2 * winw + Win.shape[0] # larger than needed for selecting fft pixel count
1881-
bins2D = convolve2D(histbins, Win, "same", largest_size=convolvesize, cache=cache)
1887+
1888+
# Determine convolution mode based on parameter periodicity
1889+
if parx.periodic and pary.periodic:
1890+
convolution_mode = "periodic_both"
1891+
elif parx.periodic:
1892+
convolution_mode = "periodic_x"
1893+
elif pary.periodic:
1894+
convolution_mode = "periodic_y"
1895+
else:
1896+
convolution_mode = "same"
1897+
1898+
bins2D = convolve2D(histbins, Win, convolution_mode, largest_size=convolvesize, cache=cache)
18821899

18831900
if meanlikes:
1884-
bin2Dlikes = convolve2D(finebinlikes, Win, "same", largest_size=convolvesize, cache=cache, cache_args=[2])
1901+
bin2Dlikes = convolve2D(
1902+
finebinlikes, Win, convolution_mode, largest_size=convolvesize, cache=cache, cache_args=[2]
1903+
)
18851904
if mult_bias_correction_order:
18861905
ix = bin2Dlikes > 0
18871906
finebinlikes[ix] /= bin2Dlikes[ix]
1888-
likes2 = convolve2D(finebinlikes, Win, "same", largest_size=convolvesize, cache=cache, cache_args=[2])
1907+
likes2 = convolve2D(
1908+
finebinlikes, Win, convolution_mode, largest_size=convolvesize, cache=cache, cache_args=[2]
1909+
)
18891910
likes2[ix] *= bin2Dlikes[ix]
18901911
bin2Dlikes = likes2
18911912
del finebinlikes
@@ -1896,17 +1917,26 @@ def get2DDensityGridData(
18961917
bin2Dlikes = None
18971918

18981919
bool_mask = None
1920+
18991921
if has_prior and boundary_correction_order >= 0 or mult_bias_correction_order or mask_function:
1922+
# Always pad by winw in both axes so that 'valid' convolution returns (ysize, xsize)
1923+
# Masks are only applied on non-periodic axes; periodic axes remain as ones.
19001924
prior_mask = np.ones((ysize + 2 * winw, xsize + 2 * winw))
19011925
if mask_function:
19021926
mask_function(
1903-
xbinmin - winw * finewidthx, ybinmin - winw * finewidthy, finewidthx, finewidthy, prior_mask
1927+
xbinmin - winw * finewidthx,
1928+
ybinmin - winw * finewidthy,
1929+
finewidthx,
1930+
finewidthy,
1931+
prior_mask,
19041932
)
19051933
bool_mask = prior_mask[winw:-winw, winw:-winw] < 1e-8
19061934

1907-
if has_prior and boundary_correction_order >= 0:
1908-
# Correct for edge effects
1935+
if has_prior and boundary_correction_order >= 0 and not (parx.periodic and pary.periodic):
1936+
# Correct for edge effects; if only one axis is periodic still correct on the other axis
19091937
self._setEdgeMask2D(parx, pary, prior_mask, winw)
1938+
# Use 'valid' on the padded prior_mask so the result is (ysize, xsize)
1939+
# For periodic axes prior_mask is ones along that axis, so this is consistent
19101940
a00 = convolve2D(prior_mask, Win, "valid", largest_size=convolvesize, cache=cache)
19111941
ix = a00 * bins2D > np.max(bins2D) * 1e-8
19121942
a00 = a00[ix]
@@ -1933,8 +1963,8 @@ def get2DDensityGridData(
19331963
a11 = convolve2D(
19341964
prior_mask, winy * indexes, "valid", largest_size=convolvesize, cache=cache, cache_args=[1]
19351965
)[ix]
1936-
xP = convolve2D(histbins, winx, "same", largest_size=convolvesize, cache=cache)[ix]
1937-
yP = convolve2D(histbins, winy, "same", largest_size=convolvesize, cache=cache)[ix]
1966+
xP = convolve2D(histbins, winx, convolution_mode, largest_size=convolvesize, cache=cache)[ix]
1967+
yP = convolve2D(histbins, winy, convolution_mode, largest_size=convolvesize, cache=cache)[ix]
19381968
denom = a20 * a01**2 + a10**2 * a02 - a00 * a02 * a20 + a11**2 * a00 - 2 * a01 * a10 * a11
19391969
A = a11**2 - a02 * a20
19401970
Ax = a10 * a02 - a01 * a11
@@ -1944,14 +1974,16 @@ def get2DDensityGridData(
19441974
else:
19451975
raise SettingError("unknown boundary_correction_order (expected 0 or 1)")
19461976

1947-
if mult_bias_correction_order:
1948-
self._setAllEdgeMask2D(prior_mask, winw)
1977+
if mult_bias_correction_order and not (parx.periodic and pary.periodic):
1978+
# If only one axis is periodic still correct on the non-periodic edges
1979+
self._setAllEdgeMask2D(prior_mask, winw, periodic_x=parx.periodic, periodic_y=pary.periodic)
1980+
# Use 'valid' on the padded prior_mask so the result is (ysize, xsize)
19491981
a00 = convolve2D(prior_mask, Win, "valid", largest_size=convolvesize, cache=cache, cache_args=[2])
19501982
for _ in range(mult_bias_correction_order):
19511983
box = histbins.copy()
19521984
ix2 = bins2D > np.max(bins2D) * 1e-8
19531985
box[ix2] /= bins2D[ix2]
1954-
bins2D *= convolve2D(box, Win, "same", largest_size=convolvesize, cache=cache, cache_args=[2])
1986+
bins2D *= convolve2D(box, Win, convolution_mode, largest_size=convolvesize, cache=cache, cache_args=[2])
19551987
if mask_function:
19561988
bins2D[~bool_mask] /= a00[~bool_mask]
19571989
else:

getdist/tests/getdist_test.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,43 @@ def testNDDensity(self):
178178

179179
self.assertTrue(np.allclose(d2D.P, dND.P, atol=1e-5))
180180

181+
def testPeriodic2D(self):
182+
# Test 2D density calculation with periodic parameters
183+
n_samples = 1000
184+
np.random.seed(42)
185+
186+
# Create angle parameter (periodic) and radius parameter (non-periodic)
187+
angle = np.random.normal(0, 1, n_samples) % (2 * np.pi)
188+
radius = np.abs(np.random.normal(2, 0.5, n_samples))
189+
190+
samples = np.column_stack([angle, radius])
191+
names = ["angle", "radius"]
192+
labels = [r"\theta", "r"]
193+
194+
mcsamples = MCSamples(
195+
samples=samples, names=names, labels=labels, ranges={"angle": [0, 2 * np.pi, "periodic"], "radius": [0, 5]}
196+
)
197+
198+
# Test 2D density calculation
199+
density = mcsamples.get2DDensity("angle", "radius", fine_bins_2D=32)
200+
201+
# Basic checks
202+
self.assertEqual(density.P.shape, (32, 32))
203+
self.assertGreater(np.max(density.P), 0)
204+
self.assertGreater(density.norm_integral(), 0)
205+
206+
# Periodic edges equal checks (merge from former test)
207+
d64x = mcsamples.get2DDensity("angle", "radius", fine_bins_2D=64)
208+
self.assertTrue(np.allclose(d64x.P[:, 0], d64x.P[:, -1], atol=5e-3, rtol=5e-3))
209+
d64y = mcsamples.get2DDensity("radius", "angle", fine_bins_2D=64)
210+
self.assertTrue(np.allclose(d64y.P[0, :], d64y.P[-1, :], atol=5e-3, rtol=5e-3))
211+
212+
# Test that periodic parameter is properly detected
213+
angle_param = mcsamples.paramNames.parWithName("angle")
214+
radius_param = mcsamples.paramNames.parWithName("radius")
215+
self.assertTrue(angle_param.periodic)
216+
self.assertFalse(radius_param.periodic)
217+
181218
def testLoads(self):
182219
# test initiating from multiple chain arrays
183220
samps = []

0 commit comments

Comments
 (0)