wiend 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.
- wiend-0.1.0/LICENSE +21 -0
- wiend-0.1.0/PKG-INFO +148 -0
- wiend-0.1.0/README.md +127 -0
- wiend-0.1.0/pyproject.toml +41 -0
- wiend-0.1.0/setup.cfg +4 -0
- wiend-0.1.0/src/wiend/__init__.py +11 -0
- wiend-0.1.0/src/wiend/data_manager.py +267 -0
- wiend-0.1.0/src/wiend/download.py +242 -0
- wiend-0.1.0/src/wiend/downscale.py +515 -0
- wiend-0.1.0/src/wiend/utils.py +562 -0
- wiend-0.1.0/src/wiend/validation.py +92 -0
- wiend-0.1.0/src/wiend.egg-info/PKG-INFO +148 -0
- wiend-0.1.0/src/wiend.egg-info/SOURCES.txt +18 -0
- wiend-0.1.0/src/wiend.egg-info/dependency_links.txt +1 -0
- wiend-0.1.0/src/wiend.egg-info/requires.txt +9 -0
- wiend-0.1.0/src/wiend.egg-info/top_level.txt +1 -0
- wiend-0.1.0/tests/test_data_manager.py +175 -0
- wiend-0.1.0/tests/test_downscale.py +62 -0
- wiend-0.1.0/tests/test_import.py +29 -0
- wiend-0.1.0/tests/test_validation.py +131 -0
wiend-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 florianscheiber
|
|
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.
|
wiend-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wiend
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ERA5 wind download, downscaling and bias correction using Weibull parameter maps.
|
|
5
|
+
Author: Florian Scheiber
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/florianscheiber/wiend
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: dask>=2024.0
|
|
12
|
+
Requires-Dist: scipy>=1.11
|
|
13
|
+
Requires-Dist: xarray>=2022.0
|
|
14
|
+
Requires-Dist: numpy>=1.21
|
|
15
|
+
Requires-Dist: fsspec>=2023.0
|
|
16
|
+
Requires-Dist: aiohttp>=3.9
|
|
17
|
+
Requires-Dist: requests>=2.31
|
|
18
|
+
Requires-Dist: zarr>=2.10
|
|
19
|
+
Requires-Dist: tqdm>=4.50
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
wiend
|
|
23
|
+
|
|
24
|
+
Python library for downloading ERA5 wind speed time series from Earth Data Hub and applying Weibull-based downscaling and bias correction.
|
|
25
|
+
|
|
26
|
+
Features
|
|
27
|
+
Download ERA5 wind speed data from Earth Data Hub
|
|
28
|
+
Automatic spatial validation for Europe
|
|
29
|
+
Weibull-based downscaling
|
|
30
|
+
Weibull-based bias correction
|
|
31
|
+
Zarr output support
|
|
32
|
+
Xarray-native workflow
|
|
33
|
+
Installation
|
|
34
|
+
pip install wiend
|
|
35
|
+
|
|
36
|
+
Requirements
|
|
37
|
+
|
|
38
|
+
- Python 3.11 or newer
|
|
39
|
+
- EDH access token via `EDH_TOKEN` or `EDH_KEY`
|
|
40
|
+
- Internet access for the first Weibull data download
|
|
41
|
+
- The shell examples below use a POSIX-style terminal; Windows users can set environment variables with PowerShell or cmd equivalents
|
|
42
|
+
|
|
43
|
+
Quick start
|
|
44
|
+
|
|
45
|
+
1. Install the package:
|
|
46
|
+
```bash
|
|
47
|
+
pip install wiend
|
|
48
|
+
```
|
|
49
|
+
2. Set your EDH token:
|
|
50
|
+
```bash
|
|
51
|
+
export EDH_TOKEN="your-token"
|
|
52
|
+
```
|
|
53
|
+
3. Run the combined download + downscale workflow:
|
|
54
|
+
```python
|
|
55
|
+
import wiend as wd
|
|
56
|
+
|
|
57
|
+
out_path = wd.download_and_downscale(
|
|
58
|
+
bbox=[5, 45, 15, 55],
|
|
59
|
+
start="2020-01-01",
|
|
60
|
+
end="2020-12-31",
|
|
61
|
+
out="wind_downscaled.zarr",
|
|
62
|
+
mapping_method="qm",
|
|
63
|
+
spacing=0.0025,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
print(out_path)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Development note
|
|
70
|
+
|
|
71
|
+
The temporary-clone smoke test is a development-only check for package installation. End users should install `wiend` from PyPI and do not need a local clone.
|
|
72
|
+
|
|
73
|
+
Data
|
|
74
|
+
|
|
75
|
+
The package code is distributed through PyPI.
|
|
76
|
+
|
|
77
|
+
The Weibull parameter maps are distributed separately as GitHub Release assets and are downloaded automatically when required due to the file size of the corrected parameters (~950 MB).
|
|
78
|
+
|
|
79
|
+
GitHub Release tag: weibull-parameters-v1
|
|
80
|
+
|
|
81
|
+
Release assets:
|
|
82
|
+
|
|
83
|
+
- `weibull-parameters-baseline-v1.zarr.zip`
|
|
84
|
+
- `weibull-parameters-corrected-v1.zarr.zip`
|
|
85
|
+
|
|
86
|
+
Local cache location: ~/.wiend/
|
|
87
|
+
|
|
88
|
+
The library downloads the release assets, extracts the Zarr archives, and reuses the cached data on later runs.
|
|
89
|
+
|
|
90
|
+
Example
|
|
91
|
+
import wiend as wd
|
|
92
|
+
|
|
93
|
+
ds = wd.download(
|
|
94
|
+
bbox=[5, 45, 15, 55],
|
|
95
|
+
start="2020-01-01",
|
|
96
|
+
end="2020-12-31",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
out_path = wd.downscale(
|
|
100
|
+
input_data=ds,
|
|
101
|
+
out="wind_downscaled.zarr",
|
|
102
|
+
bbox=[5, 45, 15, 55],
|
|
103
|
+
mapping_method="qm",
|
|
104
|
+
spacing=0.0025,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
print(out_path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
Or:
|
|
111
|
+
|
|
112
|
+
import wiend as wd
|
|
113
|
+
|
|
114
|
+
out_path = wd.download_and_downscale(
|
|
115
|
+
bbox=[5, 45, 15, 55], # lon_min, lat_min, lon_max, lat_max
|
|
116
|
+
start="2020-01-01",
|
|
117
|
+
end="2020-12-31",
|
|
118
|
+
out="wind_downscaled.zarr",
|
|
119
|
+
mapping_method="qm",
|
|
120
|
+
spacing=0.0025,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
print(out_path)
|
|
124
|
+
|
|
125
|
+
Design Decisions
|
|
126
|
+
Python >= 3.11
|
|
127
|
+
MIT License
|
|
128
|
+
Distribution via PyPI
|
|
129
|
+
Weibull maps distributed via GitHub Releases
|
|
130
|
+
Europe-only support
|
|
131
|
+
Outputs:
|
|
132
|
+
xarray.Dataset
|
|
133
|
+
Zarr
|
|
134
|
+
Development
|
|
135
|
+
|
|
136
|
+
The project uses:
|
|
137
|
+
|
|
138
|
+
Pixi
|
|
139
|
+
Ruff
|
|
140
|
+
pre-commit
|
|
141
|
+
pytest
|
|
142
|
+
|
|
143
|
+
## Testing
|
|
144
|
+
|
|
145
|
+
Run tests with:
|
|
146
|
+
bash
|
|
147
|
+
pixi run pytest tests/ -v
|
|
148
|
+
All tests and linting run automatically on GitHub via CI when you push.
|
wiend-0.1.0/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
wiend
|
|
2
|
+
|
|
3
|
+
Python library for downloading ERA5 wind speed time series from Earth Data Hub and applying Weibull-based downscaling and bias correction.
|
|
4
|
+
|
|
5
|
+
Features
|
|
6
|
+
Download ERA5 wind speed data from Earth Data Hub
|
|
7
|
+
Automatic spatial validation for Europe
|
|
8
|
+
Weibull-based downscaling
|
|
9
|
+
Weibull-based bias correction
|
|
10
|
+
Zarr output support
|
|
11
|
+
Xarray-native workflow
|
|
12
|
+
Installation
|
|
13
|
+
pip install wiend
|
|
14
|
+
|
|
15
|
+
Requirements
|
|
16
|
+
|
|
17
|
+
- Python 3.11 or newer
|
|
18
|
+
- EDH access token via `EDH_TOKEN` or `EDH_KEY`
|
|
19
|
+
- Internet access for the first Weibull data download
|
|
20
|
+
- The shell examples below use a POSIX-style terminal; Windows users can set environment variables with PowerShell or cmd equivalents
|
|
21
|
+
|
|
22
|
+
Quick start
|
|
23
|
+
|
|
24
|
+
1. Install the package:
|
|
25
|
+
```bash
|
|
26
|
+
pip install wiend
|
|
27
|
+
```
|
|
28
|
+
2. Set your EDH token:
|
|
29
|
+
```bash
|
|
30
|
+
export EDH_TOKEN="your-token"
|
|
31
|
+
```
|
|
32
|
+
3. Run the combined download + downscale workflow:
|
|
33
|
+
```python
|
|
34
|
+
import wiend as wd
|
|
35
|
+
|
|
36
|
+
out_path = wd.download_and_downscale(
|
|
37
|
+
bbox=[5, 45, 15, 55],
|
|
38
|
+
start="2020-01-01",
|
|
39
|
+
end="2020-12-31",
|
|
40
|
+
out="wind_downscaled.zarr",
|
|
41
|
+
mapping_method="qm",
|
|
42
|
+
spacing=0.0025,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
print(out_path)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Development note
|
|
49
|
+
|
|
50
|
+
The temporary-clone smoke test is a development-only check for package installation. End users should install `wiend` from PyPI and do not need a local clone.
|
|
51
|
+
|
|
52
|
+
Data
|
|
53
|
+
|
|
54
|
+
The package code is distributed through PyPI.
|
|
55
|
+
|
|
56
|
+
The Weibull parameter maps are distributed separately as GitHub Release assets and are downloaded automatically when required due to the file size of the corrected parameters (~950 MB).
|
|
57
|
+
|
|
58
|
+
GitHub Release tag: weibull-parameters-v1
|
|
59
|
+
|
|
60
|
+
Release assets:
|
|
61
|
+
|
|
62
|
+
- `weibull-parameters-baseline-v1.zarr.zip`
|
|
63
|
+
- `weibull-parameters-corrected-v1.zarr.zip`
|
|
64
|
+
|
|
65
|
+
Local cache location: ~/.wiend/
|
|
66
|
+
|
|
67
|
+
The library downloads the release assets, extracts the Zarr archives, and reuses the cached data on later runs.
|
|
68
|
+
|
|
69
|
+
Example
|
|
70
|
+
import wiend as wd
|
|
71
|
+
|
|
72
|
+
ds = wd.download(
|
|
73
|
+
bbox=[5, 45, 15, 55],
|
|
74
|
+
start="2020-01-01",
|
|
75
|
+
end="2020-12-31",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
out_path = wd.downscale(
|
|
79
|
+
input_data=ds,
|
|
80
|
+
out="wind_downscaled.zarr",
|
|
81
|
+
bbox=[5, 45, 15, 55],
|
|
82
|
+
mapping_method="qm",
|
|
83
|
+
spacing=0.0025,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
print(out_path)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
Or:
|
|
90
|
+
|
|
91
|
+
import wiend as wd
|
|
92
|
+
|
|
93
|
+
out_path = wd.download_and_downscale(
|
|
94
|
+
bbox=[5, 45, 15, 55], # lon_min, lat_min, lon_max, lat_max
|
|
95
|
+
start="2020-01-01",
|
|
96
|
+
end="2020-12-31",
|
|
97
|
+
out="wind_downscaled.zarr",
|
|
98
|
+
mapping_method="qm",
|
|
99
|
+
spacing=0.0025,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
print(out_path)
|
|
103
|
+
|
|
104
|
+
Design Decisions
|
|
105
|
+
Python >= 3.11
|
|
106
|
+
MIT License
|
|
107
|
+
Distribution via PyPI
|
|
108
|
+
Weibull maps distributed via GitHub Releases
|
|
109
|
+
Europe-only support
|
|
110
|
+
Outputs:
|
|
111
|
+
xarray.Dataset
|
|
112
|
+
Zarr
|
|
113
|
+
Development
|
|
114
|
+
|
|
115
|
+
The project uses:
|
|
116
|
+
|
|
117
|
+
Pixi
|
|
118
|
+
Ruff
|
|
119
|
+
pre-commit
|
|
120
|
+
pytest
|
|
121
|
+
|
|
122
|
+
## Testing
|
|
123
|
+
|
|
124
|
+
Run tests with:
|
|
125
|
+
bash
|
|
126
|
+
pixi run pytest tests/ -v
|
|
127
|
+
All tests and linting run automatically on GitHub via CI when you push.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wiend"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [{name="Florian Scheiber"}]
|
|
9
|
+
description = "ERA5 wind download, downscaling and bias correction using Weibull parameter maps."
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"dask>=2024.0",
|
|
16
|
+
"scipy>=1.11",
|
|
17
|
+
"xarray>=2022.0",
|
|
18
|
+
"numpy>=1.21",
|
|
19
|
+
"fsspec>=2023.0",
|
|
20
|
+
"aiohttp>=3.9",
|
|
21
|
+
"requests>=2.31",
|
|
22
|
+
"zarr>=2.10",
|
|
23
|
+
"tqdm>=4.50",
|
|
24
|
+
]
|
|
25
|
+
urls = { "Repository" = "https://github.com/florianscheiber/wiend" }
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
package-dir = { "" = "src" }
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 100
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = [
|
|
38
|
+
"E",
|
|
39
|
+
"F",
|
|
40
|
+
"I",
|
|
41
|
+
]
|
wiend-0.1.0/setup.cfg
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
|