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.
@@ -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.
@@ -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,2 @@
1
+ # snowops
2
+ Operational data pipeline for snow hydrology — NWP forcings and satellite observations in CF-compliant NetCDF
@@ -0,0 +1,6 @@
1
+ # forkairos/__init__.py
2
+ from forkairos.domain import Domain
3
+ from forkairos.pipeline import run, get_provider
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["Domain", "run", "get_provider"]
@@ -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,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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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