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

156 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-11 09:22 +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( 

32 cubes: iris.cube.Cube | iris.cube.CubeList, 

33): 

34 """Wrap power spectrum code. 

35 

36 This function is a wrapper that handles power spectrum 

37 calculations for both single cubes and cube lists and includes 

38 ensembles. 

39 

40 The input cube is split into a cube for each model, time, 

41 forecast_reference_time and realization. A power spectrum is 

42 calculated for each before combining them into one cube ahead of 

43 plotting. Attributes (model_name, realization, 

44 forecast_reference_time) are retained from original cube. 

45 

46 In the case of a CubeList containing multiple models, multiple 

47 cases or ensembles, the function iterates through each cube and 

48 calculates an individual power spectrum. 

49 

50 In the case of a single cube, it directly calculates the power 

51 spectrum. 

52 

53 [Denis_etal_2002]_ 

54 

55 Parameters 

56 ---------- 

57 cubes : iris.cube.Cube | iris.cube.CubeList 

58 Field over which to calculate a power spectrum. 

59 

60 Returns 

61 ------- 

62 iris.cube.Cube | iris.cube.CubeList 

63 Power-spectrum cube, or a CubeList for multiple models. 

64 """ 

65 out = iris.cube.CubeList() 

66 

67 for input_cube in iter_maybe(cubes): 

68 model = input_cube.attributes.get("model_name") 

69 

70 # Check whether data has realization and/or 

71 # forecast_reference_time coordinates 

72 has_realization = bool(input_cube.coords("realization")) 

73 has_frt = bool(input_cube.coords("forecast_reference_time")) 

74 

75 # Build a list containing: 

76 # (cube_slice, realization_value, frt_value) 

77 # 

78 # Retain realization and frt values and restore to 

79 # power-spectrum cubes later. 

80 if has_realization and has_frt: 

81 # Both realization and forecast_reference_time coords 

82 members = [] 

83 

84 for frt_cube in input_cube.slices_over("forecast_reference_time"): 

85 frt = frt_cube.coord("forecast_reference_time").points[0] 

86 

87 for member in frt_cube.slices_over("realization"): 

88 realiz = member.coord("realization").points[0] 

89 

90 members.append((member, realiz, frt)) 

91 

92 elif has_realization: 92 ↛ 94line 92 didn't jump to line 94 because the condition on line 92 was never true

93 # Only realization coord 

94 members = [] 

95 

96 for member in input_cube.slices_over("realization"): 

97 realiz = member.coord("realization").points[0] 

98 

99 members.append((member, realiz, None)) 

100 

101 elif has_frt: 

102 # Only forecast_reference_time coord. 

103 members = [] 

104 

105 for frt_cube in input_cube.slices_over("forecast_reference_time"): 

106 frt = frt_cube.coord("forecast_reference_time").points[0] 

107 

108 members.append((frt_cube, None, frt)) 

109 

110 else: 

111 # Neither realization nor forecast_reference_time coords. 

112 members = [(input_cube, None, None)] 

113 

114 member_power_spectra = iris.cube.CubeList() 

115 

116 # Calculate the spectrum separately for every member/FRT 

117 # combination. 

118 for member, realiz, frt in members: 

119 # Calculate power spectrum 

120 ps = _power_spectrum(member) 

121 

122 # Attach model name if available 

123 if model: 

124 ps.attributes["model_name"] = model 

125 

126 # Add the correct realization from the parent cube. 

127 if realiz is not None: 

128 ps.add_aux_coord( 

129 iris.coords.AuxCoord( 

130 realiz, 

131 long_name="realization", 

132 units="1", 

133 ) 

134 ) 

135 

136 ps = iris.util.new_axis( 

137 ps, 

138 "realization", 

139 ) 

140 

141 # Add the forecast_reference_time from the parent cube. 

142 if frt is not None: 142 ↛ 157line 142 didn't jump to line 157 because the condition on line 142 was always true

143 ps.add_aux_coord( 

144 iris.coords.AuxCoord( 

145 frt, 

146 standard_name=("forecast_reference_time"), 

147 units=member.coord("forecast_reference_time").units, 

148 ) 

149 ) 

150 

151 # Promote to dimension coordinate. 

152 ps = iris.util.new_axis( 

153 ps, 

154 "forecast_reference_time", 

155 ) 

156 

157 member_power_spectra.append(ps) 

158 

159 # If both realization and FRT vary. Concatenate in stages: 

160 # 

161 # 1. Concatenate realizations within each FRT. 

162 # 2. Concatenate the resulting cubes over FRT. 

163 if has_realization and has_frt: 

164 # Both realization and forecast_reference_time coords 

165 frt_power_spectra = iris.cube.CubeList() 

166 

167 frt_values = np.unique( 

168 [ 

169 ps_cube.coord("forecast_reference_time").points[0] 

170 for ps_cube in member_power_spectra 

171 ] 

172 ) 

173 

