wiend 0.1.0__py3-none-any.whl

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.
wiend/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from wiend.download import download, download_and_downscale
4
+ from wiend.downscale import downscale
5
+
6
+ __all__ = [
7
+ "__version__",
8
+ "download",
9
+ "downscale",
10
+ "download_and_downscale",
11
+ ]
wiend/data_manager.py ADDED
@@ -0,0 +1,267 @@
1
+ """Data access and cache helpers for wiend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import xarray as xr
15
+
16
+ from wiend.validation import (
17
+ check_dims_and_coords,
18
+ check_lat_lon_1d_ascending,
19
+ )
20
+
21
+ EDH_DEFAULT_PATH = "era5/reanalysis-era5-single-levels-v0.zarr"
22
+ DEFAULT_GITHUB_REPOSITORY = "florianscheiber/wiend"
23
+ DEFAULT_WEIBULL_RELEASE_TAG = "latest"
24
+
25
+
26
+ def get_cache_dir(cache_dir: str | Path | None = None) -> Path:
27
+ """Return the local cache directory, defaulting to ~/.wiend."""
28
+ if cache_dir is not None:
29
+ return Path(cache_dir).expanduser().resolve()
30
+ return (Path.home() / ".wiend").resolve()
31
+
32
+
33
+ def load_edh_key(edh_key: str | None = None) -> str:
34
+ """Load EDH token from argument or environment."""
35
+ if edh_key is not None:
36
+ return edh_key
37
+
38
+ try:
39
+ from dotenv import load_dotenv
40
+ except ImportError:
41
+ load_dotenv = None
42
+
43
+ if load_dotenv is not None:
44
+ load_dotenv()
45
+
46
+ key = os.getenv("EDH_TOKEN") or os.getenv("EDH_KEY")
47
+ if not key:
48
+ raise ValueError("No EDH token found. Pass edh_key or set EDH_TOKEN/EDH_KEY.")
49
+ return key
50
+
51
+
52
+ def build_edh_url(edh_path: str, edh_key: str) -> str:
53
+ """Build full EDH URL from logical path and token."""
54
+ if edh_path.startswith("http://") or edh_path.startswith("https://"):
55
+ return edh_path
56
+ normalized_path = edh_path.lstrip("/")
57
+ return f"https://edh:{edh_key}@api.earthdatahub.destine.eu/{normalized_path}"
58
+
59
+
60
+ def load_wind_from_zarr(path_input: str | Path) -> xr.DataArray:
61
+ """Load wind data from Zarr and validate expected structure."""
62
+ ds = xr.open_zarr(str(path_input))
63
+
64
+ if "wind" not in ds:
65
+ raise KeyError(f"Zarr missing variable 'wind'. Found: {list(ds.data_vars)}")
66
+
67
+ wind = ds["wind"].astype("float32")
68
+ required_dims = ("time", "lat", "lon")
69
+ required_coords = ("time", "lat", "lon")
70
+ check_dims_and_coords(wind, required_dims, required_coords)
71
+ check_lat_lon_1d_ascending(wind["lat"], wind["lon"])
72
+ return wind
73
+
74
+
75
+ def load_weibull_params_from_zarr(
76
+ path_input: str | Path,
77
+ *,
78
+ bbox: tuple[float, float, float, float] | None = None,
79
+ crop: bool = True,
80
+ ) -> tuple[xr.DataArray, xr.DataArray]:
81
+ """Load Weibull parameters k and a with optional bbox crop."""
82
+ ds = xr.open_zarr(str(path_input))
83
+
84
+ if crop:
85
+ if bbox is None:
86
+ raise ValueError("bbox must be provided when crop=True.")
87
+ lat_min, lat_max, lon_min, lon_max = bbox
88
+ ds = ds.sel(lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max))
89
+
90
+ if "k" not in ds or "a" not in ds:
91
+ raise KeyError(f"Params Zarr {path_input} missing 'k' or 'a'.")
92
+
93
+ k = ds["k"]
94
+ a = ds["a"]
95
+
96
+ if k.sizes["lat"] == 0 or k.sizes["lon"] == 0:
97
+ raise ValueError(f"Cropping produced an empty grid for {path_input} and bbox={bbox}.")
98
+
99
+ required_dims = ("lat", "lon")
100
+ required_coords = ("lat", "lon")
101
+ check_dims_and_coords(k, required_dims, required_coords)
102
+ check_dims_and_coords(a, required_dims, required_coords)
103
+ check_lat_lon_1d_ascending(k["lat"], k["lon"])
104
+ check_lat_lon_1d_ascending(a["lat"], a["lon"])
105
+ return k, a
106
+
107
+
108
+ def _spacing_token(spacing: float | None) -> str | None:
109
+ if spacing is None:
110
+ return None
111
+ return f"spacing-{str(spacing).replace('.', '-')}"
112
+
113
+
114
+ def _github_api_release_url(repository: str, release_tag: str) -> str:
115
+ owner, repo = repository.split("/", 1)
116
+ owner_q = urllib.parse.quote(owner, safe="")
117
+ repo_q = urllib.parse.quote(repo, safe="")
118
+ if release_tag == "latest":
119
+ return f"https://api.github.com/repos/{owner_q}/{repo_q}/releases/latest"
120
+ tag_q = urllib.parse.quote(release_tag, safe="")
121
+ return f"https://api.github.com/repos/{owner_q}/{repo_q}/releases/tags/{tag_q}"
122
+
123
+
124
+ def _github_release_payload(
125
+ repository: str,
126
+ release_tag: str,
127
+ github_token: str | None = None,
128
+ ) -> dict[str, Any]:
129
+ url = _github_api_release_url(repository, release_tag)
130
+ headers = {"Accept": "application/vnd.github+json"}
131
+ if github_token:
132
+ headers["Authorization"] = f"Bearer {github_token}"
133
+ req = urllib.request.Request(url, headers=headers)
134
+ try:
135
+ with urllib.request.urlopen(req) as response:
136
+ body = response.read().decode("utf-8")
137
+ except urllib.error.HTTPError as exc:
138
+ raise RuntimeError(f"Failed to fetch GitHub release metadata from {url}: {exc}") from exc
139
+ return json.loads(body)
140
+
141
+
142
+ def _select_asset(
143
+ assets: list[dict[str, Any]],
144
+ *,
145
+ role_key: str,
146
+ spacing: float | None,
147
+ ) -> dict[str, Any]:
148
+ matches: list[dict[str, Any]] = []
149
+ for asset in assets:
150
+ name = str(asset.get("name", "")).lower()
151
+ if role_key not in name:
152
+ continue
153
+ matches.append(asset)
154
+
155
+ if not matches:
156
+ raise RuntimeError(f"No release asset found for role={role_key!r}.")
157
+ if len(matches) > 1:
158
+ names = ", ".join(str(a.get("name", "<unknown>")) for a in matches)
159
+ raise RuntimeError(f"Multiple release assets matched role={role_key!r}: {names}")
160
+ return matches[0]
161
+
162
+
163
+ def _download_file(url: str, target: Path, github_token: str | None = None) -> None:
164
+ target.parent.mkdir(parents=True, exist_ok=True)
165
+ headers: dict[str, str] = {}
166
+ if github_token:
167
+ headers["Authorization"] = f"Bearer {github_token}"
168
+
169
+ req = urllib.request.Request(url, headers=headers)
170
+ try:
171
+ with urllib.request.urlopen(req) as response, target.open("wb") as file_obj:
172
+ shutil.copyfileobj(response, file_obj)
173
+ except urllib.error.HTTPError as exc:
174
+ raise RuntimeError(f"Failed to download asset from {url}: {exc}") from exc
175
+
176
+
177
+ def _materialize_release_asset(
178
+ asset: dict[str, Any],
179
+ *,
180
+ cache_dir: Path,
181
+ github_token: str | None = None,
182
+ ) -> Path:
183
+ name = str(asset["name"])
184
+ asset_dir = cache_dir / "assets"
185
+ archive_path = asset_dir / name
186
+ extract_dir = cache_dir / "datasets" / Path(name).stem
187
+
188
+ if extract_dir.exists():
189
+ return _resolve_dataset_path(extract_dir)
190
+
191
+ if not archive_path.exists():
192
+ download_url = str(asset["browser_download_url"])
193
+ _download_file(download_url, archive_path, github_token=github_token)
194
+
195
+ suffix = archive_path.suffix.lower()
196
+ if suffix == ".zip":
197
+ shutil.unpack_archive(str(archive_path), str(extract_dir))
198
+ return _resolve_dataset_path(extract_dir)
199
+
200
+ # Fallback: non-archive asset, expose directly.
201
+ return archive_path
202
+
203
+
204
+ def _resolve_dataset_path(extract_dir: Path) -> Path:
205
+ """
206
+ Resolve the actual dataset root from an extracted release asset directory.
207
+ """
208
+ zarr_dirs = list(extract_dir.rglob("*.zarr"))
209
+ if len(zarr_dirs) == 1:
210
+ return zarr_dirs[0]
211
+ if len(zarr_dirs) > 1:
212
+ raise RuntimeError(
213
+ f"Ambiguous extracted asset {extract_dir}: multiple .zarr directories found."
214
+ )
215
+
216
+ children = [p for p in extract_dir.iterdir() if p.name != "__MACOSX"]
217
+ if len(children) == 1:
218
+ return children[0]
219
+ return extract_dir
220
+
221
+
222
+ def resolve_weibull_reference_paths(
223
+ *,
224
+ spacing: float | None,
225
+ cache_dir: str | Path | None = None,
226
+ github_repository: str = DEFAULT_GITHUB_REPOSITORY,
227
+ release_tag: str = DEFAULT_WEIBULL_RELEASE_TAG,
228
+ github_token: str | None = None,
229
+ ) -> tuple[Path, Path]:
230
+ """
231
+ Resolve baseline/manipulator Weibull parameter paths from GitHub release assets.
232
+ """
233
+ cache_path = get_cache_dir(cache_dir)
234
+ cache_path.mkdir(parents=True, exist_ok=True)
235
+
236
+ payload = _github_release_payload(
237
+ github_repository,
238
+ release_tag,
239
+ github_token=github_token,
240
+ )
241
+ assets = payload.get("assets")
242
+ if not isinstance(assets, list):
243
+ raise RuntimeError("GitHub release payload does not include an assets list.")
244
+
245
+ baseline_asset = _select_asset(
246
+ assets,
247
+ role_key="baseline",
248
+ spacing=spacing,
249
+ )
250
+ manipulator_asset = _select_asset(
251
+ assets,
252
+ role_key="corrected",
253
+ spacing=spacing,
254
+ )
255
+
256
+ baseline_path = _materialize_release_asset(
257
+ baseline_asset,
258
+ cache_dir=cache_path,
259
+ github_token=github_token,
260
+ )
261
+ manipulator_path = _materialize_release_asset(
262
+ manipulator_asset,
263
+ cache_dir=cache_path,
264
+ github_token=github_token,
265
+ )
266
+
267
+ return baseline_path, manipulator_path
wiend/download.py ADDED
@@ -0,0 +1,242 @@
1
+ """Download API for wiend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ import xarray as xr
10
+
11
+ from wiend.data_manager import (
12
+ DEFAULT_GITHUB_REPOSITORY,
13
+ DEFAULT_WEIBULL_RELEASE_TAG,
14
+ EDH_DEFAULT_PATH,
15
+ build_edh_url,
16
+ load_edh_key,
17
+ )
18
+ from wiend.downscale import downscale
19
+ from wiend.validation import normalize_wind_da, parse_bbox
20
+
21
+
22
+ def _get_wind_da(
23
+ *,
24
+ bbox: tuple[float, float, float, float],
25
+ start: str | None = None,
26
+ end: str | None = None,
27
+ edh_key: str | None = None,
28
+ edh_path: str | None = None,
29
+ height: str = "10m",
30
+ u_var: str | None = None,
31
+ v_var: str | None = None,
32
+ bbox_order: str = "lonlat",
33
+ time_dim: str = "time",
34
+ chunks: dict[str, int] | None = None,
35
+ ) -> xr.DataArray:
36
+ key = load_edh_key(edh_key)
37
+ path = edh_path or EDH_DEFAULT_PATH
38
+ url = build_edh_url(path, key)
39
+
40
+ ds_src = xr.open_dataset(
41
+ url,
42
+ engine="zarr",
43
+ chunks=chunks or {},
44
+ decode_coords="all",
45
+ mask_and_scale=False,
46
+ )
47
+
48
+ if u_var is None:
49
+ preferred_u = ["u10", "u10m", "u"] if height == "10m" else ["u100", "u100m", "u"]
50
+ for name in preferred_u:
51
+ if name in ds_src.data_vars:
52
+ u_var = name
53
+ break
54
+
55
+ if v_var is None:
56
+ preferred_v = ["v10", "v10m", "v"] if height == "10m" else ["v100", "v100m", "v"]
57
+ for name in preferred_v:
58
+ if name in ds_src.data_vars:
59
+ v_var = name
60
+ break
61
+
62
+ if u_var is None or v_var is None:
63
+ raise ValueError(f"Could not infer u/v variables. Available: {list(ds_src.data_vars)}")
64
+
65
+ u_da = normalize_wind_da(ds_src[u_var], time_dim=time_dim)
66
+ v_da = normalize_wind_da(ds_src[v_var], time_dim=time_dim)
67
+
68
+ lon_min, lat_min, lon_max, lat_max = parse_bbox(bbox, bbox_order=bbox_order)
69
+ u_da = u_da.sel(lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max))
70
+ v_da = v_da.sel(lat=slice(lat_min, lat_max), lon=slice(lon_min, lon_max))
71
+
72
+ if start is not None or end is not None:
73
+ u_da = u_da.sel(time=slice(start, end))
74
+ v_da = v_da.sel(time=slice(start, end))
75
+
76
+ u_da, v_da = xr.align(u_da, v_da, join="inner")
77
+ wind_da = (u_da**2 + v_da**2) ** 0.5
78
+ return wind_da.rename("wind").astype("float32")
79
+
80
+
81
+ def download(
82
+ *,
83
+ bbox: tuple[float, float, float, float],
84
+ start: str,
85
+ end: str,
86
+ out: str | Path | None = None,
87
+ edh_key: str | None = None,
88
+ edh_path: str | None = None,
89
+ height: str = "10m",
90
+ u_var: str | None = None,
91
+ v_var: str | None = None,
92
+ bbox_order: str = "lonlat",
93
+ time_dim: str = "time",
94
+ overwrite: bool = False,
95
+ edh_time_chunk: int = 168,
96
+ tile_lat: int = 50,
97
+ tile_lon: int = 50,
98
+ min_valid: int = 100,
99
+ compute: bool = True,
100
+ write_summary: bool = False,
101
+ ) -> xr.DataArray:
102
+ """Download ERA5 wind data and optionally write it to Zarr."""
103
+ wind_da = _get_wind_da(
104
+ bbox=bbox,
105
+ start=start,
106
+ end=end,
107
+ edh_key=edh_key,
108
+ edh_path=edh_path,
109
+ height=height,
110
+ u_var=u_var,
111
+ v_var=v_var,
112
+ bbox_order=bbox_order,
113
+ time_dim=time_dim,
114
+ chunks={"time": int(edh_time_chunk)},
115
+ )
116
+
117
+ if compute:
118
+ wind_da = wind_da.compute()
119
+
120
+ if out is None:
121
+ return wind_da
122
+
123
+ out_path = Path(out)
124
+ if out_path.exists():
125
+ if not overwrite:
126
+ raise ValueError(f"Output {out_path} exists; set overwrite=True to replace.")
127
+ if out_path.is_dir():
128
+ shutil.rmtree(out_path)
129
+ else:
130
+ out_path.unlink()
131
+
132
+ ds_out = wind_da.to_dataset()
133
+ ds_out = ds_out.assign_coords(
134
+ lat=ds_out["lat"].astype("float32"), lon=ds_out["lon"].astype("float32")
135
+ )
136
+ ds_out.attrs.update(
137
+ {
138
+ "start": start,
139
+ "end": end,
140
+ "bbox": list(map(float, bbox)),
141
+ "height": height,
142
+ "edh_path": edh_path or EDH_DEFAULT_PATH,
143
+ "time_chunk": int(edh_time_chunk),
144
+ "min_valid": int(min_valid),
145
+ "source": "era5_edh",
146
+ "tile_lat": int(tile_lat),
147
+ "tile_lon": int(tile_lon),
148
+ }
149
+ )
150
+
151
+ chunks_time = min(int(edh_time_chunk), int(ds_out.sizes.get("time", edh_time_chunk)))
152
+ chunks_lat = min(int(tile_lat), int(ds_out.sizes.get("lat", tile_lat)))
153
+ chunks_lon = min(int(tile_lon), int(ds_out.sizes.get("lon", tile_lon)))
154
+ encoding = {
155
+ "wind": {"dtype": "float32", "chunks": (chunks_time, chunks_lat, chunks_lon)},
156
+ "lat": {"dtype": "float32"},
157
+ "lon": {"dtype": "float32"},
158
+ }
159
+
160
+ for var_name in ds_out.data_vars:
161
+ ds_out[var_name].encoding = {}
162
+ for coord_name in ds_out.coords:
163
+ ds_out.coords[coord_name].encoding = {}
164
+ ds_out.encoding = {}
165
+
166
+ ds_out.to_zarr(str(out_path), mode="w", compute=True, encoding=encoding)
167
+
168
+ if write_summary:
169
+ summary = {
170
+ "bbox": list(map(float, bbox)),
171
+ "start": start,
172
+ "end": end,
173
+ "out": str(out_path),
174
+ "tile_lat": int(tile_lat),
175
+ "tile_lon": int(tile_lon),
176
+ "min_valid": int(min_valid),
177
+ "height": height,
178
+ "written_vars": list(ds_out.data_vars.keys()),
179
+ }
180
+ summary_path = Path(str(out_path) + ".summary.json")
181
+ summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
182
+
183
+ return wind_da
184
+
185
+
186
+ def download_and_downscale(
187
+ *,
188
+ bbox: tuple[float, float, float, float],
189
+ start: str,
190
+ end: str,
191
+ out: str | Path,
192
+ mapping_method: str,
193
+ params_baseline_path: str | Path | None = None,
194
+ params_manipulator_path: str | Path | None = None,
195
+ spacing: float | None = None,
196
+ cache_dir: str | Path | None = None,
197
+ github_repository: str = DEFAULT_GITHUB_REPOSITORY,
198
+ release_tag: str = DEFAULT_WEIBULL_RELEASE_TAG,
199
+ github_token: str | None = None,
200
+ edh_key: str | None = None,
201
+ edh_path: str | None = None,
202
+ height: str = "10m",
203
+ u_var: str | None = None,
204
+ v_var: str | None = None,
205
+ bbox_order: str = "lonlat",
206
+ time_dim: str = "time",
207
+ overwrite: bool = False,
208
+ no_crop: bool = False,
209
+ edh_time_chunk: int = 168,
210
+ ) -> Path:
211
+ """Download ERA5 wind and run downscaling in one call."""
212
+ wind_da = download(
213
+ bbox=bbox,
214
+ start=start,
215
+ end=end,
216
+ out=None,
217
+ edh_key=edh_key,
218
+ edh_path=edh_path,
219
+ height=height,
220
+ u_var=u_var,
221
+ v_var=v_var,
222
+ bbox_order=bbox_order,
223
+ time_dim=time_dim,
224
+ edh_time_chunk=edh_time_chunk,
225
+ compute=True,
226
+ )
227
+ return downscale(
228
+ input_data=wind_da,
229
+ out=out,
230
+ bbox=bbox,
231
+ mapping_method=mapping_method,
232
+ params_baseline_path=params_baseline_path,
233
+ params_manipulator_path=params_manipulator_path,
234
+ spacing=spacing,
235
+ cache_dir=cache_dir,
236
+ github_repository=github_repository,
237
+ release_tag=release_tag,
238
+ github_token=github_token,
239
+ bbox_order=bbox_order,
240
+ no_crop=no_crop,
241
+ overwrite=overwrite,
242
+ )