1+ """Tests for spatial_average3d module."""
2+
3+ import pytest
4+ import numpy as np
5+
6+ # from fluidsim.operators.spatial_average3d import SpatialAverage
7+ from ../ spatial_average3d import SpatialAverage
8+
9+
10+ # Mock operator for testing (without full fluidsim dependency)
11+ class MockOperator :
12+ """Minimal operator for testing spatial averaging."""
13+
14+ def __init__ (self , nx , ny , nz , Lx , Ly , Lz ):
15+ self .nx = nx
16+ self .ny = ny
17+ self .nz = nz
18+ self .Lx = Lx
19+ self .Ly = Ly
20+ self .Lz = Lz
21+ self .delta = Lx / nx # Assume uniform spacing
22+
23+ # Create a simple uniform grid
24+ x1d = np .linspace (0 , Lx , nx , endpoint = False )
25+ y1d = np .linspace (0 , Ly , ny , endpoint = False )
26+ z1d = np .linspace (- Lz / 2 , Lz / 2 , nz , endpoint = False )
27+
28+ self .Z , self .Y , self .X = np .meshgrid (z1d , y1d , x1d , indexing = 'ij' )
29+ self .shapeX_loc = self .X .shape
30+
31+ def get_XYZ_loc (self ):
32+ """Return local coordinates (no MPI in tests)."""
33+ return self .X , self .Y , self .Z
34+
35+
36+ @pytest .fixture (scope = "module" )
37+ def mock_oper ():
38+ """Create a small 3D operator for testing."""
39+ return MockOperator (nx = 8 , ny = 8 , nz = 8 , Lx = 2.0 , Ly = 2.0 , Lz = 2.0 )
40+
41+
42+ @pytest .fixture (scope = "module" )
43+ def spatial_avg (mock_oper ):
44+ """Create SpatialAverage instance."""
45+ return SpatialAverage (mock_oper , nr = 10 , nrh = 8 , nz = 8 )
46+
47+
48+ # ---------------------------------------------------------------------------
49+ # Initialization tests
50+ # ---------------------------------------------------------------------------
51+
52+
53+ def test_initialization (spatial_avg , mock_oper ):
54+ """Test that spatial average initializes correctly."""
55+ assert spatial_avg .nr == 10
56+ assert spatial_avg .nrh == 8
57+ assert spatial_avg .nz == 8
58+ assert spatial_avg .X .shape == mock_oper .X .shape
59+ assert hasattr (spatial_avg , 'r' )
60+ assert hasattr (spatial_avg , 'rho' )
61+ assert hasattr (spatial_avg , 'phi' )
62+
63+
64+ def test_coordinate_shapes (spatial_avg , mock_oper ):
65+ """Test that computed coordinates have correct shapes."""
66+ shape = mock_oper .X .shape
67+ assert spatial_avg .r .shape == shape
68+ assert spatial_avg .rho .shape == shape
69+ assert spatial_avg .phi .shape == shape
70+
71+
72+ def test_bins_positive (spatial_avg ):
73+ """Test that bin centers are positive and sorted."""
74+ assert np .all (spatial_avg .r_centers > 0 )
75+ assert np .all (np .diff (spatial_avg .r_centers ) > 0 ) # monotonically increasing
76+ assert np .all (spatial_avg .rho_centers >= 0 )
77+ assert np .all (np .diff (spatial_avg .rho_centers ) > 0 )
78+
79+
80+ # ---------------------------------------------------------------------------
81+ # Radial average tests
82+ # ---------------------------------------------------------------------------
83+
84+
85+ def test_radial_average_constant_field (spatial_avg , allclose ):
86+ """Radial average of a constant field should be constant."""
87+ field = np .ones_like (spatial_avg .X ) * 5.0
88+ r_centers , field_avg = spatial_avg .compute_radial_average (field )
89+
90+ # All bins should have approximately the same value
91+ assert allclose (field_avg , 5.0 , rtol = 1e-10 )
92+
93+
94+ def test_radial_average_radial_field (spatial_avg , allclose ):
95+ """Test radial average of f(r) = r."""
96+ field = spatial_avg .r .copy ()
97+ r_centers , field_avg = spatial_avg .compute_radial_average (field )
98+
99+ # Average of r in each radial shell should equal the shell radius
100+ assert allclose (field_avg , r_centers , rtol = 0.1 )
101+
102+
103+ def test_radial_average_quadratic_field (spatial_avg , allclose ):
104+ """Test radial average of f(r) = r^2."""
105+ field = spatial_avg .r ** 2
106+ r_centers , field_avg = spatial_avg .compute_radial_average (field )
107+
108+ # Average of r^2 in each shell should equal r_center^2
109+ expected = r_centers ** 2
110+ assert allclose (field_avg , expected , rtol = 0.15 )
111+
112+
113+ def test_radial_average_vector_field (spatial_avg ):
114+ """Test radial average of a 3D vector field."""
115+ shape = spatial_avg .X .shape
116+ vx = np .ones (shape )
117+ vy = np .ones (shape ) * 2
118+ vz = np .ones (shape ) * 3
119+ vector_field = np .array ([vx , vy , vz ])
120+
121+ r_centers , v_avg = spatial_avg .compute_radial_average (vector_field )
122+
123+ assert v_avg .shape == (3 , spatial_avg .nr )
124+ assert np .allclose (v_avg [0 ], 1.0 , rtol = 1e-10 )
125+ assert np .allclose (v_avg [1 ], 2.0 , rtol = 1e-10 )
126+ assert np .allclose (v_avg [2 ], 3.0 , rtol = 1e-10 )
127+
128+
129+ def test_radial_average_with_std (spatial_avg ):
130+ """Test that standard deviation is returned when requested."""
131+ field = np .random .randn (* spatial_avg .X .shape )
132+ r_centers , field_avg , field_std = spatial_avg .compute_radial_average (
133+ field , return_std = True
134+ )
135+
136+ assert field_avg .shape == (spatial_avg .nr ,)
137+ assert field_std .shape == (spatial_avg .nr ,)
138+ assert np .all (field_std >= 0 ) # Std must be non-negative
139+
140+
141+ def test_radial_average_zero_field (spatial_avg , allclose ):
142+ """Radial average of zero field should be zero."""
143+ field = np .zeros_like (spatial_avg .X )
144+ r_centers , field_avg = spatial_avg .compute_radial_average (field )
145+
146+ assert allclose (field_avg , 0.0 , atol = 1e-15 )
147+
148+
149+ # ---------------------------------------------------------------------------
150+ # Azimuthal average tests
151+ # ---------------------------------------------------------------------------
152+
153+
154+ def test_azimuthal_average_constant_field (spatial_avg , allclose ):
155+ """Azimuthal average of a constant field should be constant."""
156+ field = np .ones_like (spatial_avg .X ) * 7.0
157+ rho_centers , z_centers , field_avg = spatial_avg .compute_azimuthal_average (field )
158+
159+ assert allclose (field_avg , 7.0 , rtol = 1e-10 )
160+
161+
162+ def test_azimuthal_average_z_dependent (spatial_avg , allclose ):
163+ """Test azimuthal average of f(z) = z."""
164+ field = spatial_avg .Z .copy ()
165+ rho_centers , z_centers , field_avg = spatial_avg .compute_azimuthal_average (field )
166+
167+ # For each z bin, average should equal z_center
168+ # Average over rho (each row should be same)
169+ avg_over_rho = np .mean (field_avg , axis = 0 )
170+ assert allclose (avg_over_rho , z_centers , rtol = 0.15 )
171+
172+
173+ def test_azimuthal_average_rho_dependent (spatial_avg , allclose ):
174+ """Test azimuthal average of f(rho) = rho."""
175+ field = spatial_avg .rho .copy ()
176+ rho_centers , z_centers , field_avg = spatial_avg .compute_azimuthal_average (field )
177+
178+ # For each rho bin, average should equal rho_center
179+ # Average over z (each column should be same)
180+ avg_over_z = np .mean (field_avg , axis = 1 )
181+ assert allclose (avg_over_z , rho_centers , rtol = 0.15 )
182+
183+
184+ def test_azimuthal_average_vector_field (spatial_avg ):
185+ """Test azimuthal average of a 3D vector field."""
186+ shape = spatial_avg .X .shape
187+ vx = np .ones (shape ) * 1.5
188+ vy = np .ones (shape ) * 2.5
189+ vz = np .ones (shape ) * 3.5
190+ vector_field = np .array ([vx , vy , vz ])
191+
192+ rho_centers , z_centers , v_avg = spatial_avg .compute_azimuthal_average (vector_field )
193+
194+ assert v_avg .shape == (3 , spatial_avg .nrh , spatial_avg .nz )
195+ assert np .allclose (v_avg [0 ], 1.5 , rtol = 1e-10 )
196+ assert np .allclose (v_avg [1 ], 2.5 , rtol = 1e-10 )
197+ assert np .allclose (v_avg [2 ], 3.5 , rtol = 1e-10 )
198+
199+
200+ def test_azimuthal_average_with_std (spatial_avg ):
201+ """Test that standard deviation is returned when requested."""
202+ field = np .random .randn (* spatial_avg .X .shape )
203+ rho_centers , z_centers , field_avg , field_std = spatial_avg .compute_azimuthal_average (
204+ field , return_std = True
205+ )
206+
207+ assert field_avg .shape == (spatial_avg .nrh , spatial_avg .nz )
208+ assert field_std .shape == (spatial_avg .nrh , spatial_avg .nz )
209+ assert np .all (field_std >= 0 )
210+
211+
212+ def test_azimuthal_average_zero_field (spatial_avg , allclose ):
213+ """Azimuthal average of zero field should be zero."""
214+ field = np .zeros_like (spatial_avg .X )
215+ rho_centers , z_centers , field_avg = spatial_avg .compute_azimuthal_average (field )
216+
217+ assert allclose (field_avg , 0.0 , atol = 1e-15 )
218+
219+
220+ # ---------------------------------------------------------------------------
221+ # Physics tests: sin(phi) weighting
222+ # ---------------------------------------------------------------------------
223+
224+
225+ def test_radial_average_sin_phi_weighting (spatial_avg ):
226+ """Test that sin(phi) weighting is correctly applied."""
227+ # Field = 1 everywhere
228+ field = np .ones_like (spatial_avg .X )
229+
230+ # Compute radial average (uses sin(phi) weights)
231+ r_centers , field_avg = spatial_avg .compute_radial_average (field )
232+
233+ # For a constant field, average should be 1 regardless of weighting
234+ assert np .allclose (field_avg , 1.0 , rtol = 1e-10 )
235+
236+ # Now test with field = phi (should give average phi in each shell)
237+ field_phi = spatial_avg .phi .copy ()
238+ r_centers , phi_avg = spatial_avg .compute_radial_average (field_phi )
239+
240+ # Weighted average of phi over sphere should be close to pi/2
241+ # (integral of phi*sin(phi) from 0 to pi gives pi/2)
242+ overall_avg_phi = np .average (phi_avg , weights = r_centers ** 2 ) # weight by shell volume
243+ assert np .allclose (overall_avg_phi , np .pi / 2 , rtol = 0.1 )
244+
245+
246+ # ---------------------------------------------------------------------------
247+ # Volume weights tests
248+ # ---------------------------------------------------------------------------
249+
250+
251+ def test_compute_volume_weights_shape (spatial_avg , mock_oper ):
252+ """Test that volume weights have correct shape."""
253+ weights = spatial_avg .compute_volume_weights ()
254+ assert weights .shape == mock_oper .X .shape
255+
256+
257+ def test_compute_volume_weights_uniform (spatial_avg , mock_oper , allclose ):
258+ """Test that volume weights are uniform for uniform grid."""
259+ weights = spatial_avg .compute_volume_weights ()
260+ expected_weight = mock_oper .delta ** 3
261+ assert allclose (weights , expected_weight , rtol = 1e-12 )
262+
263+
264+ def test_compute_volume_weights_sum (spatial_avg , mock_oper , allclose ):
265+ """Test that sum of volume weights equals total domain volume."""
266+ weights = spatial_avg .compute_volume_weights ()
267+ total_volume = np .sum (weights )
268+ expected_volume = mock_oper .Lx * mock_oper .Ly * mock_oper .Lz
269+ assert allclose (total_volume , expected_volume , rtol = 1e-10 )
270+
271+
272+ # ---------------------------------------------------------------------------
273+ # Edge cases
274+ # ---------------------------------------------------------------------------
275+
276+
277+ def test_radial_average_single_bin (mock_oper ):
278+ """Test with only one radial bin."""
279+ spatial_avg = SpatialAverage (mock_oper , nr = 1 , nrh = 8 , nz = 8 )
280+ field = np .ones_like (spatial_avg .X ) * 3.14
281+ r_centers , field_avg = spatial_avg .compute_radial_average (field )
282+
283+ assert len (field_avg ) == 1
284+ assert np .allclose (field_avg [0 ], 3.14 , rtol = 1e-10 )
285+
286+
287+ def test_azimuthal_average_single_bin (mock_oper ):
288+ """Test with only one azimuthal bin."""
289+ spatial_avg = SpatialAverage (mock_oper , nr = 10 , nrh = 1 , nz = 1 )
290+ field = np .ones_like (spatial_avg .X ) * 2.71
291+ rho_centers , z_centers , field_avg = spatial_avg .compute_azimuthal_average (field )
292+
293+ assert field_avg .shape == (1 , 1 )
294+ assert np .allclose (field_avg [0 , 0 ], 2.71 , rtol = 1e-10 )
295+
296+
297+ def test_radial_average_large_nr (mock_oper ):
298+ """Test with more bins than grid points (some bins will be empty)."""
299+ spatial_avg = SpatialAverage (mock_oper , nr = 100 , nrh = 8 , nz = 8 )
300+ field = np .ones_like (spatial_avg .X )
301+ r_centers , field_avg = spatial_avg .compute_radial_average (field )
302+
303+ # Non-empty bins should have value close to 1
304+ # Empty bins should be 0 (as set by the code)
305+ assert np .all ((field_avg == 0 ) | (np .abs (field_avg - 1.0 ) < 0.1 ))
306+
307+
308+ # ---------------------------------------------------------------------------
309+ # Consistency tests
310+ # ---------------------------------------------------------------------------
311+
312+
313+ def test_radial_azimuthal_consistency (spatial_avg , allclose ):
314+ """Test that radial and azimuthal averages are consistent for spherically symmetric fields."""
315+ # Spherically symmetric field: f(r) = r
316+ field = spatial_avg .r .copy ()
317+
318+ # Radial average
319+ r_centers , field_avg_radial = spatial_avg .compute_radial_average (field )
320+
321+ # Azimuthal average
322+ rho_centers , z_centers , field_avg_azim = spatial_avg .compute_azimuthal_average (field )
323+
324+ # For spherically symmetric field, azimuthal average should depend only on r = sqrt(rho^2 + z^2)
325+ # Compute r for each (rho, z) bin
326+ RHO , Z = np .meshgrid (rho_centers , z_centers , indexing = 'ij' )
327+ r_azim = np .sqrt (RHO ** 2 + Z ** 2 )
328+
329+ # The field value should be close to r at each (rho, z)
330+ # (with some discretization error)
331+ assert allclose (field_avg_azim , r_azim , rtol = 0.2 )
332+
333+
334+ def test_linearity_radial_average (spatial_avg , allclose ):
335+ """Test linearity: avg(a*f1 + b*f2) = a*avg(f1) + b*avg(f2)."""
336+ field1 = np .random .randn (* spatial_avg .X .shape )
337+ field2 = np .random .randn (* spatial_avg .X .shape )
338+ a , b = 2.5 , - 1.3
339+
340+ r_centers , avg1 = spatial_avg .compute_radial_average (field1 )
341+ _ , avg2 = spatial_avg .compute_radial_average (field2 )
342+ _ , avg_combined = spatial_avg .compute_radial_average (a * field1 + b * field2 )
343+
344+ expected = a * avg1 + b * avg2
345+ assert allclose (avg_combined , expected , rtol = 1e-10 )
346+
347+
348+ def test_linearity_azimuthal_average (spatial_avg , allclose ):
349+ """Test linearity for azimuthal average."""
350+ field1 = np .random .randn (* spatial_avg .X .shape )
351+ field2 = np .random .randn (* spatial_avg .X .shape )
352+ a , b = 3.2 , 0.7
353+
354+ rho_centers , z_centers , avg1 = spatial_avg .compute_azimuthal_average (field1 )
355+ _ , _ , avg2 = spatial_avg .compute_azimuthal_average (field2 )
356+ _ , _ , avg_combined = spatial_avg .compute_azimuthal_average (a * field1 + b * field2 )
357+
358+ expected = a * avg1 + b * avg2
359+ assert allclose (avg_combined , expected , rtol = 1e-10 )
0 commit comments