Coverage for src/CSET/operators/__init__.py: 100%

89 statements  

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

1# © Crown copyright, Met Office (2022-2026) 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"""Subpackage contains all of CSET's operators.""" 

16 

17import inspect 

18import json 

19import logging 

20import os 

21import zipfile 

22from pathlib import Path 

23 

24from iris import FUTURE 

25 

26# Import operators here so they are exported for use by recipes. 

27import CSET.operators 

28from CSET.operators import ( 

29 ageofair, 

30 aggregate, 

31 aviation, 

32 collapse, 

33 constraints, 

34 convection, 

35 ensembles, 

36 feature, 

37 filters, 

38 fluxes, 

39 humidity, 

40 imageprocessing, 

41 mesoscale, 

42 misc, 

43 plot, 

44 power_spectrum, 

45 precipitation, 

46 pressure, 

47 read, 

48 regrid, 

49 scoreswrappers, 

50 temperature, 

51 transect, 

52 wind, 

53 write, 

54) 

55 

56# Exported operators & functions to use elsewhere. 

57__all__ = [ 

58 "ageofair", 

59 "aggregate", 

60 "aviation", 

61 "collapse", 

62 "constraints", 

63 "convection", 

64 "ensembles", 

65 "execute_recipe", 

66 "feature", 

67 "filters", 

68 "fluxes", 

69 "get_operator", 

70 "humidity", 

71 "imageprocessing", 

72 "mesoscale", 

73 "misc", 

74 "plot", 

75 "power_spectrum", 

76 "precipitation", 

77 "pressure", 

78 "read", 

79 "regrid", 

80 "scoreswrappers", 

81 "temperature", 

82 "transect", 

83 "wind", 

84 "write", 

85] 

86 

87logger = logging.getLogger(__name__) 

88 

89# Stop iris giving a warning whenever it loads something. 

90FUTURE.datum_support = True 

91# Stop iris giving a warning whenever it saves something. 

92FUTURE.save_split_attrs = True 

93# Accept microsecond precision in iris times. 

94FUTURE.date_microseconds = True 

95 

96 

97def get_operator(name: str): 

98 """Get an operator by its name. 

99 

100 Parameters 

101 ---------- 

102 name: str 

103 The name of the desired operator. 

104 

105 Returns 

106 ------- 

107 function 

108 The named operator. 

109 

110 Raises 

111 ------ 

112 ValueError 

113 If name is not an operator. 

114 

115 Examples 

116 -------- 

117 >>> CSET.operators.get_operator("read.read_cubes") 

118 <function read_cubes at 0x7fcf9353c8b0> 

119 """ 

120 logger.debug("get_operator(%s)", name) 

121 try: 

122 name_sections = name.split(".") 

123 operator = CSET.operators 

124 for section in name_sections: 

125 operator = getattr(operator, section) 

126 if callable(operator): 

127 return operator 

128 else: 

129 raise TypeError 

130 except (AttributeError, TypeError) as err: 

131 raise ValueError(f"Unknown operator: {name}") from err 

132 

133 

134def _write_metadata(recipe: dict): 

135 """Write a meta.json file in the CWD.""" 

136 metadata = recipe.copy() 

137 # Remove steps, as not needed, and might contain non-serialisable types. 

138 metadata.pop("steps", None) 

139 # To remove long variable names with suffix 

140 if "title" in metadata: 

141 metadata["title"] = metadata["title"].replace("_for_climate_averaging", "") 

142 metadata["title"] = metadata["title"].replace("_radiative_timestep", "") 

143 metadata["title"] = metadata["title"].replace("_maximum_random_overlap", "") 

144 with open("meta.json", "wt", encoding="UTF-8") as fp: 

145 json.dump(metadata, fp, indent=2) 

146 

147 

148def _step_parser(step: dict, step_input: any) -> str: 

149 """Execute a recipe step, recursively executing any sub-steps.""" 

150 logger.debug("Executing step: %s", step) 

