Coverage for src/CSET/cset_workflow/app/fetch_fcst/bin/fetch_data.py: 78%

117 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"""Retrieve the files from the filesystem for the current cycle point.""" 

16 

17import abc 

18import ast 

19import glob 

20import itertools 

21import logging 

22import os 

23import ssl 

24import sys 

25import urllib.parse 

26import urllib.request 

27from concurrent.futures import ThreadPoolExecutor 

28from datetime import datetime, timedelta 

29from pathlib import Path 

30from typing import Literal, Self 

31 

32import isodate 

33 

34logging.basicConfig( 

35 level=os.getenv("LOGLEVEL", "INFO"), 

36 format="%(asctime)s %(levelname)s %(message)s", 

37 stream=sys.stdout, 

38) 

39logger = logging.getLogger(__name__) 

40 

41 

42class FileRetrieverABC(abc.ABC): 

43 """Abstract base class for retrieving files from a data source. 

44 

45 The `get_file` method must be defined. Optionally the __enter__ and __exit__ 

46 methods maybe be overridden to add setup or cleanup code. 

47 

48 The class is designed to be used as a context manager, so that resources can 

49 be cleaned up after the retrieval is complete. All the files of a model are 

50 retrieved within a single context manager block, within which the `get_file` 

51 method is called for each file path. 

52 """ 

53 

54 def __enter__(self) -> Self: 

55 """Initialise the file retriever.""" 

56 logger.debug("Initialising FileRetriever.") 

57 return self 

58 

59 def __exit__(self, exc_type, exc_value, traceback): 

60 """Clean up the file retriever.""" 

61 logger.debug("Tearing down FileRetriever.") 

62 

63 @abc.abstractmethod 

64 def get_file(self, file_path: str, output_dir: str) -> bool: # pragma: no cover 

65 """Save a file from the data source to the output directory. 

66 

67 Not all of the given paths will exist, so FileNotFoundErrors should be 

68 logged, but not raised. 

69 

70 Implementations should be thread safe, as the method is called from 

71 multiple threads. 

72 

73 Parameters 

74 ---------- 

75 file_path: str 

76 Path of the file to copy on the data source. It may contain patterns 

77 like globs, which will be expanded in a system specific manner. 

78 output_dir: str 

79 Path to filesystem directory into which the file should be copied. 

80 

81 Returns 

82 ------- 

83 bool: 

84 True if files were transferred, otherwise False. 

85 """ 

86 raise NotImplementedError 

87 

88 

89class FilesystemFileRetriever(FileRetrieverABC): 

90 """Retrieve files from the filesystem.""" 

91 

92 def get_file(self, file_path: str, output_dir: str) -> bool: 

93 """Save a file from the filesystem to the output directory. 

94 

95 Parameters 

96 ---------- 

97 file_path: str 

98 Path of the file to copy on the filesystem. It may contain patterns 

99 like globs, which will be expanded in a system specific manner. 

100 output_dir: str 

101 Path to filesystem directory into which the file should be copied. 

102 

103 Returns 

104 ------- 

105 bool: 

106 True if files were transferred, otherwise False. 

107 """ 

108 file_paths = glob.glob(os.path.expanduser(file_path)) 

109 logger.debug("Copying files:\n%s", "\n".join(file_paths)) 

110 if not file_paths: 

111 logger.warning("file_path does not match any files: %s", file_path) 

112 any_files_copied = False 

113 for f in file_paths: 

114 file = Path(f).absolute() 

115 try: 

116 # Save to a filename derived from the full path, to 

117 # differentiate similarly named files from different 

118 # directories. 

119 # `}` replaces `/` as it can be in file names. 

120 os.symlink(file, f"{output_dir}/{'}'.join(file.parts)}") 

121 any_files_copied = True 

122 except OSError as err: 

123 logger.warning("Failed to copy %s, error: %s", file, err) 

124 return any_files_copied 

125 

126 

127class HTTPFileRetriever(FileRetrieverABC): 

128 """Retrieve files via HTTP.""" 

129 

130 def get_file(self, file_path: str, output_dir: str) -> bool: 

131 """Save a file from a HTTP address to the output directory. 

132 

133 Parameters 

134 ---------- 

135 file_path: str 

136 Path of the file to copy on MASS. It may contain patterns like 

137 globs, which will be expanded in a system specific manner. 

138 output_dir: str 

139 Path to filesystem directory into which the file should be copied. 

140 

141 Returns 

142 ------- 

143 bool: 

144 True if files were transferred, otherwise False. 

145 """ 

146 ctx = ssl.create_default_context() 

147 # Needed to enable compatibility with malformed iBoss TLS certificates. 

