forkairos 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forkairos-0.1.0/LICENSE +21 -0
- forkairos-0.1.0/PKG-INFO +60 -0
- forkairos-0.1.0/README.md +2 -0
- forkairos-0.1.0/forkairos/__init__.py +6 -0
- forkairos-0.1.0/forkairos/domain.py +45 -0
- forkairos-0.1.0/forkairos/export.py +0 -0
- forkairos-0.1.0/forkairos/pipeline.py +70 -0
- forkairos-0.1.0/forkairos/processing.py +175 -0
- forkairos-0.1.0/forkairos/providers/__init__.py +0 -0
- forkairos-0.1.0/forkairos/providers/base.py +68 -0
- forkairos-0.1.0/forkairos/providers/ecmwf_open.py +126 -0
- forkairos-0.1.0/forkairos/providers/era5.py +183 -0
- forkairos-0.1.0/forkairos/providers/gfs.py +139 -0
- forkairos-0.1.0/forkairos/providers/open_meteo.py +125 -0
- forkairos-0.1.0/forkairos.egg-info/PKG-INFO +60 -0
- forkairos-0.1.0/forkairos.egg-info/SOURCES.txt +21 -0
- forkairos-0.1.0/forkairos.egg-info/dependency_links.txt +1 -0
- forkairos-0.1.0/forkairos.egg-info/requires.txt +15 -0
- forkairos-0.1.0/forkairos.egg-info/top_level.txt +1 -0
- forkairos-0.1.0/pyproject.toml +56 -0
- forkairos-0.1.0/setup.cfg +4 -0
- forkairos-0.1.0/tests/test_domain.py +46 -0
- forkairos-0.1.0/tests/test_processing.py +63 -0
forkairos-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cristóbal Sardá
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
forkairos-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forkairos
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Operational pipeline for meteorological forcing data in CF-compliant NetCDF
|
|
5
|
+
Author-email: Cristóbal Reyes <cristobal.sarda@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Cristóbal Sardá
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/Jano01/forkairos
|
|
29
|
+
Project-URL: Documentation, https://forkairos.readthedocs.io
|
|
30
|
+
Project-URL: Repository, https://github.com/Jano01/forkairos
|
|
31
|
+
Project-URL: Issues, https://github.com/Jano01/forkairos/issues
|
|
32
|
+
Keywords: hydrology,meteorology,NWP,forcing,NetCDF,ERA5,GFS,ECMWF
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Intended Audience :: Science/Research
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
|
|
38
|
+
Classifier: Topic :: Scientific/Engineering :: Hydrology
|
|
39
|
+
Requires-Python: >=3.12
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
License-File: LICENSE
|
|
42
|
+
Requires-Dist: xarray>=2024.1.0
|
|
43
|
+
Requires-Dist: geopandas>=0.14.0
|
|
44
|
+
Requires-Dist: netcdf4>=1.6.0
|
|
45
|
+
Requires-Dist: requests>=2.31.0
|
|
46
|
+
Requires-Dist: tqdm>=4.66.0
|
|
47
|
+
Requires-Dist: pydantic>=2.0.0
|
|
48
|
+
Requires-Dist: openmeteo-requests>=1.1.0
|
|
49
|
+
Requires-Dist: requests-cache>=1.1.0
|
|
50
|
+
Requires-Dist: retry-requests>=2.0.0
|
|
51
|
+
Requires-Dist: cdsapi>=0.6.0
|
|
52
|
+
Requires-Dist: cfgrib>=0.9.10
|
|
53
|
+
Requires-Dist: scipy>=1.12.0
|
|
54
|
+
Requires-Dist: numpy>=1.26.0
|
|
55
|
+
Requires-Dist: pandas>=2.2.0
|
|
56
|
+
Requires-Dist: ecmwf-opendata>=0.3.0
|
|
57
|
+
Dynamic: license-file
|
|
58
|
+
|
|
59
|
+
# snowops
|
|
60
|
+
Operational data pipeline for snow hydrology — NWP forcings and satellite observations in CF-compliant NetCDF
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# forkairos/domain.py
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import geopandas as gpd
|
|
4
|
+
|
|
5
|
+
class Domain:
|
|
6
|
+
"""
|
|
7
|
+
Represents a watershed domain derived from a shapefile.
|
|
8
|
+
Computes a bounding box with an optional buffer for data download.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, shapefile: str | Path, buffer_km: float = 10.0):
|
|
12
|
+
"""
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
shapefile : path to the basin shapefile (any CRS)
|
|
16
|
+
buffer_km : buffer around the basin in kilometers
|
|
17
|
+
"""
|
|
18
|
+
self.shapefile = Path(shapefile)
|
|
19
|
+
self.buffer_km = buffer_km
|
|
20
|
+
|
|
21
|
+
# Read and reproject to WGS84
|
|
22
|
+
gdf = gpd.read_file(self.shapefile).to_crs("EPSG:4326")
|
|
23
|
+
self.geometry = gdf.union_all()
|
|
24
|
+
|
|
25
|
+
# Compute bbox with buffer
|
|
26
|
+
gdf_proj = gdf.to_crs(gdf.estimate_utm_crs())
|
|
27
|
+
gdf_buffered = gdf_proj.buffer(buffer_km * 1000)
|
|
28
|
+
bbox_buffered = gdf_buffered.to_crs("EPSG:4326").total_bounds
|
|
29
|
+
|
|
30
|
+
self.west = round(float(bbox_buffered[0]), 4)
|
|
31
|
+
self.south = round(float(bbox_buffered[1]), 4)
|
|
32
|
+
self.east = round(float(bbox_buffered[2]), 4)
|
|
33
|
+
self.north = round(float(bbox_buffered[3]), 4)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def bbox(self) -> tuple[float, float, float, float]:
|
|
37
|
+
"""Returns (west, south, east, north) in WGS84."""
|
|
38
|
+
return (self.west, self.south, self.east, self.north)
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str:
|
|
41
|
+
return (
|
|
42
|
+
f"Domain(shapefile={self.shapefile.name!r}, "
|
|
43
|
+
f"buffer_km={self.buffer_km}, "
|
|
44
|
+
f"bbox=({self.west}, {self.south}, {self.east}, {self.north}))"
|
|
45
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# forkairos/pipeline.py
|
|
2
|
+
import xarray as xr
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from forkairos.domain import Domain
|
|
5
|
+
from forkairos.providers.base import BaseProvider
|
|
6
|
+
from forkairos.providers.open_meteo import OpenMeteoProvider
|
|
7
|
+
from forkairos.providers.era5 import ERA5Provider
|
|
8
|
+
from forkairos.providers.gfs import GFSProvider
|
|
9
|
+
from forkairos.providers.ecmwf_open import ECMWFOpenProvider
|
|
10
|
+
|
|
11
|
+
PROVIDERS = {
|
|
12
|
+
"open_meteo": OpenMeteoProvider,
|
|
13
|
+
"era5": ERA5Provider,
|
|
14
|
+
"gfs": GFSProvider,
|
|
15
|
+
"ecmwf_open": ECMWFOpenProvider,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def get_provider(name: str) -> BaseProvider:
|
|
19
|
+
"""
|
|
20
|
+
Returns an instance of the requested provider.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
name : provider name. Available: "open_meteo"
|
|
25
|
+
"""
|
|
26
|
+
if name not in PROVIDERS:
|
|
27
|
+
raise ValueError(f"Unknown provider '{name}'. Available: {list(PROVIDERS)}")
|
|
28
|
+
return PROVIDERS[name]()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run(
|
|
32
|
+
shapefile: str | Path,
|
|
33
|
+
provider_name: str,
|
|
34
|
+
variables: list[str],
|
|
35
|
+
start: str,
|
|
36
|
+
end: str,
|
|
37
|
+
freq: str = "1h",
|
|
38
|
+
buffer_km: float = 10.0,
|
|
39
|
+
output_path: str | Path = "output.nc",
|
|
40
|
+
) -> xr.Dataset:
|
|
41
|
+
"""
|
|
42
|
+
Full pipeline: shapefile → download → NetCDF.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
shapefile : path to basin shapefile
|
|
47
|
+
provider_name : e.g. "open_meteo"
|
|
48
|
+
variables : list of variable names
|
|
49
|
+
start : start date "YYYY-MM-DD"
|
|
50
|
+
end : end date "YYYY-MM-DD"
|
|
51
|
+
freq : temporal resolution e.g. "1h", "6h", "1d"
|
|
52
|
+
buffer_km : buffer around basin in km
|
|
53
|
+
output_path : path for the output NetCDF file
|
|
54
|
+
"""
|
|
55
|
+
print(f"[forkairos] Loading domain from {shapefile}")
|
|
56
|
+
domain = Domain(shapefile, buffer_km=buffer_km)
|
|
57
|
+
print(f"[forkairos] {domain}")
|
|
58
|
+
|
|
59
|
+
print(f"[forkairos] Provider: {provider_name}")
|
|
60
|
+
provider = get_provider(provider_name)
|
|
61
|
+
|
|
62
|
+
print(f"[forkairos] Downloading {variables} | {start} → {end} | {freq}")
|
|
63
|
+
ds = provider.download(domain, variables, start, end, freq)
|
|
64
|
+
|
|
65
|
+
output_path = Path(output_path)
|
|
66
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
ds.to_netcdf(output_path)
|
|
68
|
+
print(f"[forkairos] Saved → {output_path}")
|
|
69
|
+
|
|
70
|
+
return ds
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# forkairos/processing.py
|
|
2
|
+
import numpy as np
|
|
3
|
+
import xarray as xr
|
|
4
|
+
from scipy.interpolate import RegularGridInterpolator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Resoluciones sugeridas según DEM
|
|
8
|
+
DEM_RESOLUTION_GUIDE = {
|
|
9
|
+
"SRTM 90m": 0.001,
|
|
10
|
+
"SRTM 30m": 0.0003,
|
|
11
|
+
"ALOS 12.5m": 0.0001,
|
|
12
|
+
"TanDEM 12m": 0.0001,
|
|
13
|
+
"COP-DEM 30m": 0.0003,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolution_guide() -> None:
|
|
18
|
+
"""Print suggested regridding resolutions based on common DEMs."""
|
|
19
|
+
print("Suggested regridding resolutions by DEM:")
|
|
20
|
+
for dem, res in DEM_RESOLUTION_GUIDE.items():
|
|
21
|
+
print(f" {dem:20s} → {res}° (~{res * 111:.0f} km)")
|
|
22
|
+
print()
|
|
23
|
+
print("Note: bilinear interpolation does not account for topographic")
|
|
24
|
+
print("effects on temperature or precipitation. Use with caution")
|
|
25
|
+
print("at resolutions finer than 0.01° (~1 km).")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bias_correct(
|
|
29
|
+
ds: xr.Dataset,
|
|
30
|
+
reference: xr.Dataset,
|
|
31
|
+
method: str = "qdm",
|
|
32
|
+
variables: list[str] | None = None,
|
|
33
|
+
) -> xr.Dataset:
|
|
34
|
+
"""
|
|
35
|
+
Apply bias correction to a Dataset using a reference Dataset.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
ds : Dataset to correct (e.g. from a NWP provider)
|
|
40
|
+
reference : Observational reference Dataset (e.g. CR2MET, ERA5-Land)
|
|
41
|
+
method : Correction method — currently supported: "qdm"
|
|
42
|
+
variables : Variables to correct. If None, corrects all common variables.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
Bias-corrected Dataset with same structure as input.
|
|
47
|
+
"""
|
|
48
|
+
if method == "qdm":
|
|
49
|
+
return _qdm(ds, reference, variables)
|
|
50
|
+
else:
|
|
51
|
+
raise ValueError(f"Unknown method '{method}'. Available: ['qdm']")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _qdm(
|
|
55
|
+
ds: xr.Dataset,
|
|
56
|
+
ref: xr.Dataset,
|
|
57
|
+
variables: list[str] | None,
|
|
58
|
+
) -> xr.Dataset:
|
|
59
|
+
"""
|
|
60
|
+
Quantile Delta Mapping (Cannon et al. 2015).
|
|
61
|
+
|
|
62
|
+
Preserves the model's relative changes while correcting systematic bias.
|
|
63
|
+
Applied independently per variable and per grid cell.
|
|
64
|
+
"""
|
|
65
|
+
if variables is None:
|
|
66
|
+
variables = [v for v in ds.data_vars if v in ref.data_vars]
|
|
67
|
+
|
|
68
|
+
if not variables:
|
|
69
|
+
raise ValueError("No common variables found between ds and reference.")
|
|
70
|
+
|
|
71
|
+
ds_out = ds.copy(deep=True)
|
|
72
|
+
n_quantiles = 100
|
|
73
|
+
quantiles = np.linspace(0, 1, n_quantiles)
|
|
74
|
+
|
|
75
|
+
for var in variables:
|
|
76
|
+
if var not in ref.data_vars:
|
|
77
|
+
print(f"[forkairos] Warning: '{var}' not in reference — skipping.")
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
print(f"[forkairos] Bias correcting '{var}' with QDM...")
|
|
81
|
+
|
|
82
|
+
mod = ds[var]
|
|
83
|
+
obs = ref[var]
|
|
84
|
+
|
|
85
|
+
# Interpolate reference to model grid if needed
|
|
86
|
+
if not (obs.lat.values == mod.lat.values).all() or \
|
|
87
|
+
not (obs.lon.values == mod.lon.values).all():
|
|
88
|
+
obs = obs.interp(lat=mod.lat, lon=mod.lon, method="linear")
|
|
89
|
+
|
|
90
|
+
# Apply QDM per grid cell
|
|
91
|
+
corrected = mod.copy(deep=True)
|
|
92
|
+
|
|
93
|
+
for i in range(len(mod.lat)):
|
|
94
|
+
for j in range(len(mod.lon)):
|
|
95
|
+
mod_cell = mod.isel(lat=i, lon=j).values
|
|
96
|
+
obs_cell = obs.isel(lat=i, lon=j).values
|
|
97
|
+
|
|
98
|
+
# Remove NaNs for quantile computation
|
|
99
|
+
mod_clean = mod_cell[~np.isnan(mod_cell)]
|
|
100
|
+
obs_clean = obs_cell[~np.isnan(obs_cell)]
|
|
101
|
+
|
|
102
|
+
if len(mod_clean) == 0 or len(obs_clean) == 0:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
# Compute quantile mapping
|
|
106
|
+
mod_quantiles = np.quantile(mod_clean, quantiles)
|
|
107
|
+
obs_quantiles = np.quantile(obs_clean, quantiles)
|
|
108
|
+
|
|
109
|
+
# For each model value, find its quantile and apply delta
|
|
110
|
+
for t in range(len(mod_cell)):
|
|
111
|
+
if np.isnan(mod_cell[t]):
|
|
112
|
+
continue
|
|
113
|
+
# Find quantile of current value in model distribution
|
|
114
|
+
q = np.interp(mod_cell[t], mod_quantiles, quantiles)
|
|
115
|
+
# Get observed value at same quantile
|
|
116
|
+
obs_val = np.interp(q, quantiles, obs_quantiles)
|
|
117
|
+
# Apply delta (QDM preserves relative change)
|
|
118
|
+
delta = mod_cell[t] - np.interp(q, quantiles, mod_quantiles)
|
|
119
|
+
corrected.values[i, j, t] = obs_val + delta
|
|
120
|
+
|
|
121
|
+
ds_out[var] = corrected
|
|
122
|
+
|
|
123
|
+
ds_out.attrs["bias_correction"] = f"QDM — reference: user-provided"
|
|
124
|
+
return ds_out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def regrid(
|
|
128
|
+
ds: xr.Dataset,
|
|
129
|
+
resolution: float,
|
|
130
|
+
method: str = "bilinear",
|
|
131
|
+
variables: list[str] | None = None,
|
|
132
|
+
) -> xr.Dataset:
|
|
133
|
+
"""
|
|
134
|
+
Regrid a Dataset to a finer resolution using interpolation.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
ds : Input Dataset with lat/lon dimensions
|
|
139
|
+
resolution : Target resolution in degrees
|
|
140
|
+
method : Interpolation method — currently supported: "bilinear"
|
|
141
|
+
variables : Variables to regrid. If None, regrids all.
|
|
142
|
+
|
|
143
|
+
Returns
|
|
144
|
+
-------
|
|
145
|
+
Regridded Dataset at target resolution.
|
|
146
|
+
|
|
147
|
+
Notes
|
|
148
|
+
-----
|
|
149
|
+
Bilinear interpolation does not account for topographic effects.
|
|
150
|
+
For guidance on appropriate resolutions, call forkairos.processing.resolution_guide().
|
|
151
|
+
"""
|
|
152
|
+
if method != "bilinear":
|
|
153
|
+
raise ValueError(f"Unknown method '{method}'. Available: ['bilinear']")
|
|
154
|
+
|
|
155
|
+
if variables is None:
|
|
156
|
+
variables = list(ds.data_vars)
|
|
157
|
+
|
|
158
|
+
# Build target grid
|
|
159
|
+
west = float(ds.lon.min())
|
|
160
|
+
east = float(ds.lon.max())
|
|
161
|
+
south = float(ds.lat.min())
|
|
162
|
+
north = float(ds.lat.max())
|
|
163
|
+
|
|
164
|
+
new_lats = np.arange(south, north + resolution, resolution).round(6)
|
|
165
|
+
new_lons = np.arange(west, east + resolution, resolution).round(6)
|
|
166
|
+
|
|
167
|
+
ds_out = ds.interp(
|
|
168
|
+
lat=new_lats,
|
|
169
|
+
lon=new_lons,
|
|
170
|
+
method="linear",
|
|
171
|
+
kwargs={"fill_value": "extrapolate"},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
ds_out.attrs["regridding"] = f"bilinear interpolation → {resolution}°"
|
|
175
|
+
return ds_out
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# forkairos/providers/base.py
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any
|
|
4
|
+
import xarray as xr
|
|
5
|
+
from forkairos.domain import Domain
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseProvider(ABC):
|
|
9
|
+
"""
|
|
10
|
+
Abstract base class for all NWP providers.
|
|
11
|
+
Every provider must implement these four methods.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
name: str # e.g. "era5", "gfs"
|
|
15
|
+
mode: str # "forecast", "reanalysis", or "both"
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def available_variables(self) -> dict[str, str]:
|
|
19
|
+
"""
|
|
20
|
+
Returns a dictionary of available variables.
|
|
21
|
+
|
|
22
|
+
Returns
|
|
23
|
+
-------
|
|
24
|
+
dict where key = variable name, value = description
|
|
25
|
+
e.g. {"temperature_2m": "Air temperature at 2m (°C)"}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def available_date_range(self) -> tuple[str, str]:
|
|
30
|
+
"""
|
|
31
|
+
Returns the available date range for this provider.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
tuple (start_date, end_date) as strings "YYYY-MM-DD"
|
|
36
|
+
end_date can be "present" for operational providers
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def available_frequencies(self) -> list[str]:
|
|
41
|
+
"""
|
|
42
|
+
Returns the temporal resolutions available.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
list e.g. ["1h", "3h", "6h", "1d"]
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def download(
|
|
51
|
+
self,
|
|
52
|
+
domain: Domain,
|
|
53
|
+
variables: list[str],
|
|
54
|
+
start: str,
|
|
55
|
+
end: str,
|
|
56
|
+
freq: str,
|
|
57
|
+
) -> xr.Dataset:
|
|
58
|
+
"""
|
|
59
|
+
Download data and return a CF-compliant xarray Dataset.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
domain : Domain object with bbox
|
|
64
|
+
variables : list of variable names from available_variables()
|
|
65
|
+
start : start date "YYYY-MM-DD"
|
|
66
|
+
end : end date "YYYY-MM-DD"
|
|
67
|
+
freq : temporal resolution from available_frequencies()
|
|
68
|
+
"""
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# forkairos/providers/ecmwf_open.py
|
|
2
|
+
import openmeteo_requests
|
|
3
|
+
import requests_cache
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import xarray as xr
|
|
6
|
+
import numpy as np
|
|
7
|
+
from retry_requests import retry
|
|
8
|
+
from forkairos.providers.base import BaseProvider
|
|
9
|
+
from forkairos.domain import Domain
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ECMWFOpenProvider(BaseProvider):
|
|
13
|
+
|
|
14
|
+
name = "ecmwf_open"
|
|
15
|
+
mode = "forecast"
|
|
16
|
+
|
|
17
|
+
VARIABLES = {
|
|
18
|
+
"temperature_2m": "Air temperature at 2m (°C)",
|
|
19
|
+
"precipitation": "Total precipitation (mm)",
|
|
20
|
+
"snowfall": "Snowfall (cm)",
|
|
21
|
+
"surface_pressure": "Surface pressure (hPa)",
|
|
22
|
+
"wind_speed_10m_u": "U-component of wind at 10m (km/h)",
|
|
23
|
+
"wind_speed_10m_v": "V-component of wind at 10m (km/h)",
|
|
24
|
+
"shortwave_radiation": "Surface solar radiation downwards (W/m²)",
|
|
25
|
+
"snow_depth": "Snow depth (m)",
|
|
26
|
+
"relative_humidity_2m": "Relative humidity at 2m (%)",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
OPENMETEO_NAMES = {
|
|
30
|
+
"temperature_2m": "temperature_2m",
|
|
31
|
+
"precipitation": "precipitation",
|
|
32
|
+
"snowfall": "snowfall",
|
|
33
|
+
"surface_pressure": "surface_pressure",
|
|
34
|
+
"wind_speed_10m_u": "wind_u_component_10m",
|
|
35
|
+
"wind_speed_10m_v": "wind_v_component_10m",
|
|
36
|
+
"shortwave_radiation": "shortwave_radiation",
|
|
37
|
+
"snow_depth": "snow_depth",
|
|
38
|
+
"relative_humidity_2m": "relative_humidity_2m",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
FREQUENCIES = ["1h", "3h", "6h"]
|
|
42
|
+
FORECAST_URL = "https://api.open-meteo.com/v1/ecmwf"
|
|
43
|
+
|
|
44
|
+
def available_variables(self) -> dict[str, str]:
|
|
45
|
+
return self.VARIABLES
|
|
46
|
+
|
|
47
|
+
def available_date_range(self) -> tuple[str, str]:
|
|
48
|
+
start = pd.Timestamp.today().strftime("%Y-%m-%d")
|
|
49
|
+
return (start, "present+10days")
|
|
50
|
+
|
|
51
|
+
def available_frequencies(self) -> list[str]:
|
|
52
|
+
return self.FREQUENCIES
|
|
53
|
+
|
|
54
|
+
def download(
|
|
55
|
+
self,
|
|
56
|
+
domain: Domain,
|
|
57
|
+
variables: list[str],
|
|
58
|
+
start: str,
|
|
59
|
+
end: str,
|
|
60
|
+
freq: str = "1h",
|
|
61
|
+
) -> xr.Dataset:
|
|
62
|
+
|
|
63
|
+
for v in variables:
|
|
64
|
+
if v not in self.VARIABLES:
|
|
65
|
+
raise ValueError(f"Variable '{v}' not available. Choose from: {list(self.VARIABLES)}")
|
|
66
|
+
if freq not in self.FREQUENCIES:
|
|
67
|
+
raise ValueError(f"Frequency '{freq}' not available. Choose from: {self.FREQUENCIES}")
|
|
68
|
+
|
|
69
|
+
om_vars = [self.OPENMETEO_NAMES[v] for v in variables]
|
|
70
|
+
|
|
71
|
+
west, south, east, north = domain.bbox
|
|
72
|
+
lats = np.arange(south, north + 0.25, 0.25).round(4)
|
|
73
|
+
lons = np.arange(west, east + 0.25, 0.25).round(4)
|
|
74
|
+
|
|
75
|
+
cache_session = requests_cache.CachedSession(".cache_ecmwf_om", expire_after=3600)
|
|
76
|
+
retry_session = retry(cache_session, retries=5, backoff_factor=0.2)
|
|
77
|
+
client = openmeteo_requests.Client(session=retry_session)
|
|
78
|
+
|
|
79
|
+
rows = []
|
|
80
|
+
for lat in lats:
|
|
81
|
+
cols = []
|
|
82
|
+
for lon in lons:
|
|
83
|
+
params = {
|
|
84
|
+
"latitude": lat,
|
|
85
|
+
"longitude": lon,
|
|
86
|
+
"hourly": om_vars,
|
|
87
|
+
"start_date": start,
|
|
88
|
+
"end_date": end,
|
|
89
|
+
"timezone": "UTC",
|
|
90
|
+
}
|
|
91
|
+
responses = client.weather_api(self.FORECAST_URL, params=params)
|
|
92
|
+
r = responses[0]
|
|
93
|
+
hourly = r.Hourly()
|
|
94
|
+
|
|
95
|
+
times = pd.date_range(
|
|
96
|
+
start=pd.to_datetime(hourly.Time(), unit="s", utc=True),
|
|
97
|
+
end=pd.to_datetime(hourly.TimeEnd(), unit="s", utc=True),
|
|
98
|
+
freq=pd.Timedelta(seconds=hourly.Interval()),
|
|
99
|
+
inclusive="left",
|
|
100
|
+
).tz_localize(None)
|
|
101
|
+
|
|
102
|
+
data_vars = {}
|
|
103
|
+
for i, v in enumerate(variables):
|
|
104
|
+
data_vars[v] = (["time"], hourly.Variables(i).ValuesAsNumpy())
|
|
105
|
+
|
|
106
|
+
ds_point = xr.Dataset(
|
|
107
|
+
data_vars,
|
|
108
|
+
coords={"time": times},
|
|
109
|
+
).expand_dims(lat=[lat], lon=[lon])
|
|
110
|
+
|
|
111
|
+
cols.append(ds_point)
|
|
112
|
+
rows.append(xr.concat(cols, dim="lon"))
|
|
113
|
+
|
|
114
|
+
ds = xr.concat(rows, dim="lat")
|
|
115
|
+
|
|
116
|
+
ds["lat"].attrs = {"units": "degrees_north", "standard_name": "latitude"}
|
|
117
|
+
ds["lon"].attrs = {"units": "degrees_east", "standard_name": "longitude"}
|
|
118
|
+
ds["time"].attrs = {"standard_name": "time"}
|
|
119
|
+
ds.attrs = {
|
|
120
|
+
"source": "ECMWF IFS forecast via Open-Meteo API",
|
|
121
|
+
"provider": self.name,
|
|
122
|
+
"domain": repr(domain),
|
|
123
|
+
"Conventions": "CF-1.8",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return ds
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# forkairos/providers/era5.py
|
|
2
|
+
import cdsapi
|
|
3
|
+
import xarray as xr
|
|
4
|
+
import numpy as np
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from forkairos.providers.base import BaseProvider
|
|
7
|
+
from forkairos.domain import Domain
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ERA5Provider(BaseProvider):
|
|
11
|
+
|
|
12
|
+
name = "era5"
|
|
13
|
+
mode = "reanalysis"
|
|
14
|
+
|
|
15
|
+
VARIABLES = {
|
|
16
|
+
"temperature_2m": "Air temperature at 2m (°C)",
|
|
17
|
+
"precipitation": "Total precipitation (m)",
|
|
18
|
+
"snowfall": "Snowfall (m of water equivalent)",
|
|
19
|
+
"snow_depth": "Snow depth (m of water equivalent)",
|
|
20
|
+
"surface_pressure": "Surface pressure (Pa)",
|
|
21
|
+
"wind_speed_10m_u": "U-component of wind at 10m (m/s)",
|
|
22
|
+
"wind_speed_10m_v": "V-component of wind at 10m (m/s)",
|
|
23
|
+
"relative_humidity": "Relative humidity (%)",
|
|
24
|
+
"shortwave_radiation": "Surface solar radiation downwards (J/m²)",
|
|
25
|
+
"longwave_radiation": "Surface thermal radiation downwards (J/m²)",
|
|
26
|
+
"snow_cover": "Fraction of snow cover (0-1)",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Mapping forkairos variable names → CDS parameter names
|
|
30
|
+
CDS_NAMES = {
|
|
31
|
+
"temperature_2m": "2m_temperature",
|
|
32
|
+
"precipitation": "total_precipitation",
|
|
33
|
+
"snowfall": "snowfall",
|
|
34
|
+
"snow_depth": "snow_depth",
|
|
35
|
+
"surface_pressure": "surface_pressure",
|
|
36
|
+
"wind_speed_10m_u": "10m_u_component_of_wind",
|
|
37
|
+
"wind_speed_10m_v": "10m_v_component_of_wind",
|
|
38
|
+
"relative_humidity": "relative_humidity",
|
|
39
|
+
"shortwave_radiation": "surface_solar_radiation_downwards",
|
|
40
|
+
"longwave_radiation": "surface_thermal_radiation_downwards",
|
|
41
|
+
"snow_cover": "fraction_of_snow_cover",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Mapping CDS short names (what ERA5 actually returns) → forkairos names
|
|
45
|
+
CDS_SHORT_NAMES = {
|
|
46
|
+
"t2m": "temperature_2m",
|
|
47
|
+
"tp": "precipitation",
|
|
48
|
+
"sf": "snowfall",
|
|
49
|
+
"sd": "snow_depth",
|
|
50
|
+
"sp": "surface_pressure",
|
|
51
|
+
"u10": "wind_speed_10m_u",
|
|
52
|
+
"v10": "wind_speed_10m_v",
|
|
53
|
+
"r": "relative_humidity",
|
|
54
|
+
"ssrd": "shortwave_radiation",
|
|
55
|
+
"strd": "longwave_radiation",
|
|
56
|
+
"fscov": "snow_cover",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
FREQUENCIES = ["1h"]
|
|
60
|
+
|
|
61
|
+
def available_variables(self) -> dict[str, str]:
|
|
62
|
+
return self.VARIABLES
|
|
63
|
+
|
|
64
|
+
def available_date_range(self) -> tuple[str, str]:
|
|
65
|
+
import pandas as pd
|
|
66
|
+
end = (pd.Timestamp.today() - pd.DateOffset(months=3)).strftime("%Y-%m-%d")
|
|
67
|
+
return ("1940-01-01", end)
|
|
68
|
+
|
|
69
|
+
def available_frequencies(self) -> list[str]:
|
|
70
|
+
return self.FREQUENCIES
|
|
71
|
+
|
|
72
|
+
def download(
|
|
73
|
+
self,
|
|
74
|
+
domain: Domain,
|
|
75
|
+
variables: list[str],
|
|
76
|
+
start: str,
|
|
77
|
+
end: str,
|
|
78
|
+
freq: str = "1h",
|
|
79
|
+
cache_dir: str | Path = ".cache_era5",
|
|
80
|
+
) -> xr.Dataset:
|
|
81
|
+
|
|
82
|
+
# Validate inputs
|
|
83
|
+
for v in variables:
|
|
84
|
+
if v not in self.VARIABLES:
|
|
85
|
+
raise ValueError(f"Variable '{v}' not available. Choose from: {list(self.VARIABLES)}")
|
|
86
|
+
|
|
87
|
+
# Translate to CDS names
|
|
88
|
+
cds_vars = [self.CDS_NAMES[v] for v in variables]
|
|
89
|
+
|
|
90
|
+
# Build date range
|
|
91
|
+
import pandas as pd
|
|
92
|
+
dates = pd.date_range(start, end, freq="D")
|
|
93
|
+
years = list(dict.fromkeys(str(d.year) for d in dates))
|
|
94
|
+
months = list(dict.fromkeys(f"{d.month:02d}" for d in dates))
|
|
95
|
+
days = list(dict.fromkeys(f"{d.day:02d}" for d in dates))
|
|
96
|
+
|
|
97
|
+
# Bbox: CDS expects [north, west, south, east]
|
|
98
|
+
west, south, east, north = domain.bbox
|
|
99
|
+
area = [north, west, south, east]
|
|
100
|
+
|
|
101
|
+
# Download
|
|
102
|
+
cache_dir = Path(cache_dir)
|
|
103
|
+
cache_dir.mkdir(exist_ok=True)
|
|
104
|
+
zip_file = cache_dir / f"era5_{'_'.join(variables)}_{start}_{end}.zip"
|
|
105
|
+
output_file = cache_dir / f"era5_{'_'.join(variables)}_{start}_{end}.nc"
|
|
106
|
+
|
|
107
|
+
if not output_file.exists():
|
|
108
|
+
client = cdsapi.Client()
|
|
109
|
+
partial_files = []
|
|
110
|
+
|
|
111
|
+
for var_name, cds_var in zip(variables, cds_vars):
|
|
112
|
+
var_zip = cache_dir / f"era5_{var_name}_{start}_{end}.zip"
|
|
113
|
+
var_nc = cache_dir / f"era5_{var_name}_{start}_{end}.nc"
|
|
114
|
+
partial_files.append(var_nc)
|
|
115
|
+
|
|
116
|
+
if not var_nc.exists():
|
|
117
|
+
client.retrieve(
|
|
118
|
+
"reanalysis-era5-single-levels",
|
|
119
|
+
{
|
|
120
|
+
"product_type": "reanalysis",
|
|
121
|
+
"variable": [cds_var],
|
|
122
|
+
"year": years,
|
|
123
|
+
"month": months,
|
|
124
|
+
"day": days,
|
|
125
|
+
"time": [f"{h:02d}:00" for h in range(24)],
|
|
126
|
+
"area": area,
|
|
127
|
+
"format": "netcdf",
|
|
128
|
+
},
|
|
129
|
+
str(var_zip),
|
|
130
|
+
)
|
|
131
|
+
import zipfile
|
|
132
|
+
if zipfile.is_zipfile(var_zip):
|
|
133
|
+
with zipfile.ZipFile(var_zip, "r") as z:
|
|
134
|
+
names = z.namelist()
|
|
135
|
+
nc_name = next(n for n in names if n.endswith(".nc"))
|
|
136
|
+
z.extract(nc_name, cache_dir)
|
|
137
|
+
(cache_dir / nc_name).rename(var_nc)
|
|
138
|
+
var_zip.unlink()
|
|
139
|
+
else:
|
|
140
|
+
# Already a NetCDF
|
|
141
|
+
var_zip.rename(var_nc)
|
|
142
|
+
else:
|
|
143
|
+
print(f"[era5] Using cached file: {var_nc}")
|
|
144
|
+
|
|
145
|
+
# Merge all variables into one dataset
|
|
146
|
+
datasets = [xr.open_dataset(f) for f in partial_files]
|
|
147
|
+
merged = xr.merge(datasets, compat="override")
|
|
148
|
+
merged.to_netcdf(output_file)
|
|
149
|
+
for ds_tmp in datasets:
|
|
150
|
+
ds_tmp.close()
|
|
151
|
+
|
|
152
|
+
else:
|
|
153
|
+
print(f"[era5] Using cached file: {output_file}")
|
|
154
|
+
|
|
155
|
+
# Load and rename short CDS names → forkairos names
|
|
156
|
+
ds = xr.open_dataset(output_file)
|
|
157
|
+
rename_dict = {var: self.CDS_SHORT_NAMES[var] for var in ds.data_vars if var in self.CDS_SHORT_NAMES}
|
|
158
|
+
ds = ds.rename(rename_dict)
|
|
159
|
+
|
|
160
|
+
# Rename coordinates to match forkairos convention
|
|
161
|
+
coord_map = {}
|
|
162
|
+
if "valid_time" in ds.coords:
|
|
163
|
+
coord_map["valid_time"] = "time"
|
|
164
|
+
if "latitude" in ds.coords:
|
|
165
|
+
coord_map["latitude"] = "lat"
|
|
166
|
+
if "longitude" in ds.coords:
|
|
167
|
+
coord_map["longitude"] = "lon"
|
|
168
|
+
if coord_map:
|
|
169
|
+
ds = ds.rename(coord_map)
|
|
170
|
+
|
|
171
|
+
# Drop auxiliary coords not needed
|
|
172
|
+
for c in ["expver", "number"]:
|
|
173
|
+
if c in ds.coords:
|
|
174
|
+
ds = ds.drop_vars(c)
|
|
175
|
+
|
|
176
|
+
ds.attrs = {
|
|
177
|
+
"source": "ERA5 reanalysis (Copernicus CDS)",
|
|
178
|
+
"provider": self.name,
|
|
179
|
+
"domain": repr(domain),
|
|
180
|
+
"Conventions": "CF-1.8",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return ds
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# forkairos/providers/gfs.py
|
|
2
|
+
import requests
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import xarray as xr
|
|
5
|
+
import numpy as np
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from forkairos.providers.base import BaseProvider
|
|
8
|
+
from forkairos.domain import Domain
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GFSProvider(BaseProvider):
|
|
12
|
+
|
|
13
|
+
name = "gfs"
|
|
14
|
+
mode = "forecast"
|
|
15
|
+
|
|
16
|
+
VARIABLES = {
|
|
17
|
+
"temperature_2m": "Air temperature at 2m (°C)",
|
|
18
|
+
"precipitation": "Total precipitation (kg/m²)",
|
|
19
|
+
"snowfall": "Snowfall (kg/m²)",
|
|
20
|
+
"surface_pressure": "Surface pressure (Pa)",
|
|
21
|
+
"wind_speed_10m_u": "U-component of wind at 10m (m/s)",
|
|
22
|
+
"wind_speed_10m_v": "V-component of wind at 10m (m/s)",
|
|
23
|
+
"relative_humidity": "Relative humidity at 2m (%)",
|
|
24
|
+
"shortwave_radiation": "Downward shortwave radiation (W/m²)",
|
|
25
|
+
"snow_depth": "Snow depth (m)",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Mapping forkairos names → Open-Meteo GFS variable names
|
|
29
|
+
OPENMETEO_NAMES = {
|
|
30
|
+
"temperature_2m": "temperature_2m",
|
|
31
|
+
"precipitation": "precipitation",
|
|
32
|
+
"snowfall": "snowfall",
|
|
33
|
+
"surface_pressure": "surface_pressure",
|
|
34
|
+
"wind_speed_10m_u": "wind_u_component_10m",
|
|
35
|
+
"wind_speed_10m_v": "wind_v_component_10m",
|
|
36
|
+
"relative_humidity": "relative_humidity_2m",
|
|
37
|
+
"shortwave_radiation": "shortwave_radiation",
|
|
38
|
+
"snow_depth": "snow_depth",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
FREQUENCIES = ["1h", "3h", "6h", "1d"]
|
|
42
|
+
|
|
43
|
+
# GFS via Open-Meteo forecast endpoint
|
|
44
|
+
FORECAST_URL = "https://api.open-meteo.com/v1/gfs"
|
|
45
|
+
|
|
46
|
+
def available_variables(self) -> dict[str, str]:
|
|
47
|
+
return self.VARIABLES
|
|
48
|
+
|
|
49
|
+
def available_date_range(self) -> tuple[str, str]:
|
|
50
|
+
start = pd.Timestamp.today().strftime("%Y-%m-%d")
|
|
51
|
+
end = (pd.Timestamp.today() + pd.Timedelta(days=15)).strftime("%Y-%m-%d")
|
|
52
|
+
return (start, "present+16days")
|
|
53
|
+
|
|
54
|
+
def available_frequencies(self) -> list[str]:
|
|
55
|
+
return self.FREQUENCIES
|
|
56
|
+
|
|
57
|
+
def download(
|
|
58
|
+
self,
|
|
59
|
+
domain: Domain,
|
|
60
|
+
variables: list[str],
|
|
61
|
+
start: str,
|
|
62
|
+
end: str,
|
|
63
|
+
freq: str = "1h",
|
|
64
|
+
) -> xr.Dataset:
|
|
65
|
+
|
|
66
|
+
import openmeteo_requests
|
|
67
|
+
import requests_cache
|
|
68
|
+
from retry_requests import retry
|
|
69
|
+
|
|
70
|
+
# Validate inputs
|
|
71
|
+
for v in variables:
|
|
72
|
+
if v not in self.VARIABLES:
|
|
73
|
+
raise ValueError(f"Variable '{v}' not available. Choose from: {list(self.VARIABLES)}")
|
|
74
|
+
if freq not in self.FREQUENCIES:
|
|
75
|
+
raise ValueError(f"Frequency '{freq}' not available. Choose from: {self.FREQUENCIES}")
|
|
76
|
+
|
|
77
|
+
# Translate variable names
|
|
78
|
+
om_vars = [self.OPENMETEO_NAMES[v] for v in variables]
|
|
79
|
+
|
|
80
|
+
# Build grid of points covering the bbox
|
|
81
|
+
west, south, east, north = domain.bbox
|
|
82
|
+
lats = np.arange(south, north + 0.25, 0.25).round(4)
|
|
83
|
+
lons = np.arange(west, east + 0.25, 0.25).round(4)
|
|
84
|
+
|
|
85
|
+
# Setup client
|
|
86
|
+
cache_session = requests_cache.CachedSession(".cache_gfs", expire_after=3600)
|
|
87
|
+
retry_session = retry(cache_session, retries=5, backoff_factor=0.2)
|
|
88
|
+
client = openmeteo_requests.Client(session=retry_session)
|
|
89
|
+
|
|
90
|
+
# Download grid
|
|
91
|
+
rows = []
|
|
92
|
+
for lat in lats:
|
|
93
|
+
cols = []
|
|
94
|
+
for lon in lons:
|
|
95
|
+
params = {
|
|
96
|
+
"latitude": lat,
|
|
97
|
+
"longitude": lon,
|
|
98
|
+
"hourly": om_vars,
|
|
99
|
+
"start_date": start,
|
|
100
|
+
"end_date": end,
|
|
101
|
+
"timezone": "UTC",
|
|
102
|
+
}
|
|
103
|
+
responses = client.weather_api(self.FORECAST_URL, params=params)
|
|
104
|
+
r = responses[0]
|
|
105
|
+
hourly = r.Hourly()
|
|
106
|
+
|
|
107
|
+
times = pd.date_range(
|
|
108
|
+
start=pd.to_datetime(hourly.Time(), unit="s", utc=True),
|
|
109
|
+
end=pd.to_datetime(hourly.TimeEnd(), unit="s", utc=True),
|
|
110
|
+
freq=pd.Timedelta(seconds=hourly.Interval()),
|
|
111
|
+
inclusive="left",
|
|
112
|
+
).tz_localize(None)
|
|
113
|
+
|
|
114
|
+
data_vars = {}
|
|
115
|
+
for i, v in enumerate(variables):
|
|
116
|
+
data_vars[v] = (["time"], hourly.Variables(i).ValuesAsNumpy())
|
|
117
|
+
|
|
118
|
+
ds_point = xr.Dataset(
|
|
119
|
+
data_vars,
|
|
120
|
+
coords={"time": times},
|
|
121
|
+
).expand_dims(lat=[lat], lon=[lon])
|
|
122
|
+
|
|
123
|
+
cols.append(ds_point)
|
|
124
|
+
rows.append(xr.concat(cols, dim="lon"))
|
|
125
|
+
|
|
126
|
+
ds = xr.concat(rows, dim="lat")
|
|
127
|
+
|
|
128
|
+
# CF-compliant metadata
|
|
129
|
+
ds["lat"].attrs = {"units": "degrees_north", "standard_name": "latitude"}
|
|
130
|
+
ds["lon"].attrs = {"units": "degrees_east", "standard_name": "longitude"}
|
|
131
|
+
ds["time"].attrs = {"standard_name": "time"}
|
|
132
|
+
ds.attrs = {
|
|
133
|
+
"source": "GFS forecast via Open-Meteo API",
|
|
134
|
+
"provider": self.name,
|
|
135
|
+
"domain": repr(domain),
|
|
136
|
+
"Conventions": "CF-1.8",
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return ds
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# forkairos/providers/open_meteo.py
|
|
2
|
+
import openmeteo_requests
|
|
3
|
+
import requests_cache
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import xarray as xr
|
|
6
|
+
import numpy as np
|
|
7
|
+
from retry_requests import retry
|
|
8
|
+
from forkairos.providers.base import BaseProvider
|
|
9
|
+
from forkairos.domain import Domain
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenMeteoProvider(BaseProvider):
|
|
13
|
+
|
|
14
|
+
name = "open_meteo"
|
|
15
|
+
mode = "both"
|
|
16
|
+
|
|
17
|
+
VARIABLES = {
|
|
18
|
+
"temperature_2m": "Air temperature at 2m (°C)",
|
|
19
|
+
"precipitation": "Total precipitation (mm)",
|
|
20
|
+
"snowfall": "Snowfall (cm)",
|
|
21
|
+
"snow_depth": "Snow depth (m)",
|
|
22
|
+
"surface_pressure": "Surface pressure (hPa)",
|
|
23
|
+
"wind_speed_10m": "Wind speed at 10m (km/h)",
|
|
24
|
+
"wind_direction_10m": "Wind direction at 10m (°)",
|
|
25
|
+
"relative_humidity_2m": "Relative humidity at 2m (%)",
|
|
26
|
+
"shortwave_radiation": "Shortwave solar radiation (W/m²)",
|
|
27
|
+
"et0_fao_evapotranspiration": "ET0 FAO evapotranspiration (mm)",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
FREQUENCIES = ["1h", "3h", "6h", "1d"]
|
|
31
|
+
|
|
32
|
+
def available_variables(self) -> dict[str, str]:
|
|
33
|
+
return self.VARIABLES
|
|
34
|
+
|
|
35
|
+
def available_date_range(self) -> tuple[str, str]:
|
|
36
|
+
return ("1940-01-01", "present")
|
|
37
|
+
|
|
38
|
+
def available_frequencies(self) -> list[str]:
|
|
39
|
+
return self.FREQUENCIES
|
|
40
|
+
|
|
41
|
+
def download(
|
|
42
|
+
self,
|
|
43
|
+
domain: Domain,
|
|
44
|
+
variables: list[str],
|
|
45
|
+
start: str,
|
|
46
|
+
end: str,
|
|
47
|
+
freq: str = "1h",
|
|
48
|
+
) -> xr.Dataset:
|
|
49
|
+
|
|
50
|
+
# Validate inputs
|
|
51
|
+
for v in variables:
|
|
52
|
+
if v not in self.VARIABLES:
|
|
53
|
+
raise ValueError(f"Variable '{v}' not available. Choose from: {list(self.VARIABLES)}")
|
|
54
|
+
if freq not in self.FREQUENCIES:
|
|
55
|
+
raise ValueError(f"Frequency '{freq}' not available. Choose from: {self.FREQUENCIES}")
|
|
56
|
+
|
|
57
|
+
# Build grid of points covering the bbox
|
|
58
|
+
west, south, east, north = domain.bbox
|
|
59
|
+
lats = np.arange(south, north + 0.25, 0.25).round(4)
|
|
60
|
+
lons = np.arange(west, east + 0.25, 0.25).round(4)
|
|
61
|
+
|
|
62
|
+
# Setup Open-Meteo client with cache and retry
|
|
63
|
+
cache_session = requests_cache.CachedSession(".cache_openmeteo", expire_after=-1)
|
|
64
|
+
retry_session = retry(cache_session, retries=5, backoff_factor=0.2)
|
|
65
|
+
client = openmeteo_requests.Client(session=retry_session)
|
|
66
|
+
|
|
67
|
+
# Choose endpoint: reanalysis vs forecast
|
|
68
|
+
today = pd.Timestamp.today().normalize()
|
|
69
|
+
end_dt = pd.Timestamp(end)
|
|
70
|
+
if end_dt < today - pd.Timedelta(days=7):
|
|
71
|
+
url = "https://archive-api.open-meteo.com/v1/archive"
|
|
72
|
+
else:
|
|
73
|
+
url = "https://api.open-meteo.com/v1/forecast"
|
|
74
|
+
|
|
75
|
+
# Download one point per grid cell
|
|
76
|
+
rows = []
|
|
77
|
+
for lat in lats:
|
|
78
|
+
cols = []
|
|
79
|
+
for lon in lons:
|
|
80
|
+
params = {
|
|
81
|
+
"latitude": lat,
|
|
82
|
+
"longitude": lon,
|
|
83
|
+
"hourly": variables,
|
|
84
|
+
"start_date": start,
|
|
85
|
+
"end_date": end,
|
|
86
|
+
"timezone": "UTC",
|
|
87
|
+
}
|
|
88
|
+
responses = client.weather_api(url, params=params)
|
|
89
|
+
r = responses[0]
|
|
90
|
+
hourly = r.Hourly()
|
|
91
|
+
|
|
92
|
+
times = pd.date_range(
|
|
93
|
+
start=pd.to_datetime(hourly.Time(), unit="s", utc=True),
|
|
94
|
+
end=pd.to_datetime(hourly.TimeEnd(), unit="s", utc=True),
|
|
95
|
+
freq=pd.Timedelta(seconds=hourly.Interval()),
|
|
96
|
+
inclusive="left",
|
|
97
|
+
).tz_localize(None)
|
|
98
|
+
|
|
99
|
+
data_vars = {}
|
|
100
|
+
for i, v in enumerate(variables):
|
|
101
|
+
data_vars[v] = (["time"], hourly.Variables(i).ValuesAsNumpy())
|
|
102
|
+
|
|
103
|
+
ds_point = xr.Dataset(
|
|
104
|
+
data_vars,
|
|
105
|
+
coords={"time": times},
|
|
106
|
+
).expand_dims(lat=[lat], lon=[lon])
|
|
107
|
+
|
|
108
|
+
cols.append(ds_point)
|
|
109
|
+
|
|
110
|
+
rows.append(xr.concat(cols, dim="lon"))
|
|
111
|
+
|
|
112
|
+
ds = xr.concat(rows, dim="lat")
|
|
113
|
+
|
|
114
|
+
# CF-compliant metadata
|
|
115
|
+
ds["lat"].attrs = {"units": "degrees_north", "standard_name": "latitude"}
|
|
116
|
+
ds["lon"].attrs = {"units": "degrees_east", "standard_name": "longitude"}
|
|
117
|
+
ds["time"].attrs = {"standard_name": "time"}
|
|
118
|
+
ds.attrs = {
|
|
119
|
+
"source": "Open-Meteo API",
|
|
120
|
+
"provider": self.name,
|
|
121
|
+
"domain": repr(domain),
|
|
122
|
+
"Conventions": "CF-1.8",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return ds
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forkairos
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Operational pipeline for meteorological forcing data in CF-compliant NetCDF
|
|
5
|
+
Author-email: Cristóbal Reyes <cristobal.sarda@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Cristóbal Sardá
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/Jano01/forkairos
|
|
29
|
+
Project-URL: Documentation, https://forkairos.readthedocs.io
|
|
30
|
+
Project-URL: Repository, https://github.com/Jano01/forkairos
|
|
31
|
+
Project-URL: Issues, https://github.com/Jano01/forkairos/issues
|
|
32
|
+
Keywords: hydrology,meteorology,NWP,forcing,NetCDF,ERA5,GFS,ECMWF
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Intended Audience :: Science/Research
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
|
|
38
|
+
Classifier: Topic :: Scientific/Engineering :: Hydrology
|
|
39
|
+
Requires-Python: >=3.12
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
License-File: LICENSE
|
|
42
|
+
Requires-Dist: xarray>=2024.1.0
|
|
43
|
+
Requires-Dist: geopandas>=0.14.0
|
|
44
|
+
Requires-Dist: netcdf4>=1.6.0
|
|
45
|
+
Requires-Dist: requests>=2.31.0
|
|
46
|
+
Requires-Dist: tqdm>=4.66.0
|
|
47
|
+
Requires-Dist: pydantic>=2.0.0
|
|
48
|
+
Requires-Dist: openmeteo-requests>=1.1.0
|
|
49
|
+
Requires-Dist: requests-cache>=1.1.0
|
|
50
|
+
Requires-Dist: retry-requests>=2.0.0
|
|
51
|
+
Requires-Dist: cdsapi>=0.6.0
|
|
52
|
+
Requires-Dist: cfgrib>=0.9.10
|
|
53
|
+
Requires-Dist: scipy>=1.12.0
|
|
54
|
+
Requires-Dist: numpy>=1.26.0
|
|
55
|
+
Requires-Dist: pandas>=2.2.0
|
|
56
|
+
Requires-Dist: ecmwf-opendata>=0.3.0
|
|
57
|
+
Dynamic: license-file
|
|
58
|
+
|
|
59
|
+
# snowops
|
|
60
|
+
Operational data pipeline for snow hydrology — NWP forcings and satellite observations in CF-compliant NetCDF
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
forkairos/__init__.py
|
|
5
|
+
forkairos/domain.py
|
|
6
|
+
forkairos/export.py
|
|
7
|
+
forkairos/pipeline.py
|
|
8
|
+
forkairos/processing.py
|
|
9
|
+
forkairos.egg-info/PKG-INFO
|
|
10
|
+
forkairos.egg-info/SOURCES.txt
|
|
11
|
+
forkairos.egg-info/dependency_links.txt
|
|
12
|
+
forkairos.egg-info/requires.txt
|
|
13
|
+
forkairos.egg-info/top_level.txt
|
|
14
|
+
forkairos/providers/__init__.py
|
|
15
|
+
forkairos/providers/base.py
|
|
16
|
+
forkairos/providers/ecmwf_open.py
|
|
17
|
+
forkairos/providers/era5.py
|
|
18
|
+
forkairos/providers/gfs.py
|
|
19
|
+
forkairos/providers/open_meteo.py
|
|
20
|
+
tests/test_domain.py
|
|
21
|
+
tests/test_processing.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
xarray>=2024.1.0
|
|
2
|
+
geopandas>=0.14.0
|
|
3
|
+
netcdf4>=1.6.0
|
|
4
|
+
requests>=2.31.0
|
|
5
|
+
tqdm>=4.66.0
|
|
6
|
+
pydantic>=2.0.0
|
|
7
|
+
openmeteo-requests>=1.1.0
|
|
8
|
+
requests-cache>=1.1.0
|
|
9
|
+
retry-requests>=2.0.0
|
|
10
|
+
cdsapi>=0.6.0
|
|
11
|
+
cfgrib>=0.9.10
|
|
12
|
+
scipy>=1.12.0
|
|
13
|
+
numpy>=1.26.0
|
|
14
|
+
pandas>=2.2.0
|
|
15
|
+
ecmwf-opendata>=0.3.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forkairos
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "forkairos"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Operational pipeline for meteorological forcing data in CF-compliant NetCDF"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Cristóbal Reyes", email = "cristobal.sarda@gmail.com" }
|
|
13
|
+
]
|
|
14
|
+
keywords = ["hydrology", "meteorology", "NWP", "forcing", "NetCDF", "ERA5", "GFS", "ECMWF"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Atmospheric Science",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Hydrology",
|
|
22
|
+
]
|
|
23
|
+
requires-python = ">=3.12"
|
|
24
|
+
dependencies = [
|
|
25
|
+
"xarray>=2024.1.0",
|
|
26
|
+
"geopandas>=0.14.0",
|
|
27
|
+
"netcdf4>=1.6.0",
|
|
28
|
+
"requests>=2.31.0",
|
|
29
|
+
"tqdm>=4.66.0",
|
|
30
|
+
"pydantic>=2.0.0",
|
|
31
|
+
"openmeteo-requests>=1.1.0",
|
|
32
|
+
"requests-cache>=1.1.0",
|
|
33
|
+
"retry-requests>=2.0.0",
|
|
34
|
+
"cdsapi>=0.6.0",
|
|
35
|
+
"cfgrib>=0.9.10",
|
|
36
|
+
"scipy>=1.12.0",
|
|
37
|
+
"numpy>=1.26.0",
|
|
38
|
+
"pandas>=2.2.0",
|
|
39
|
+
"ecmwf-opendata>=0.3.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/Jano01/forkairos"
|
|
44
|
+
Documentation = "https://forkairos.readthedocs.io"
|
|
45
|
+
Repository = "https://github.com/Jano01/forkairos"
|
|
46
|
+
Issues = "https://github.com/Jano01/forkairos/issues"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["."]
|
|
50
|
+
include = ["forkairos*"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
markers = [
|
|
55
|
+
"credentials: tests that require external API credentials (deselect with -m 'not credentials')",
|
|
56
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# tests/test_domain.py
|
|
2
|
+
import pytest
|
|
3
|
+
from forkairos.domain import Domain
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_domain_loads(synthetic_shapefile):
|
|
7
|
+
"""Domain loads from shapefile without error."""
|
|
8
|
+
domain = Domain(synthetic_shapefile, buffer_km=5.0)
|
|
9
|
+
assert domain is not None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_domain_bbox_order(synthetic_domain):
|
|
13
|
+
"""Bbox is in correct order: west < east, south < north."""
|
|
14
|
+
west, south, east, north = synthetic_domain.bbox
|
|
15
|
+
assert west < east
|
|
16
|
+
assert south < north
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_domain_bbox_wgs84(synthetic_domain):
|
|
20
|
+
"""Bbox coordinates are in valid WGS84 range."""
|
|
21
|
+
west, south, east, north = synthetic_domain.bbox
|
|
22
|
+
assert -180 <= west <= 180
|
|
23
|
+
assert -180 <= east <= 180
|
|
24
|
+
assert -90 <= south <= 90
|
|
25
|
+
assert -90 <= north <= 90
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_domain_buffer_expands_bbox(synthetic_shapefile):
|
|
29
|
+
"""Buffer increases bbox size."""
|
|
30
|
+
domain_no_buffer = Domain(synthetic_shapefile, buffer_km=0.0)
|
|
31
|
+
domain_with_buffer = Domain(synthetic_shapefile, buffer_km=10.0)
|
|
32
|
+
|
|
33
|
+
w0, s0, e0, n0 = domain_no_buffer.bbox
|
|
34
|
+
w1, s1, e1, n1 = domain_with_buffer.bbox
|
|
35
|
+
|
|
36
|
+
assert w1 < w0
|
|
37
|
+
assert s1 < s0
|
|
38
|
+
assert e1 > e0
|
|
39
|
+
assert n1 > n0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_domain_repr(synthetic_domain):
|
|
43
|
+
"""Domain repr contains shapefile name and bbox."""
|
|
44
|
+
r = repr(synthetic_domain)
|
|
45
|
+
assert "test_basin.shp" in r
|
|
46
|
+
assert "bbox" in r
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# tests/test_processing.py
|
|
2
|
+
import pytest
|
|
3
|
+
import numpy as np
|
|
4
|
+
import xarray as xr
|
|
5
|
+
from forkairos.processing import regrid, bias_correct, resolution_guide
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_regrid_increases_resolution(mock_dataset):
|
|
9
|
+
"""Regrid produces finer grid than input."""
|
|
10
|
+
ds_fine = regrid(mock_dataset, resolution=0.1)
|
|
11
|
+
assert len(ds_fine.lat) > len(mock_dataset.lat)
|
|
12
|
+
assert len(ds_fine.lon) > len(mock_dataset.lon)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_regrid_preserves_variables(mock_dataset):
|
|
16
|
+
"""Regrid keeps all variables."""
|
|
17
|
+
ds_fine = regrid(mock_dataset, resolution=0.1)
|
|
18
|
+
for var in mock_dataset.data_vars:
|
|
19
|
+
assert var in ds_fine.data_vars
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_regrid_preserves_time(mock_dataset):
|
|
23
|
+
"""Regrid does not alter time dimension."""
|
|
24
|
+
ds_fine = regrid(mock_dataset, resolution=0.1)
|
|
25
|
+
assert len(ds_fine.time) == len(mock_dataset.time)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_regrid_adds_metadata(mock_dataset):
|
|
29
|
+
"""Regrid adds regridding attribute to dataset."""
|
|
30
|
+
ds_fine = regrid(mock_dataset, resolution=0.1)
|
|
31
|
+
assert "regridding" in ds_fine.attrs
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_regrid_invalid_method(mock_dataset):
|
|
35
|
+
"""Regrid raises ValueError for unknown method."""
|
|
36
|
+
with pytest.raises(ValueError, match="Unknown method"):
|
|
37
|
+
regrid(mock_dataset, resolution=0.1, method="kriging")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_bias_correct_output_shape(mock_dataset, mock_reference):
|
|
41
|
+
"""Bias correction preserves dataset shape."""
|
|
42
|
+
ds_corrected = bias_correct(mock_dataset, mock_reference)
|
|
43
|
+
for var in mock_dataset.data_vars:
|
|
44
|
+
assert ds_corrected[var].shape == mock_dataset[var].shape
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_bias_correct_adds_metadata(mock_dataset, mock_reference):
|
|
48
|
+
"""Bias correction adds attribute to dataset."""
|
|
49
|
+
ds_corrected = bias_correct(mock_dataset, mock_reference)
|
|
50
|
+
assert "bias_correction" in ds_corrected.attrs
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_bias_correct_invalid_method(mock_dataset, mock_reference):
|
|
54
|
+
"""Bias correction raises ValueError for unknown method."""
|
|
55
|
+
with pytest.raises(ValueError, match="Unknown method"):
|
|
56
|
+
bias_correct(mock_dataset, mock_reference, method="delta")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_resolution_guide_runs(capsys):
|
|
60
|
+
"""resolution_guide prints without error."""
|
|
61
|
+
resolution_guide()
|
|
62
|
+
captured = capsys.readouterr()
|
|
63
|
+
assert "SRTM" in captured.out
|