Coverage for src/CSET/operators/power_spectrum.py: 95%

98 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 08:32 +0000

1# © Crown copyright, Met Office (2022-2025) and CSET contributors. 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14 

15"""Operators for calculating power spectra.""" 

16 

17import logging 

18 

19import iris 

20import iris.coords 

21import iris.cube 

22import iris.exceptions 

23import numpy as np 

24from scipy import fft 

25 

26from CSET._common import iter_maybe 

27 

28logger = logging.getLogger(__name__) 

29 

30 

31def calculate_power_spectrum(cubes: iris.cube.Cube | iris.cube.CubeList): 

32 """Wrap power spectrum code. 

33 

34 This function is a wrapper that handles power spectrum 

35 calculations for both single cubes and cube lists and includes ensembles. 

36 

37 The input cube is split up into a cube for each model, 

38 time and realization and a power spectrum calculated for each before 

39 combining into one cube ahead of plotting. This is done to retain the 

40 model_name attribute correctly for different models. 

41 

42 In case of a CubeList (Multiple models and ensembles): It iterates through 

43 each cube and calculates an individual power spectrum. In case of a 

44 single cube (one model) it directly calculates the power spectrum. 

45 

46 Parameters 

47 ---------- 

48 cubes: Cube | CubeList 

49 Field over which to calculate a power spectrum. 

50 

51 Returns 

52 ------- 

53 Cube | CubeList: 

54 CubeList of power spectra. 

55 """ 

56 out = iris.cube.CubeList() 

57 for cube in iter_maybe(cubes): 

58 model = cube.attributes.get("model_name") 

59 

60 # Check whether data has a realization coord. 

61 if cube.coords("realization"): 

62 members_and_realizations = [ 

63 (member, int(member.coord("realization").points[0])) 

64 for member in cube.slices_over("realization") 

65 ] 

66 else: 

67 members_and_realizations = [(cube, None)] 

68 

69 # Loop over each realization. 

70 member_power_spectra = iris.cube.CubeList() 

71 for member, realiz in members_and_realizations: 

72 # Calculate power spectrum. 

73 ps = _power_spectrum(member) 

74 # Attach model name if available. 

75 if model: 

76 ps.attributes["model_name"] = model 

77 # Add the correct realization from the parent cube. 

78 if realiz is not None: 

79 ps.add_aux_coord( 

80 iris.coords.AuxCoord(realiz, long_name="realization", units="1") 

81 ) 

82 # Promote to dimension coordinate. 

83 ps = iris.util.new_axis(ps, "realization") 

84 member_power_spectra.append(ps) 

85 

86 # Merge the individual realization cubes into a single cube, then 

87 # squeeze off length 1 realization coordinates. 

88 combined_cube = member_power_spectra.concatenate_cube() 

89 combined_cube = iris.util.squeeze(combined_cube) 

90 out.append(combined_cube) 

91 

92 # Directly return cube if we only have one. 

93 if len(out) == 1: 

94 return out[0] 

95 else: 

96 return out 

97 

98 

99def _power_spectrum(cube: iris.cube.Cube) -> iris.cube.Cube: 

100 """Calculate power spectrum for a single cube for 1 vertical level at 1 time. 

101 

102 Parameters 

103 ---------- 

104 cube: Cube 

105 Data to plot as power spectrum. 

106 The cubes should cover the same phenomenon i.e. all cubes contain temperature data. 

107 We do not support different data such as temperature and humidity in the same CubeList 

108 for plotting. 

109 

110 Returns 

111 ------- 

112 iris.cube.Cube 

113 The power spectrum of the data. 

114 To be plotted and aggregation performed after. 

115 

116 Raises 

117 ------ 

118 ValueError 

119 If the cube doesn't have the right dimensions. 

120 TypeError 

121 If the cube isn't a Cube. 

122 """ 

123 # Extract time coordinate and convert to datetime 

124 time_coord = cube.coord("time") 

125 time_points = time_coord.units.num2date(time_coord.points) 

126 

127 if cube.ndim == 2: 

128 cube_3d = cube.data[np.newaxis, :, :] 

129 logger.debug("Adding in new axis for a 2 dimensional cube.") 

130 elif cube.ndim == 3: 

131 cube_3d = cube.data 

132 else: 

