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

89 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-10 13:27 +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 dfss, 

36 ensembles, 

37 feature, 

38 filters, 

39 fluxes, 

40 humidity, 

41 imageprocessing, 

42 mesoscale, 

43 misc, 

44 plot, 

45 power_spectrum, 

46 precipitation, 

47 pressure, 

48 read, 

49 regrid, 

50 scoreswrappers, 

51 temperature, 

52 transect, 

53 wind, 

54 write, 

55) 

56 

57# Exported operators & functions to use elsewhere. 

58__all__ = [ 

59 "ageofair", 

60 "aggregate", 

61 "aviation", 

62 "collapse", 

63 "constraints", 

64 "convection", 

65 "ensembles", 

66 "execute_recipe", 

67 "feature", 

68 "filters", 

69 "fluxes", 

70 "humidity", 

71 "get_operator", 

72 "imageprocessing", 

73 "mesoscale", 

74 "misc", 

75 "plot", 

76 "power_spectrum", 

77 "precipitation", 

78 "pressure", 

79 "read", 

80 "regrid", 

81 "temperature", 

82 "scoreswrappers", 

83 "transect", 

84 "wind", 

85 "write", 

86 "dfss", 

87] 

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 logging.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 AttributeError 

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 logging.debug("Executing step: %s", step) 

151 kwargs = {} 

152 for key in step.keys(): 

153 if key == "operator": 

154 operator = get_operator(step["operator"]) 

155 logging.info("operator: %s", step["operator"]) 

156 elif isinstance(step[key], dict) and "operator" in step[key]: 

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

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

159 else: 

160 kwargs[key] = step[key] 

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

162 logging.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 logging.debug("first_arg: %s", first_arg) 

167 if first_arg not in kwargs: 

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

169 return operator(step_input, **kwargs) 

170 else: 

171 logging.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, 

192 plot_resolution: int = None, 

193 skip_write: bool = 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) as err: 

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

226 raise err 

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 logger = logging.getLogger(__name__) 

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)