151 kwargs = {} 

152 for key, value in step.items(): 

153 if key == "operator": 

154 operator = get_operator(value) 

155 logger.info("operator: %s", value) 

156 elif isinstance(value, dict) and "operator" in value: 

157 logger.debug("Recursing into argument: %s", key) 

158 kwargs[key] = _step_parser(value, step_input) 

159 else: 

160 kwargs[key] = value 

161 logger.debug("args: %s", kwargs) 

162 logger.debug("step_input: %s", step_input) 

163 # If first argument of operator is explicitly defined, use that rather 

164 # than step_input. This is known through introspection of the operator. 

165 first_arg = next(iter(inspect.signature(operator).parameters.keys())) 

166 logger.debug("first_arg: %s", first_arg) 

167 if first_arg not in kwargs: 

168 logger.debug("first_arg not in kwargs, using step_input.") 

169 return operator(step_input, **kwargs) 

170 else: 

171 logger.debug("first_arg in kwargs.") 

172 return operator(**kwargs) 

173 

174 

175def create_diagnostic_archive(): 

176 """Create archive for easy download of plots and data.""" 

177 output_directory: Path = Path.cwd() 

178 archive_path = output_directory / "diagnostic.zip" 

179 with zipfile.ZipFile( 

180 archive_path, "w", compression=zipfile.ZIP_DEFLATED 

181 ) as archive: 

182 for file in output_directory.rglob("*"): 

183 # Check the archive doesn't add itself. 

184 if not file.samefile(archive_path): 

185 archive.write(file, arcname=file.relative_to(output_directory)) 

186 

187 

188def execute_recipe( 

189 recipe: dict, 

190 output_directory: Path, 

191 style_file: Path | None = None, 

192 plot_resolution: int | None = None, 

193 skip_write: bool | None = None, 

194) -> None: 

195 """Parse and executes the steps from a recipe file. 

196 

197 Parameters 

198 ---------- 

199 recipe: dict 

200 Parsed recipe. 

201 output_directory: Path 

202 Pathlike indicating desired location of output. 

203 style_file: Path, optional 

204 Path to a style file. 

205 plot_resolution: int, optional 

206 Resolution of plots in dpi. 

207 skip_write: bool, optional 

208 Skip saving processed output alongside plots. 

209 

210 Raises 

211 ------ 

212 FileNotFoundError 

213 The recipe or input file cannot be found. 

214 FileExistsError 

215 The output directory as actually a file. 

216 ValueError 

217 The recipe is not well formed. 

218 TypeError 

219 The provided recipe is not a stream or Path. 

220 """ 

221 # Create output directory. 

222 try: 

223 output_directory.mkdir(parents=True, exist_ok=True) 

224 except (FileExistsError, NotADirectoryError): 

225 logger.error("Output directory is a file. %s", output_directory) 

226 raise 

227 steps = recipe["steps"] 

228 

229 # Execute the steps in a recipe. 

230 original_working_directory = Path.cwd() 

231 try: 

232 os.chdir(output_directory) 

233 diagnostic_log = logging.FileHandler( 

234 filename="CSET.log", mode="w", encoding="UTF-8" 

235 ) 

236 diagnostic_log.setFormatter( 

237 logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s") 

238 ) 

239 logger.addHandler(diagnostic_log) 

240 # Create metadata file used by some steps. 

241 if style_file: 

242 recipe["style_file_path"] = str(style_file) 

243 if plot_resolution: 

244 recipe["plot_resolution"] = plot_resolution 

245 if skip_write: 

246 recipe["skip_write"] = skip_write 

247 _write_metadata(recipe) 

248 

249 # Execute the recipe. 

250 step_input = None 

251 for step in steps: 

252 step_input = _step_parser(step, step_input) 

253 logger.info("Recipe output:\n%s", step_input) 

254 

255 logger.info("Creating diagnostic archive.") 

256 create_diagnostic_archive() 

257 finally: 

258 os.chdir(original_working_directory)