133 raise ValueError( 

134 f"Cube is {cube.ndim} dimensional. Cube should be 2 or 3 dimensional." 

135 ) 

136 

137 # Calculate spectrum 

138 ps_array = _DCT_ps(cube_3d) 

139 

140 # Make wavenumber comparable between models with different domain sizes and 

141 # resolutions. Try to find appropriate spatial coordinates. 

142 coord_pairs = ( 

143 ("projection_x_coordinate", "projection_y_coordinate"), 

144 ("grid_latitude", "grid_longitude"), 

145 ("latitude", "longitude"), 

146 ) 

147 for x_coord_name, y_coord_name in coord_pairs: 

148 try: 

149 # Try projection coordinates first (most common for limited area models) 

150 x_coord = cube.coord(x_coord_name) 

151 y_coord = cube.coord(y_coord_name) 

152 except iris.exceptions.CoordinateNotFoundError: 

153 continue 

154 logger.debug( 

155 "Using %s and %s coordinates for grid spacing calculation.", 

156 x_coord_name, 

157 y_coord_name, 

158 ) 

159 break # Break out of loop if we found usable coords. 

160 else: 

161 # Raise error if no usable coords found. 

162 raise ValueError( 

163 "Could not find appropriate spatial coordinates. " 

164 "Expected one of: 'projection_x_coordinate'/'projection_y_coordinate', " 

165 "'grid_latitude'/'grid_longitude', or 'latitude'/'longitude'." 

166 ) 

167 

168 # Calculate grid spacing. 

169 dx = np.abs(np.diff(x_coord.points).mean()) 

170 dy = np.abs(np.diff(y_coord.points).mean()) 

171 if "latitude" in x_coord.name(): 

172 # Convert from degrees to meters. x is lat, y is lon. 

173 R_earth = 6371000 # meters 

174 lat_mid = np.mean(x_coord.points) 

175 dx = dx * np.pi / 180 * R_earth * np.cos(lat_mid * np.pi / 180) 

176 dy = dy * np.pi / 180 * R_earth 

177 domain_size_km = ((dx * cube_3d.shape[2]) + (dy * cube_3d.shape[1])) / 2 / 1000 

178 

179 # Convert wavenumber into physically meaningful wavenumber coordinate in 

180 # cycles per km rather than wavenumber per index k. 

181 ps_len = ps_array.shape[1] 

182 k_indices = np.arange(1, ps_len + 1) 

183 physical_wavenumbers = k_indices / domain_size_km # cycles/km 

184 

185 # Create a new DimCoord with physical wavenumber 

186 physical_wavenumbers_coord = iris.coords.DimCoord( 

187 physical_wavenumbers, long_name="physical_wavenumber", units="km-1" 

188 ) 

189 

190 # Calculate wavelength and add as auxiliary coordinate 

191 wavelengths = domain_size_km / k_indices # km 

192 wavelength_coord = iris.coords.AuxCoord( 

193 wavelengths, long_name="wavelength", units="km" 

194 ) 

195 

196 # Ensure power spectrum output is 2D: (time, frequency) 

197 if ps_array.ndim == 1: 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true

198 ps_array = ps_array[np.newaxis, :] 

199 

200 # Prepare time coordinate 

201 numeric_time = time_coord.units.date2num(time_points) 

202 numeric_time = np.atleast_1d(numeric_time) 

203 

204 # Make time coord length match the number of spectra 

205 if len(numeric_time) != ps_array.shape[0]: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true

206 numeric_time = np.repeat(numeric_time[0], ps_array.shape[0]) 

207 

208 new_time_coord = iris.coords.DimCoord( 

209 numeric_time, 

210 standard_name="time", 

211 units=time_coord.units, 

212 ) 

213 

214 # Create output cube with physical coordinates 

215 ps_cube = iris.cube.Cube( 

216 ps_array, 

217 dim_coords_and_dims=[ 

218 (new_time_coord, 0), 

219 (physical_wavenumbers_coord, 1), 

220 ], 

221 long_name="power_spectral_density", 

222 ) 

223 

224 # Add wavelength as auxiliary coordinate 

225 # Realization coordinate is added in _calculate_power_spectrum 

226 ps_cube.add_aux_coord(wavelength_coord, data_dims=1) 

227 

228 return ps_cube 

229 

230 

231def _DCT_ps(y_3d): 

