Source code for hypergas.wind

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright (c) 2023-2024 HyperGas developers
#
# This file is part of hypergas.
#
# hypergas is a library to retrieve trace gases from hyperspectral satellite data
"""Calculate u10 and v10 for the scene."""

import logging
import os
from typing import Mapping

import requests
import numpy as np
import pandas as pd
import xarray as xr
import yaml
from xarray import DataArray

LOG = logging.getLogger(__name__)


[docs] class Wind(): """Calculate u10 and v10 from reanalysis wind data.""" def __init__(self, scn): LOG.info('Reading wind data ...') # load settings _dirname = os.path.dirname(__file__) with open(os.path.join(_dirname, 'config.yaml')) as f: settings = yaml.safe_load(f)['data'] self.era5_dir = os.path.join(_dirname, settings['era5_dir']) self.geosfp_dir = os.path.join(_dirname, settings['geosfp_dir']) # get the obs time self.scene = scn self.obs_time = scn['radiance'].attrs['start_time'] # calculate the lons and lats self.lons, self.lats = self.scene['radiance'].attrs['area'].get_lonlats() if not isinstance(self.lons, DataArray): self.lons = DataArray(self.lons, dims=("y", "x")) self.lats = DataArray(self.lats, dims=("y", "x")) # calculate the wind self.load_data()
[docs] def load_data(self): """Load wind data.""" # get the wind data u10_era5, v10_era5 = self.load_era5() u10_geosfp, v10_geosfp, sp_geosfp = self.load_geosfp() u10_openmeteo, v10_openmeteo, sp_openmeteo = self.load_openmeteo() # combine them into DataArrays new_dim = pd.Index(['ERA5', 'GEOS-FP', 'Open-Meteo'], name='source') u10 = xr.concat([u10_era5, u10_geosfp, u10_openmeteo], new_dim).rename('u10') v10 = xr.concat([v10_era5, v10_geosfp, v10_openmeteo], new_dim).rename('v10') # the SRON server does not save the pressure data sp_dim = pd.Index(['GEOS-FP', 'Open-Meteo'], name='source') sp = xr.concat([sp_geosfp, sp_openmeteo], sp_dim).rename('sp') # copy shared attrs all_attrs = self.scene['radiance'].attrs attrs_share = {key: all_attrs[key] for key in ['area', 'sensor', 'geotransform', 'spatial_ref', 'filename'] if key in all_attrs} u10.attrs = attrs_share v10.attrs = attrs_share sp.attrs = attrs_share u10.attrs['long_name'] = '10 metre U wind component' v10.attrs['long_name'] = '10 metre V wind component' sp.attrs['long_name'] = 'surface pressure' u10.attrs['units'] = 'm s-1' v10.attrs['units'] = 'm s-1' sp.attrs['units'] = 'Pa' self.u10 = u10 self.v10 = v10 self.sp = sp
[docs] def load_era5(self): """Load local ERA5 wind data.""" wind_file = os.path.join(self.era5_dir, self.obs_time.strftime('%Y/sl_%Y%m%d.grib')) # read the nearest ERA5 wind data ds_era5 = xr.open_dataset(wind_file, engine='cfgrib', indexpath='') ds_era5.coords['longitude'] = (ds_era5.coords['longitude'] + 180) % 360 - 180 # interpolate data to the 2d scene u10 = ds_era5['u10'].interp(time=self.obs_time.strftime('%Y-%m-%d %H:%M'), longitude=self.lons, latitude=self.lats, ) v10 = ds_era5['v10'].interp(time=self.obs_time.strftime('%Y-%m-%d %H:%M'), longitude=self.lons, latitude=self.lats, ) # remove coords u10 = u10.reset_coords(drop=True) v10 = v10.reset_coords(drop=True) return u10, v10
[docs] def load_geosfp(self): """Load local GEOS-FP wind data.""" # read GEOS-FP by hour name geosfp_name = 'GEOS.fp.asm.tavg1_2d_slv_Nx.' + self.obs_time.strftime('%Y%m%d') \ + '_' + '{:02d}{:02d}'.format(self.obs_time.hour, 30) + '.V01.nc4' wind_file = os.path.join(self.geosfp_dir, self.obs_time.strftime('%Y/%m/%d'), geosfp_name) ds_geosfp = xr.open_dataset(wind_file).isel(time=0) # interpolate data to the 2d scene u10 = ds_geosfp['U10M'].interp(lon=self.lons, lat=self.lats, ) v10 = ds_geosfp['V10M'].interp(lon=self.lons, lat=self.lats, ) sp = ds_geosfp['PS'].interp(lon=self.lons, lat=self.lats, ) # remove coords u10 = u10.reset_coords(drop=True) v10 = v10.reset_coords(drop=True) sp = sp.reset_coords(drop=True) return u10, v10, sp
@staticmethod def _interp_angle( nrows: int, ncols: int, corners: Mapping[str, float], ) -> np.ndarray: """ Bilinear interpolation with center constraint. Parameters ---------- nrows, ncols : int Output dimensions corners : mapping Corner angles with keys: UL, UR, LL, LR, C Returns ------- angle : np.ndarray (nrows, ncols) Interpolated angle field [degrees] """ required = {"UL", "UR", "LL", "LR", "C"} missing = required - corners.keys() if missing: raise ValueError(f"Missing corner keys: {missing}") ul = corners["UL"] ur = corners["UR"] ll = corners["LL"] lr = corners["LR"] center = corners["C"] # Normalized coordinates y = np.linspace(0.0, 1.0, nrows, dtype=np.float64) x = np.linspace(0.0, 1.0, ncols, dtype=np.float64) xx, yy = np.meshgrid(x, y) # Bilinear interpolation from corners angle = ( ul * (1 - xx) * (1 - yy) + ur * xx * (1 - yy) + ll * (1 - xx) * yy + lr * xx * yy ) # Apply center constraint center_bilin = 0.25 * (ul + ur + ll + lr) delta = center - center_bilin # Weight: zero at edges, one at center w = 4.0 * xx * (1 - xx) * yy * (1 - yy) return angle + delta * w
[docs] def load_openmeteo(self): """ Fetch OpenMeteo data at domain corners and center, then interpolate to 2D grid. Returns ------- u10, v10, sp : np.ndarray 2D fields of U-wind, V-wind (m/s), and surface pressure (Pa) """ nrows, ncols = self.lats.shape # Define corner and center positions positions = { 'UL': (self.lats[0, 0], self.lons[0, 0]), # Upper-left 'UR': (self.lats[0, -1], self.lons[0, -1]), # Upper-right 'LL': (self.lats[-1, 0], self.lons[-1, 0]), # Lower-left 'LR': (self.lats[-1, -1], self.lons[-1, -1]), # Lower-right 'C': (self.lats[nrows//2, ncols//2], self.lons[nrows//2, ncols//2]) # Center } # Fetch data at each position wspd_dict = {} wdir_dict = {} sp_dict = {} for key, (lat, lon) in positions.items(): url = "https://archive-api.open-meteo.com/v1/archive" params = { "latitude": lat.item(), "longitude": lon.item(), "start_date": self.obs_time.strftime('%Y-%m-%d'), "end_date": self.obs_time.strftime('%Y-%m-%d'), "hourly": ["wind_speed_10m", "wind_direction_10m", "surface_pressure"], "wind_speed_unit": "ms", "timezone": "UTC", } try: response = requests.get(url, params=params, timeout=30) response.raise_for_status() data = response.json() # Extract hourly data hourly = data['hourly'] ds_hourly = xr.Dataset( { 'wspd': (['time'], hourly['wind_speed_10m']), 'wdir': (['time'], hourly['wind_direction_10m']), 'pressure': (['time'], hourly['surface_pressure']), }, coords={'time': pd.to_datetime(hourly['time'])} ) # Interpolate to exact observation time ds_interp = ds_hourly.interp(time=self.obs_time) wspd_dict[key] = float(ds_interp['wspd'].values) wdir_dict[key] = float(ds_interp['wdir'].values) sp_dict[key] = float(ds_interp['pressure'].values) * 100 # hPa to Pa except Exception as e: raise RuntimeError(f"Failed to fetch data for {key}: {e}") # Interpolate wind direction and speed separately wdir_2d = self._interp_angle(nrows, ncols, wdir_dict) wspd_2d = self._interp_angle(nrows, ncols, wspd_dict) sp_2d = self._interp_angle(nrows, ncols, sp_dict) # Convert wind speed and direction to U and V components rad = np.deg2rad(wdir_2d) u10 = -wspd_2d * np.sin(rad) v10 = -wspd_2d * np.cos(rad) u10 = xr.DataArray(u10, dims=['y', 'x']) v10 = xr.DataArray(v10, dims=['y', 'x']) sp_2d = xr.DataArray(sp_2d, dims=['y', 'x']) return u10, v10, sp_2d