148 ctx.verify_flags &= ~ssl.VERIFY_X509_STRICT 

149 save_path = ( 

150 f"{output_dir.removesuffix('/')}/" 

151 + urllib.parse.urlparse(file_path).path.split("/")[-1] 

152 ) 

153 any_files_copied = False 

154 try: 

155 with urllib.request.urlopen(file_path, timeout=30, context=ctx) as response: 

156 with open(save_path, "wb") as fp: 

157 # Read in 1 MiB chunks so data needn't fit in memory. 

158 while data := response.read(1024 * 1024): 

159 fp.write(data) 

160 any_files_copied = True 

161 except OSError as err: 

162 logger.warning("Failed to retrieve %s, error: %s", file_path, err) 

163 return any_files_copied 

164 

165 

166def _get_needed_environment_variables() -> dict: 

167 """Load the needed variables from the environment.""" 

168 variables = { 

169 "raw_path": os.environ["DATA_PATH"], 

170 "date_type": os.environ["DATE_TYPE"], 

171 "data_time": datetime.fromisoformat(os.environ["CYLC_TASK_CYCLE_POINT"]), 

172 "forecast_length": isodate.parse_duration(os.environ["ANALYSIS_LENGTH"]), 

173 "forecast_offset": isodate.parse_duration(os.environ["ANALYSIS_OFFSET"]), 

174 "model_identifier": os.environ["MODEL_IDENTIFIER"], 

175 "rose_datac": os.environ["ROSE_DATAC"], 

176 } 

177 try: 

178 variables["data_period"] = isodate.parse_duration(os.environ["DATA_PERIOD"]) 

179 except KeyError: 

180 # Data period is not needed for initiation time. 

181 if variables["date_type"] != "initiation": 

182 raise 

183 variables["data_period"] = None 

184 logger.debug("Environment variables loaded: %s", variables) 

185 return variables 

186 

187 

188def _get_needed_environment_variables_obs() -> dict: 

189 """Load the needed variables from the environment.""" 

190 variables = { 

191 "subtype": ast.literal_eval(os.environ.get("OBS_SUBTYPE")), 

192 "data_time": datetime.fromisoformat(os.environ["CYLC_TASK_CYCLE_POINT"]), 

193 "forecast_length": isodate.parse_duration(os.environ["ANALYSIS_LENGTH"]), 

194 "obs_fields": ast.literal_eval(os.environ["SURFACE_SYNOP_FIELDS"]), 

195 "model_identifier": "OBS", 

196 "wmo_nmbrs": ast.literal_eval(os.environ.get("WMO_BLOCK_STTN_NMBRS")) 

197 if len(os.environ.get("WMO_BLOCK_STTN_NMBRS")) > 0 

198 else None, 

199 "subarea_extent": ast.literal_eval(os.environ.get("SUBAREA_EXTENT")) 

200 if len(os.environ.get("SUBAREA_EXTENT")) > 0 

201 else None, 

202 "obs_interval": isodate.parse_duration(os.environ["SURFACE_SYNOP_INTERVAL"]), 

203 "obs_offset": isodate.parse_duration(os.environ["SURFACE_SYNOP_OFFSET"]), 

204 "rose_datac": os.environ["ROSE_DATAC"], 

205 } 

206 logger.debug("Environment variables loaded: %s", variables) 

207 return variables 

208 

209 

210def _template_file_path( 

211 raw_path: str, 

212 date_type: Literal["validity", "initiation"], 

213 data_time: datetime, 

214 forecast_length: timedelta, 

215 forecast_offset: timedelta, 

216 data_period: timedelta, 

217) -> list[str]: 

218 """Fill time placeholders to generate a file path to fetch.""" 

219 placeholder_times: list[datetime] = [] 

220 lead_times: list[timedelta] = [] 

221 match date_type: 

222 case "validity": 

223 date = data_time 

224 while date < data_time + forecast_length: 

225 placeholder_times.append(date) 

226 date += data_period 

227 case "initiation": 

228 placeholder_times.append(data_time) 

229 lead_time = forecast_offset 

230 while lead_time < forecast_length: 

231 lead_times.append(lead_time) 

232 lead_time += data_period 

233 case _: 

234 raise ValueError(f"Invalid date type: {date_type}") 

235 

236 paths: set[str] = set() 

237 for placeholder_time in placeholder_times: 

238 # Expand out all other format strings. 

239 path = placeholder_time.strftime(os.path.expandvars(raw_path)) 

240 if lead_times: 

241 # Expand out lead time format strings, %N. 

242 for lead_time in lead_times: 

243 # BUG: Will not respect escaped % signs, e.g: %%N. 

