|
27 | 27 | find_latitude_index, |
28 | 28 | find_longitude_index, |
29 | 29 | find_time_index, |
| 30 | + geodesic_to_lambert_conformal, |
30 | 31 | geodesic_to_utm, |
31 | 32 | get_elevation_data_from_dataset, |
32 | 33 | get_final_date_from_time_array, |
@@ -605,6 +606,61 @@ def __set_earth_rotation_vector(self): |
605 | 606 |
|
606 | 607 | # Validators (used to verify an attribute is being set correctly.) |
607 | 608 |
|
| 609 | + @staticmethod |
| 610 | + def __dictionary_matches_dataset(dictionary, dataset): |
| 611 | + """Check whether a mapping dictionary is compatible with a dataset.""" |
| 612 | + variables = dataset.variables |
| 613 | + required_keys = ( |
| 614 | + "time", |
| 615 | + "latitude", |
| 616 | + "longitude", |
| 617 | + "level", |
| 618 | + "temperature", |
| 619 | + "u_wind", |
| 620 | + "v_wind", |
| 621 | + ) |
| 622 | + |
| 623 | + for key in required_keys: |
| 624 | + variable_name = dictionary.get(key) |
| 625 | + if variable_name is None or variable_name not in variables: |
| 626 | + return False |
| 627 | + |
| 628 | + projection_name = dictionary.get("projection") |
| 629 | + if projection_name is not None and projection_name not in variables: |
| 630 | + return False |
| 631 | + |
| 632 | + geopotential_height_name = dictionary.get("geopotential_height") |
| 633 | + geopotential_name = dictionary.get("geopotential") |
| 634 | + has_geopotential_height = ( |
| 635 | + geopotential_height_name is not None |
| 636 | + and geopotential_height_name in variables |
| 637 | + ) |
| 638 | + has_geopotential = ( |
| 639 | + geopotential_name is not None and geopotential_name in variables |
| 640 | + ) |
| 641 | + |
| 642 | + return has_geopotential_height or has_geopotential |
| 643 | + |
| 644 | + def __resolve_dictionary_for_dataset(self, dictionary, dataset): |
| 645 | + """Resolve a compatible mapping dictionary for the loaded dataset. |
| 646 | +
|
| 647 | + If the provided mapping is incompatible with the dataset variables, |
| 648 | + this method tries built-in mappings and falls back to the first |
| 649 | + compatible one. |
| 650 | + """ |
| 651 | + if self.__dictionary_matches_dataset(dictionary, dataset): |
| 652 | + return dictionary |
| 653 | + |
| 654 | + for model_name, candidate in self.__weather_model_map.all_dictionaries.items(): |
| 655 | + if self.__dictionary_matches_dataset(candidate, dataset): |
| 656 | + warnings.warn( |
| 657 | + "Provided weather mapping does not match dataset variables. " |
| 658 | + f"Falling back to built-in mapping '{model_name}'." |
| 659 | + ) |
| 660 | + return candidate |
| 661 | + |
| 662 | + return dictionary |
| 663 | + |
608 | 664 | def __validate_dictionary(self, file, dictionary): |
609 | 665 | # removed CMC until it is fixed. |
610 | 666 | available_models = [ |
@@ -1200,6 +1256,36 @@ def set_atmospheric_model( # pylint: disable=too-many-statements |
1200 | 1256 | case "windy": |
1201 | 1257 | self.process_windy_atmosphere(file) |
1202 | 1258 | case "forecast" | "reanalysis" | "ensemble": |
| 1259 | + if isinstance(file, str): |
| 1260 | + shortcut_map = self.__atm_type_file_to_function_map.get(type, {}) |
| 1261 | + matching_shortcut = next( |
| 1262 | + ( |
| 1263 | + shortcut |
| 1264 | + for shortcut in shortcut_map |
| 1265 | + if shortcut.lower() == file.lower() |
| 1266 | + ), |
| 1267 | + None, |
| 1268 | + ) |
| 1269 | + if matching_shortcut is not None: |
| 1270 | + file = matching_shortcut |
| 1271 | + |
| 1272 | + if isinstance(file, str): |
| 1273 | + file_upper = file.upper() |
| 1274 | + if type == "forecast" and file_upper == "HIRESW": |
| 1275 | + raise ValueError( |
| 1276 | + "The HIRESW latest-model shortcut is currently " |
| 1277 | + "unavailable because NOMADS OPeNDAP is deactivated. " |
| 1278 | + "Please use another forecast source or provide a " |
| 1279 | + "compatible dataset path/URL explicitly." |
| 1280 | + ) |
| 1281 | + if type == "ensemble" and file_upper == "GEFS": |
| 1282 | + raise ValueError( |
| 1283 | + "The GEFS latest-model shortcut is currently " |
| 1284 | + "unavailable because NOMADS OPeNDAP is deactivated. " |
| 1285 | + "Please use another ensemble source or provide a " |
| 1286 | + "compatible dataset path/URL explicitly." |
| 1287 | + ) |
| 1288 | + |
1203 | 1289 | dictionary = self.__validate_dictionary(file, dictionary) |
1204 | 1290 | try: |
1205 | 1291 | fetch_function = self.__atm_type_file_to_function_map[type][file] |
@@ -1661,20 +1747,34 @@ def process_forecast_reanalysis(self, file, dictionary): # pylint: disable=too- |
1661 | 1747 | # Read weather file |
1662 | 1748 | if isinstance(file, str): |
1663 | 1749 | data = netCDF4.Dataset(file) |
1664 | | - if dictionary["time"] not in data.variables.keys(): |
1665 | | - dictionary = self.__weather_model_map.get("ECMWF_v0") |
1666 | 1750 | else: |
1667 | 1751 | data = file |
1668 | 1752 |
|
| 1753 | + dictionary = self.__resolve_dictionary_for_dataset(dictionary, data) |
| 1754 | + |
1669 | 1755 | # Get time, latitude and longitude data from file |
1670 | 1756 | time_array = data.variables[dictionary["time"]] |
1671 | | - lon_list = data.variables[dictionary["longitude"]][:].tolist() |
1672 | | - lat_list = data.variables[dictionary["latitude"]][:].tolist() |
| 1757 | + lon_array = data.variables[dictionary["longitude"]] |
| 1758 | + lat_array = data.variables[dictionary["latitude"]] |
| 1759 | + |
| 1760 | + # Some THREDDS datasets use projected x/y coordinates. |
| 1761 | + if dictionary.get("projection") is not None: |
| 1762 | + projection_variable = data.variables[dictionary["projection"]] |
| 1763 | + x_units = getattr(lon_array, "units", "m") |
| 1764 | + target_lon, target_lat = geodesic_to_lambert_conformal( |
| 1765 | + self.latitude, |
| 1766 | + self.longitude, |
| 1767 | + projection_variable, |
| 1768 | + x_units=x_units, |
| 1769 | + ) |
| 1770 | + else: |
| 1771 | + target_lon = self.longitude |
| 1772 | + target_lat = self.latitude |
1673 | 1773 |
|
1674 | 1774 | # Find time, latitude and longitude indexes |
1675 | 1775 | time_index = find_time_index(self.datetime_date, time_array) |
1676 | | - lon, lon_index = find_longitude_index(self.longitude, lon_list) |
1677 | | - _, lat_index = find_latitude_index(self.latitude, lat_list) |
| 1776 | + lon, lon_index = find_longitude_index(target_lon, lon_array) |
| 1777 | + _, lat_index = find_latitude_index(target_lat, lat_array) |
1678 | 1778 |
|
1679 | 1779 | # Get pressure level data from file |
1680 | 1780 | levels = get_pressure_levels_from_file(data, dictionary) |
@@ -1732,9 +1832,9 @@ def process_forecast_reanalysis(self, file, dictionary): # pylint: disable=too- |
1732 | 1832 | ) from e |
1733 | 1833 |
|
1734 | 1834 | # Prepare for bilinear interpolation |
1735 | | - x, y = self.latitude, lon |
1736 | | - x1, y1 = lat_list[lat_index - 1], lon_list[lon_index - 1] |
1737 | | - x2, y2 = lat_list[lat_index], lon_list[lon_index] |
| 1835 | + x, y = target_lat, lon |
| 1836 | + x1, y1 = float(lat_array[lat_index - 1]), float(lon_array[lon_index - 1]) |
| 1837 | + x2, y2 = float(lat_array[lat_index]), float(lon_array[lon_index]) |
1738 | 1838 |
|
1739 | 1839 | # Determine properties in lat, lon |
1740 | 1840 | height = bilinear_interpolation( |
@@ -1786,6 +1886,17 @@ def process_forecast_reanalysis(self, file, dictionary): # pylint: disable=too- |
1786 | 1886 | wind_vs[:, 1, 1], |
1787 | 1887 | ) |
1788 | 1888 |
|
| 1889 | + # Some datasets expose different level counts between fields |
| 1890 | + # (e.g., temperature on isobaric1 and geopotential on isobaric). |
| 1891 | + min_profile_length = min( |
| 1892 | + len(levels), len(height), len(temper), len(wind_u), len(wind_v) |
| 1893 | + ) |
| 1894 | + levels = levels[:min_profile_length] |
| 1895 | + height = height[:min_profile_length] |
| 1896 | + temper = temper[:min_profile_length] |
| 1897 | + wind_u = wind_u[:min_profile_length] |
| 1898 | + wind_v = wind_v[:min_profile_length] |
| 1899 | + |
1789 | 1900 | # Determine wind speed, heading and direction |
1790 | 1901 | wind_speed = calculate_wind_speed(wind_u, wind_v) |
1791 | 1902 | wind_heading = calculate_wind_heading(wind_u, wind_v) |
@@ -1843,22 +1954,25 @@ def process_forecast_reanalysis(self, file, dictionary): # pylint: disable=too- |
1843 | 1954 | ) |
1844 | 1955 | else: |
1845 | 1956 | self.atmospheric_model_interval = 0 |
1846 | | - self.atmospheric_model_init_lat = lat_list[0] |
1847 | | - self.atmospheric_model_end_lat = lat_list[-1] |
1848 | | - self.atmospheric_model_init_lon = lon_list[0] |
1849 | | - self.atmospheric_model_end_lon = lon_list[-1] |
| 1957 | + self.atmospheric_model_init_lat = float(lat_array[0]) |
| 1958 | + self.atmospheric_model_end_lat = float(lat_array[len(lat_array) - 1]) |
| 1959 | + self.atmospheric_model_init_lon = float(lon_array[0]) |
| 1960 | + self.atmospheric_model_end_lon = float(lon_array[len(lon_array) - 1]) |
1850 | 1961 |
|
1851 | 1962 | # Save debugging data |
1852 | | - self.lat_array = lat_list |
1853 | | - self.lon_array = lon_list |
| 1963 | + self.lat_array = [x1, x2] |
| 1964 | + self.lon_array = [y1, y2] |
1854 | 1965 | self.lon_index = lon_index |
1855 | 1966 | self.lat_index = lat_index |
1856 | 1967 | self.geopotentials = geopotentials |
1857 | 1968 | self.wind_us = wind_us |
1858 | 1969 | self.wind_vs = wind_vs |
1859 | 1970 | self.levels = levels |
1860 | 1971 | self.temperatures = temperatures |
1861 | | - self.time_array = time_array[:].tolist() |
| 1972 | + self.time_array = [ |
| 1973 | + float(time_array[0]), |
| 1974 | + float(time_array[time_array.shape[0] - 1]), |
| 1975 | + ] |
1862 | 1976 | self.height = height |
1863 | 1977 |
|
1864 | 1978 | # Close weather data |
@@ -1937,23 +2051,40 @@ def process_ensemble(self, file, dictionary): # pylint: disable=too-many-locals |
1937 | 2051 | else: |
1938 | 2052 | data = file |
1939 | 2053 |
|
| 2054 | + dictionary = self.__resolve_dictionary_for_dataset(dictionary, data) |
| 2055 | + |
1940 | 2056 | # Get time, latitude and longitude data from file |
1941 | 2057 | time_array = data.variables[dictionary["time"]] |
1942 | | - lon_list = data.variables[dictionary["longitude"]][:].tolist() |
1943 | | - lat_list = data.variables[dictionary["latitude"]][:].tolist() |
| 2058 | + lon_array = data.variables[dictionary["longitude"]] |
| 2059 | + lat_array = data.variables[dictionary["latitude"]] |
| 2060 | + |
| 2061 | + # Some THREDDS datasets use projected x/y coordinates. |
| 2062 | + #TODO CHECK THIS I AM NOT SURE????? |
| 2063 | + if dictionary.get("projection") is not None: |
| 2064 | + projection_variable = data.variables[dictionary["projection"]] |
| 2065 | + x_units = getattr(lon_array, "units", "m") |
| 2066 | + target_lon, target_lat = geodesic_to_lambert_conformal( |
| 2067 | + self.latitude, |
| 2068 | + self.longitude, |
| 2069 | + projection_variable, |
| 2070 | + x_units=x_units, |
| 2071 | + ) |
| 2072 | + else: |
| 2073 | + target_lon = self.longitude |
| 2074 | + target_lat = self.latitude |
1944 | 2075 |
|
1945 | 2076 | # Find time, latitude and longitude indexes |
1946 | 2077 | time_index = find_time_index(self.datetime_date, time_array) |
1947 | | - lon, lon_index = find_longitude_index(self.longitude, lon_list) |
1948 | | - _, lat_index = find_latitude_index(self.latitude, lat_list) |
| 2078 | + lon, lon_index = find_longitude_index(target_lon, lon_array) |
| 2079 | + _, lat_index = find_latitude_index(target_lat, lat_array) |
1949 | 2080 |
|
1950 | 2081 | # Get ensemble data from file |
| 2082 | + has_ensemble_dimension = True |
1951 | 2083 | try: |
1952 | 2084 | num_members = len(data.variables[dictionary["ensemble"]][:]) |
1953 | | - except KeyError as e: |
1954 | | - raise ValueError( |
1955 | | - "Unable to read ensemble data from file. Check file and dictionary." |
1956 | | - ) from e |
| 2085 | + except KeyError: |
| 2086 | + has_ensemble_dimension = False |
| 2087 | + num_members = 1 |
1957 | 2088 |
|
1958 | 2089 | # Get pressure level data from file |
1959 | 2090 | levels = get_pressure_levels_from_file(data, dictionary) |
@@ -2012,10 +2143,16 @@ def process_ensemble(self, file, dictionary): # pylint: disable=too-many-locals |
2012 | 2143 | "Unable to read wind-v component. Check file and dictionary." |
2013 | 2144 | ) from e |
2014 | 2145 |
|
| 2146 | + if not has_ensemble_dimension: |
| 2147 | + geopotentials = np.expand_dims(geopotentials, axis=0) |
| 2148 | + temperatures = np.expand_dims(temperatures, axis=0) |
| 2149 | + wind_us = np.expand_dims(wind_us, axis=0) |
| 2150 | + wind_vs = np.expand_dims(wind_vs, axis=0) |
| 2151 | + |
2015 | 2152 | # Prepare for bilinear interpolation |
2016 | | - x, y = self.latitude, lon |
2017 | | - x1, y1 = lat_list[lat_index - 1], lon_list[lon_index - 1] |
2018 | | - x2, y2 = lat_list[lat_index], lon_list[lon_index] |
| 2153 | + x, y = target_lat, lon |
| 2154 | + x1, y1 = float(lat_array[lat_index - 1]), float(lon_array[lon_index - 1]) |
| 2155 | + x2, y2 = float(lat_array[lat_index]), float(lon_array[lon_index]) |
2019 | 2156 |
|
2020 | 2157 | # Determine properties in lat, lon |
2021 | 2158 | height = bilinear_interpolation( |
@@ -2067,6 +2204,19 @@ def process_ensemble(self, file, dictionary): # pylint: disable=too-many-locals |
2067 | 2204 | wind_vs[:, :, 1, 1], |
2068 | 2205 | ) |
2069 | 2206 |
|
| 2207 | + min_profile_length = min( |
| 2208 | + len(levels), |
| 2209 | + height.shape[1], |
| 2210 | + temper.shape[1], |
| 2211 | + wind_u.shape[1], |
| 2212 | + wind_v.shape[1], |
| 2213 | + ) |
| 2214 | + levels = levels[:min_profile_length] |
| 2215 | + height = height[:, :min_profile_length] |
| 2216 | + temper = temper[:, :min_profile_length] |
| 2217 | + wind_u = wind_u[:, :min_profile_length] |
| 2218 | + wind_v = wind_v[:, :min_profile_length] |
| 2219 | + |
2070 | 2220 | # Determine wind speed, heading and direction |
2071 | 2221 | wind_speed = calculate_wind_speed(wind_u, wind_v) |
2072 | 2222 | wind_heading = calculate_wind_heading(wind_u, wind_v) |
@@ -2099,22 +2249,25 @@ def process_ensemble(self, file, dictionary): # pylint: disable=too-many-locals |
2099 | 2249 | self.atmospheric_model_init_date = get_initial_date_from_time_array(time_array) |
2100 | 2250 | self.atmospheric_model_end_date = get_final_date_from_time_array(time_array) |
2101 | 2251 | self.atmospheric_model_interval = get_interval_date_from_time_array(time_array) |
2102 | | - self.atmospheric_model_init_lat = lat_list[0] |
2103 | | - self.atmospheric_model_end_lat = lat_list[-1] |
2104 | | - self.atmospheric_model_init_lon = lon_list[0] |
2105 | | - self.atmospheric_model_end_lon = lon_list[-1] |
| 2252 | + self.atmospheric_model_init_lat = float(lat_array[0]) |
| 2253 | + self.atmospheric_model_end_lat = float(lat_array[len(lat_array) - 1]) |
| 2254 | + self.atmospheric_model_init_lon = float(lon_array[0]) |
| 2255 | + self.atmospheric_model_end_lon = float(lon_array[len(lon_array) - 1]) |
2106 | 2256 |
|
2107 | 2257 | # Save debugging data |
2108 | | - self.lat_array = lat_list |
2109 | | - self.lon_array = lon_list |
| 2258 | + self.lat_array = [x1, x2] |
| 2259 | + self.lon_array = [y1, y2] |
2110 | 2260 | self.lon_index = lon_index |
2111 | 2261 | self.lat_index = lat_index |
2112 | 2262 | self.geopotentials = geopotentials |
2113 | 2263 | self.wind_us = wind_us |
2114 | 2264 | self.wind_vs = wind_vs |
2115 | 2265 | self.levels = levels |
2116 | 2266 | self.temperatures = temperatures |
2117 | | - self.time_array = time_array[:].tolist() |
| 2267 | + self.time_array = [ |
| 2268 | + float(time_array[0]), |
| 2269 | + float(time_array[time_array.shape[0] - 1]), |
| 2270 | + ] |
2118 | 2271 | self.height = height |
2119 | 2272 |
|
2120 | 2273 | # Close weather data |
|
0 commit comments