232 """Calculate power spectra for regional domains. 

233 

234 Parameters 

235 ---------- 

236 y_3d: 3D array 

237 3 dimensional array to calculate spectrum for. 

238 (2D field data with 3rd dimension of time) 

239 

240 Returns 

241 ------- 

242 ps_array: 

243 Array of power spectra values calculated for input field (for each time) 

244 

245 Method for regional domains: 

246 Calculate power spectra over limited area domain using Discrete Cosine Transform (DCT) 

247 as described in Denis et al 2002 [Denis_etal_2002]_. 

248 

249 References 

250 ---------- 

251 .. [Denis_etal_2002] Bertrand Denis, Jean Côté and René Laprise (2002) 

252 "Spectral Decomposition of Two-Dimensional Atmospheric Fields on 

253 Limited-Area Domains Using the Discrete Cosine Transform (DCT)" 

254 Monthly Weather Review, Vol. 130, 1812-1828 

255 doi: https://doi.org/10.1175/1520-0493(2002)130<1812:SDOTDA>2.0.CO;2 

256 """ 

257 Nt, Ny, Nx = y_3d.shape 

258 

259 # Max coefficient 

260 Nmin = min(Nx - 1, Ny - 1) 

261 

262 # Create alpha matrix (of wavenumbers) 

263 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

264 

265 # Prepare output array 

266 ps_array = np.zeros((Nt, Nmin)) 

267 

268 # Loop over time to get spectrum for each time. 

269 for t in range(Nt): 

270 y_2d = y_3d[t] 

271 

272 # Apply 2D DCT to transform y_3d[t] from physical space to spectral space. 

273 # fkk is a 2D array of DCT coefficients, representing the amplitudes of 

274 # cosine basis functions at different spatial frequencies. 

275 

276 # DCT transform and normalise spectrum to allow comparison between models. 

277 fkk = fft.dctn(y_2d, norm="ortho") 

278 

279 # calculate variance (energy) of spectral coefficient at each wavenumber pair (k_x, k_y) 

280 # as the square of the DCT coefficient, normalised by the total number of grid points (Nx * Ny). 

281 sigma_2 = fkk**2 / Nx / Ny 

282 

283 # Group ellipses of alphas into the same wavenumber k/Nmin 

284 for k in range(1, Nmin + 1): 

285 # Define the bounds of the current normalised wavenumber magnitude of bin k 

286 alpha = k / Nmin 

287 alpha_p1 = (k + 1) / Nmin 

288 

289 # Sum up elements matching in bin k and divide by bin size 

290 mask_k = np.where((alpha_matrix >= alpha) & (alpha_matrix < alpha_p1)) 

291 n_coeffs = len(mask_k[0]) # number of coefficients in bin k 

292 if n_coeffs > 0: 292 ↛ 297line 292 didn't jump to line 297 because the condition on line 292 was always true

293 ps_array[t, k - 1] = ( 

294 np.sum(sigma_2[mask_k]) / n_coeffs 

295 ) # average power in bin k 

296 else: 

297 ps_array[t, k - 1] = 0.0 

298 

299 return ps_array 

300 

301 

302def _create_alpha_matrix(Ny, Nx): 

303 """Construct an array of 2D wavenumbers from 2D wavenumber pair. 

304 

305 Parameters 

306 ---------- 

307 Ny, Nx: 

308 Dimensions of the 2D field for which the power spectra is calculated. Used to 

309 create the array of 2D wavenumbers. Each Ny, Nx pair is associated with a 

310 single-scale parameter. 

311 

312 Returns 

313 ------- 

314 alpha_matrix: 

315 normalisation of 2D wavenumber axes, transforming the spectral domain into 

316 an elliptic coordinate system. 

317 

318 """ 

319 # Create x_indices: each row is [1, 2, ..., Nx] 

320 x_indices = np.tile(np.arange(1, Nx + 1), (Ny, 1)) 

321 

322 # Create y_indices: each column is [1, 2, ..., Ny] 

323 y_indices = np.tile(np.arange(1, Ny + 1).reshape(Ny, 1), (1, Nx)) 

324 

325 # Compute alpha_matrix 

326 alpha_matrix = np.sqrt((x_indices**2) / Nx**2 + (y_indices**2) / Ny**2) 

327 

328 return alpha_matrix