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

89 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-27 13:04 +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 

127 if callable(operator): 

128 return operator 

129 else: 

130 raise TypeError 

131 except (AttributeError, TypeError) as err: 

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

133 

134 

135def _write_metadata(recipe: dict): 

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

137 metadata = recipe.copy() 

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

139 metadata.pop("steps", None) 

140 # To remove long variable names with suffix 

141 if "title" in metadata: 

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

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

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

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

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

147 

148 

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

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

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

152 kwargs = {} 

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

154 if key == "operator": 

155 operator = get_operator(value) 

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

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

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

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

160 else: 

161 kwargs[key] = value 

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

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

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

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

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

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

168 if first_arg not in kwargs: 

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

170 return operator(step_input, **kwargs) 

171 else: 

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

173 return operator(**kwargs) 

174 

175 

176def create_diagnostic_archive(): 

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

178 output_directory: Path = Path.cwd() 

179 archive_path = output_directory / "diagnostic.zip" 

180 with zipfile.ZipFile( 

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

182 ) as archive: 

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

184 # Check the archive doesn't add itself. 

185 if not file.samefile(archive_path): 

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

187 

188 

189def execute_recipe( 

190 recipe: dict, 

191 output_directory: Path, 

192 style_file: Path | None = None, 

193 plot_resolution: int | None = None, 

194 skip_write: bool | None = None, 

195) -> None: 

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

197 

198 Parameters 

199 ---------- 

200 recipe: dict 

201 Parsed recipe. 

202 output_directory: Path 

203 Pathlike indicating desired location of output. 

204 style_file: Path, optional 

205 Path to a style file. 

206 plot_resolution: int, optional 

207 Resolution of plots in dpi. 

208 skip_write: bool, optional 

209 Skip saving processed output alongside plots. 

210 

211 Raises 

212 ------ 

213 FileNotFoundError 

214 The recipe or input file cannot be found. 

215 FileExistsError 

216 The output directory as actually a file. 

217 ValueError 

218 The recipe is not well formed. 

219 TypeError 

220 The provided recipe is not a stream or Path. 

221 """ 

222 # Create output directory. 

223 try: 

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

225 except (FileExistsError, NotADirectoryError): 

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

227 raise 

228 steps = recipe["steps"] 

229 

230 # Execute the steps in a recipe. 

231 original_working_directory = Path.cwd() 

232 try: 

233 os.chdir(output_directory) 

234 diagnostic_log = logging.FileHandler( 

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

236 ) 

237 diagnostic_log.setFormatter( 

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

239 ) 

240 logger.addHandler(diagnostic_log) 

241 # Create metadata file used by some steps. 

242 if style_file: 

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

244 if plot_resolution: 

245 recipe["plot_resolution"] = plot_resolution 

246 if skip_write: 

247 recipe["skip_write"] = skip_write 

248 _write_metadata(recipe) 

249 

250 # Execute the recipe. 

251 step_input = None 

252 for step in steps: 

253 step_input = _step_parser(step, step_input) 

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

255 

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

257 create_diagnostic_archive() 

258 finally: 

259 os.chdir(original_working_directory)