244 paths.add( 

245 path.replace("%N", f"{int(lead_time.total_seconds()) // 3600:03d}") 

246 ) 

247 else: 

248 paths.add(path) 

249 return sorted(paths) 

250 

251 

252def fetch_data(file_retriever: FileRetrieverABC): 

253 """Fetch the data for a model. 

254 

255 The following environment variables need to be set: 

256 * ANALYSIS_OFFSET 

257 * ANALYSIS_LENGTH 

258 * CYLC_TASK_CYCLE_POINT 

259 * DATA_PATH 

260 * DATA_PERIOD 

261 * DATE_TYPE 

262 * MODEL_IDENTIFIER 

263 * ROSE_DATAC 

264 

265 Parameters 

266 ---------- 

267 file_retriever: FileRetriever 

268 FileRetriever implementation to use. 

269 

270 Raises 

271 ------ 

272 FileNotFound: 

273 If no files are found for the model, across all tried paths. 

274 """ 

275 v = _get_needed_environment_variables() 

276 

277 # Prepare output directory. 

278 cycle_data_dir = f"{v['rose_datac']}/data/{v['model_identifier']}" 

279 os.makedirs(cycle_data_dir, exist_ok=True) 

280 logger.debug("Output directory: %s", cycle_data_dir) 

281 

282 # Get file paths. 

283 paths = _template_file_path( 

284 v["raw_path"], 

285 v["date_type"], 

286 v["data_time"], 

287 v["forecast_length"], 

288 v["forecast_offset"], 

289 v["data_period"], 

290 ) 

291 logger.info("Retrieving paths:\n%s", "\n".join(paths)) 

292 

293 # Use file retriever to transfer data with multiple threads. 

294 with file_retriever() as retriever, ThreadPoolExecutor() as executor: 

295 files_found = executor.map( 

296 retriever.get_file, paths, itertools.repeat(cycle_data_dir) 

297 ) 

298 # Exhaust the iterator with list so all futures get resolved before we 

299 # exit the with block, ensuring all files are retrieved. 

300 any_files_found = any(list(files_found)) 

301 if not any_files_found: 

302 raise FileNotFoundError("No files found for model!") 

303 

304 

305def fetch_obs(obs_retriever: FileRetrieverABC): 

306 """Fetch the observations corresponding to a model run. 

307 

308 The following environment variables need to be set: 

309 * ANALYSIS_OFFSET 

310 * ANALYSIS_LENGTH 

311 * CYLC_TASK_CYCLE_POINT 

312 * DATA_PATH 

313 * DATA_PERIOD 

314 * DATE_TYPE 

315 * MODEL_IDENTIFIER 

316 * ROSE_DATAC 

317 

318 Parameters 

319 ---------- 

320 obs_retriever: ObsRetriever 

321 ObsRetriever implementation to use. Defaults to FilesystemFileRetriever. 

322 

323 Raises 

324 ------ 

325 FileNotFound: 

326 If no observations are available. 

327 """ 

328 v = _get_needed_environment_variables_obs() 

329 

330 # Prepare output directory. 

331 cycle_obs_dir = f"{v['rose_datac']}/data/OBS" 

332 os.makedirs(cycle_obs_dir, exist_ok=True) 

333 logger.debug("Output directory: %s", cycle_obs_dir) 

334 

335 # Loop over requested obs subtypes. 

336 for subtype in v["subtype"]: 

337 obs_base_path = ( 

338 subtype 

339 + "_" 

340 + "%Y%m%dT%H%MZ_dt_" 

341 + str(int(v["forecast_length"].total_seconds() // 3600)).zfill(3) 

342 + ".nc" 

343 ) 

344 paths = _template_file_path( 

345 obs_base_path, 

346 "initiation", 

347 v["data_time"], 

348 v["forecast_length"], 

349 timedelta(seconds=0), 

350 v["obs_interval"], 

351 ) 

352 logger.info("Retrieving paths:\n%s", "\n".join(paths)) 

353 

354 # Use obs retriever to transfer data with multiple threads. 

355 # We shouldn't need to iterate as we do for the forecast data 

356 # because these files will be smaller. 

357 try: 

358 obs_retriever.get_file( 

359 paths[0], 

360 subtype, 

361 v["obs_fields"], 

362 v["data_time"], 

363 v["obs_offset"], 

364 v["forecast_length"], 

365 v["obs_interval"], 

366 cycle_obs_dir, 

367 wmo_nmbrs=v["wmo_nmbrs"], 

368 subarea_extent=v["subarea_extent"], 

369 ) 

370 except Exception as exc: 

371 raise ValueError("No observations available.") from exc