174 for frt in frt_values: 

175 cubes_for_frt = iris.cube.CubeList( 

176 [ 

177 ps_cube 

178 for ps_cube in member_power_spectra 

179 if (ps_cube.coord("forecast_reference_time").points[0] == frt) 

180 ] 

181 ) 

182 

183 # Within one FRT, realization is the coordinate 

184 # that varies. 

185 frt_cube = cubes_for_frt.concatenate_cube() 

186 

187 frt_power_spectra.append(frt_cube) 

188 

189 # If there is only one FRT, no second concatenation is 

190 # required. 

191 if len(frt_power_spectra) == 1: 

192 combined_cube = frt_power_spectra[0] 

193 

194 else: 

195 # There are multiple FRTs. 

196 # 

197 # If every FRT has one time point, time and FRT vary 

198 # together. Make time an AuxCoord attached to the FRT 

199 # dimension so Iris has one concatenation dimension. 

200 one_time_per_frt = all( 

201 frt_cube.coord("time").shape == (1,) 

202 for frt_cube in frt_power_spectra 

203 ) 

204 

205 if one_time_per_frt: 

206 cubes_for_frt_concat = iris.cube.CubeList() 

207 

208 for frt_cube in frt_power_spectra: 

209 frt_cube = frt_cube.copy() 

210 

211 time_coord = frt_cube.coord("time").copy() 

212 

213 time_dims = frt_cube.coord_dims("time") 

214 

215 time_dim = time_dims[0] 

216 

217 # Select the only point on the time 

218 # dimension. This removes that dimension but 

219 # initially leaves time as a scalar coord. 

220 index = [slice(None)] * frt_cube.ndim 

221 index[time_dim] = 0 

222 

223 frt_cube = frt_cube[tuple(index)] 

224 

225 frt_cube.remove_coord("time") 

226 

227 frt_dims = frt_cube.coord_dims("forecast_reference_time") 

228 

229 frt_dim = frt_dims[0] 

230 

231 # Attach the one time point to the FRT 

232 # dimension. 

233 frt_cube.add_aux_coord( 

234 time_coord, 

235 data_dims=(frt_dim,), 

236 ) 

237 

238 cubes_for_frt_concat.append(frt_cube) 

239 

240 combined_cube = cubes_for_frt_concat.concatenate_cube() 

241 

242 else: 

243 # If all FRT cubes have the same time coordinate, 

244 # only FRT varies and normal concatenation should 

245 # work. 

246 first_time = frt_power_spectra[0].coord("time") 

247 

248 matching_times = all( 

249 frt_cube.coord("time") == first_time 

250 for frt_cube in frt_power_spectra[1:] 

251 ) 

252 

253 if not matching_times: 253 ↛ 262line 253 didn't jump to line 262 because the condition on line 253 was always true

254 raise ValueError( 

255 "Cannot combine power spectra: " 

256 "multiple forecast reference times " 

257 "have different multi-point time " 

258 "coordinates." 

259 ) 

260 

261 # Combine individual cubes into single cube. 

262 combined_cube = frt_power_spectra.concatenate_cube() 

263 

264 else: 

265 # Only one of realization or FRT varies, or neither 

266 # exists. In those cases only one concatenation axis is 

267 # required. 

268 # Combine individual cubes into single cube. 

269 if len(member_power_spectra) == 1: 269 ↛ 272line 269 didn't jump to line 272 because the condition on line 269 was always true

270 combined_cube = member_power_spectra[0] 

271 else: 

272 combined_cube = member_power_spectra.concatenate_cube() 

273 

274 combined_cube = iris.util.squeeze(combined_cube) 

275 

276 out.append(combined_cube) 

277 

278 # Directly return cube if only one. 

279 if len(out) == 1: 

280 return out[0] 

281 

282 return out 

283 

284 

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

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

287 

288 Parameters 

289 ---------- 

290 cube: Cube 

291 Data to plot as power spectrum. 

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

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

294 for plotting. 

295 

296 Returns 

297 ------- 

298 iris.cube.Cube 

299 The power spectrum of the data. 

300 To be plotted and aggregation performed after. 

301 

302 Raises 

303 ------ 

304 ValueError 

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

306 TypeError 

307 If the cube isn't a Cube. 

308 """ 

309 # Extract time coordinate and convert to datetime 

310 time_coord = cube.coord("time") 

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

312 

313 if cube.ndim == 2: 

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

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

316 elif cube.ndim == 3: 

317 cube_3d = cube.data 

318 else: 

319 raise ValueError( 

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

321 ) 

322 

323 # Calculate spectrum 

324 ps_array = _DCT_ps(cube_3d) 

325 

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

327 # resolutions. Try to find appropriate spatial coordinates. 

328 coord_pairs = ( 

329 ("projection_x_coordinate", "projection_y_coordinate"), 

330 ("grid_latitude", "grid_longitude"), 

331 ("latitude", "longitude"), 

332 ) 

333 for x_coord_name, y_coord_name in coord_pairs: 

334 try: 

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

336 x_coord = cube.coord(x_coord_name) 

337 y_coord = cube.coord(y_coord_name) 

338 except iris.exceptions.CoordinateNotFoundError: 

339 continue 

340 logger.debug( 

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

342 x_coord_name, 

343 y_coord_name, 

344 ) 

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

346 else: 

347 # Raise error if no usable coords found. 

348 raise ValueError( 

349 "Could not find appropriate spatial coordinates. " 

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

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

352 ) 

353 

354 # Calculate grid spacing. 

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

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

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

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

359 R_earth = 6371000 # meters 

360 lat_mid = np.mean(x_coord.points) 

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

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

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

364 

365 # Convert wavenumber into physically meaningful wavenumber coordinate in 

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

367 ps_len = ps_array.shape[1] 

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

369 physical_wavenumbers = k_indices / domain_size_km # cycles/km 

370 

371 # Create a new DimCoord with physical wavenumber 

372 physical_wavenumbers_coord = iris.coords.DimCoord( 

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

374 ) 

375 

376 # Calculate wavelength and add as auxiliary coordinate 

377 wavelengths = domain_size_km / k_indices # km 

378 wavelength_coord = iris.coords.AuxCoord( 

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

380 ) 

381 

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

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

384 ps_array = ps_array[np.newaxis, :] 

385 

386 # Prepare time coordinate 

387 numeric_time = time_coord.units.date2num(time_points) 

388 numeric_time = np.atleast_1d(numeric_time) 

389 

390 # Make time coord length match the number of spectra 

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

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

393 

394 new_time_coord = iris.coords.DimCoord( 

395 numeric_time, 

396 standard_name="time", 

397 units=time_coord.units, 

398 ) 

399 

400 # Create output cube with physical coordinates 

401 ps_cube = iris.cube.Cube( 

402 ps_array, 

403 dim_coords_and_dims=[ 

404 (new_time_coord, 0), 

405 (physical_wavenumbers_coord, 1), 

406 ], 

407 long_name="power_spectral_density", 

408 ) 

409 

410 # Add wavelength as auxiliary coordinate 

411 # Realization coordinate is added in _calculate_power_spectrum 

412 ps_cube.add_aux_coord(wavelength_coord, data_dims=1) 

413 

414 return ps_cube 

415 

416 

417def _DCT_ps(y_3d): 

418 """Calculate power spectra for regional domains. 

419 

420 Parameters 

421 ---------- 

422 y_3d: 3D array 

423 3 dimensional array to calculate spectrum for. 

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

425 

426 Returns 

427 ------- 

428 ps_array: 

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

430 """ 

431 Nt, Ny, Nx = y_3d.shape 

432 

433 # Max coefficient 

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

435 

436 # Create alpha matrix (of wavenumbers) 

437 alpha_matrix = _create_alpha_matrix(Ny, Nx) 

438 

439 # Prepare output array 

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

441 

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

443 for t in range(Nt): 

444 y_2d = y_3d[t] 

445 

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

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

448 # cosine basis functions at different spatial frequencies. 

449 

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

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

452 

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

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

455 sigma_2 = fkk**2 / Nx / Ny 

456 

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

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

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

460 alpha = k / Nmin 

461 alpha_p1 = (k + 1) / Nmin 

462 

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

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

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

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

467 ps_array[t, k - 1] = ( 

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

469 ) # average power in bin k 

470 else: 

471 ps_array[t, k - 1] = 0.0 

472 

473 return ps_array 

474 

475 

476def _create_alpha_matrix(Ny, Nx): 

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

478 

479 Parameters 

480 ---------- 

481 Ny, Nx: 

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

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

484 single-scale parameter. 

485 

486 Returns 

487 ------- 

488 alpha_matrix: 

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

490 an elliptic coordinate system. 

491 

492 """ 

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

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

495 

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

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

498 

499 # Compute alpha_matrix 

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

501 

502 return alpha_matrix 

503 

504 

505def _coord_dimension(cube, coord_name): 

506 """Return the single dimension associated with a coordinate. 

507 

508 Parameters 

509 ---------- 

510 cube : iris.cube.Cube 

511 Cube containing the coordinate. 

512 coord_name : str 

513 Name of the coordinate for which to retrieve the associated 

514 dimension. 

515 

516 Returns 

517 ------- 

518 int 

519 Index of the dimension associated with the coordinate. 

520 

521 Raises 

522 ------ 

523 ValueError 

524 Raised if the coordinate is not associated with exactly one 

525 dimension. 

526 

527 """ 

528 coord_dims = cube.coord_dims(coord_name) 

529 

530 if len(coord_dims) != 1: 530 ↛ 533line 530 didn't jump to line 533 because the condition on line 530 was always true

531 raise ValueError(f"Expected {coord_name} to be a one-dimensional coordinate.") 

532 

533 return coord_dims[0]