agrs 0.1.3__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.
- agrs/__init__.py +4 -0
- agrs/aggregation.py +87 -0
- agrs/client.py +143 -0
- agrs/config.py +21 -0
- agrs/indices.py +85 -0
- agrs/selection.py +70 -0
- agrs/sources/planetary_computer_source.py +53 -0
- agrs/utils.py +65 -0
- agrs-0.1.3.dist-info/METADATA +91 -0
- agrs-0.1.3.dist-info/RECORD +13 -0
- agrs-0.1.3.dist-info/WHEEL +5 -0
- agrs-0.1.3.dist-info/licenses/LICENSE +21 -0
- agrs-0.1.3.dist-info/top_level.txt +1 -0
agrs/__init__.py
ADDED
agrs/aggregation.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from typing import Dict, List, Tuple
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from .config import DEFAULT_SUMMARY_STATS, DEFAULT_STAGE_BOUNDS
|
|
6
|
+
|
|
7
|
+
def summarize_array(arr: np.ndarray, stats: List[str]) -> Dict[str, float]:
|
|
8
|
+
arr_flat = arr[np.isfinite(arr)].ravel()
|
|
9
|
+
if arr_flat.size == 0:
|
|
10
|
+
return {s: np.nan for s in stats}
|
|
11
|
+
|
|
12
|
+
out = {}
|
|
13
|
+
if "mean" in stats:
|
|
14
|
+
out["mean"] = float(np.mean(arr_flat))
|
|
15
|
+
if "median" in stats:
|
|
16
|
+
out["median"] = float(np.median(arr_flat))
|
|
17
|
+
if "min" in stats:
|
|
18
|
+
out["min"] = float(np.min(arr_flat))
|
|
19
|
+
if "max" in stats:
|
|
20
|
+
out["max"] = float(np.max(arr_flat))
|
|
21
|
+
if "std" in stats:
|
|
22
|
+
out["std"] = float(np.std(arr_flat))
|
|
23
|
+
return out
|
|
24
|
+
|
|
25
|
+
def stage_for_fraction(
|
|
26
|
+
frac: float,
|
|
27
|
+
stage_bounds: Dict[str, Tuple[float, float]] = DEFAULT_STAGE_BOUNDS,
|
|
28
|
+
) -> str:
|
|
29
|
+
for name, (lo, hi) in stage_bounds.items():
|
|
30
|
+
if lo <= frac < hi:
|
|
31
|
+
return name
|
|
32
|
+
return "other"
|
|
33
|
+
|
|
34
|
+
def aggregate_field_indices(
|
|
35
|
+
field_id: str,
|
|
36
|
+
index_time_series: Dict[str, List[Dict]],
|
|
37
|
+
season_start,
|
|
38
|
+
season_end,
|
|
39
|
+
summary_stats: List[str] = None,
|
|
40
|
+
stage_bounds: Dict[str, Tuple[float, float]] = None,
|
|
41
|
+
) -> pd.Series:
|
|
42
|
+
"""
|
|
43
|
+
index_time_series: dict index_name -> list of dicts:
|
|
44
|
+
{"datetime": dt, "fraction": f, "array": arr_field}
|
|
45
|
+
Returns a pd.Series with columns like NDVI_early_mean, NDVI_mid_max, ...
|
|
46
|
+
"""
|
|
47
|
+
if summary_stats is None:
|
|
48
|
+
summary_stats = DEFAULT_SUMMARY_STATS
|
|
49
|
+
if stage_bounds is None:
|
|
50
|
+
stage_bounds = DEFAULT_STAGE_BOUNDS
|
|
51
|
+
|
|
52
|
+
data = {
|
|
53
|
+
"field_id": field_id,
|
|
54
|
+
"season_start": season_start,
|
|
55
|
+
"season_end": season_end,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for index_name, entries in index_time_series.items():
|
|
59
|
+
# group arrays by stage
|
|
60
|
+
stage_arrays = {stage: [] for stage in stage_bounds.keys()}
|
|
61
|
+
for e in entries:
|
|
62
|
+
f = e["fraction"]
|
|
63
|
+
arr = e["array"]
|
|
64
|
+
stage = stage_for_fraction(f, stage_bounds)
|
|
65
|
+
if stage in stage_arrays:
|
|
66
|
+
stage_arrays[stage].append(arr)
|
|
67
|
+
|
|
68
|
+
# compute stats per stage
|
|
69
|
+
for stage, arr_list in stage_arrays.items():
|
|
70
|
+
if not arr_list:
|
|
71
|
+
for stat in summary_stats:
|
|
72
|
+
col = f"{index_name}_{stage}_{stat}"
|
|
73
|
+
data[col] = float("nan")
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# combine all arrays in stage (stack, then aggregate)
|
|
77
|
+
stacked = np.stack(arr_list, axis=0)
|
|
78
|
+
# reduce over time and space (flatten)
|
|
79
|
+
# simple approach: average over time first, then stats over space
|
|
80
|
+
mean_over_time = np.nanmean(stacked, axis=0)
|
|
81
|
+
stats_dict = summarize_array(mean_over_time, summary_stats)
|
|
82
|
+
|
|
83
|
+
for stat, val in stats_dict.items():
|
|
84
|
+
col = f"{index_name}_{stage}_{stat}"
|
|
85
|
+
data[col] = val
|
|
86
|
+
|
|
87
|
+
return pd.Series(data)
|
agrs/client.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from typing import Optional, List, Dict, Any
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
from tqdm import tqdm
|
|
7
|
+
|
|
8
|
+
from .config import DEFAULT_INDICES
|
|
9
|
+
from .indices import compute_indices
|
|
10
|
+
from .aggregation import aggregate_field_indices
|
|
11
|
+
from .selection import (
|
|
12
|
+
select_snapshots_fractional,
|
|
13
|
+
select_snapshot_fixed_date,
|
|
14
|
+
select_top_n_cloudfree,
|
|
15
|
+
)
|
|
16
|
+
from .sources.planetary_computer_source import PlanetaryComputerS2Source
|
|
17
|
+
from .utils import gdf_to_bbox, clip_raster_to_geom
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class s2agc:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
source: str = "planetary_computer",
|
|
24
|
+
max_cloud: float = 0.3,
|
|
25
|
+
):
|
|
26
|
+
if source != "planetary_computer":
|
|
27
|
+
raise ValueError("Currently only 'planetary_computer' source is supported in MVP.")
|
|
28
|
+
self.source = PlanetaryComputerS2Source(max_cloud=max_cloud)
|
|
29
|
+
|
|
30
|
+
def get_features(
|
|
31
|
+
self,
|
|
32
|
+
fields: gpd.GeoDataFrame,
|
|
33
|
+
field_id_col: str,
|
|
34
|
+
start_date: str,
|
|
35
|
+
end_date: str,
|
|
36
|
+
crop: Optional[str] = None,
|
|
37
|
+
n_snapshots: int = 3,
|
|
38
|
+
snapshot_strategy: str = "fractional", # 'fractional', 'fixed_date', 'top_n_cloudfree'
|
|
39
|
+
fractions: Optional[List[float]] = None,
|
|
40
|
+
target_date: Optional[str] = None,
|
|
41
|
+
indices: Optional[List[str]] = None,
|
|
42
|
+
bands: Optional[List[str]] = None,
|
|
43
|
+
stac_limit: int = 100,
|
|
44
|
+
) -> pd.DataFrame:
|
|
45
|
+
"""
|
|
46
|
+
Main entry point: returns field-level RS features for given season.
|
|
47
|
+
"""
|
|
48
|
+
if indices is None:
|
|
49
|
+
indices = DEFAULT_INDICES
|
|
50
|
+
|
|
51
|
+
season_start = datetime.fromisoformat(start_date)
|
|
52
|
+
season_end = datetime.fromisoformat(end_date)
|
|
53
|
+
|
|
54
|
+
bbox = gdf_to_bbox(fields)
|
|
55
|
+
items = self.source.search_items(bbox, start_date, end_date, limit=stac_limit)
|
|
56
|
+
if not items:
|
|
57
|
+
raise RuntimeError("No Sentinel-2 items found for given area/date range.")
|
|
58
|
+
|
|
59
|
+
# Choose snapshots according to strategy
|
|
60
|
+
if snapshot_strategy == "fractional":
|
|
61
|
+
if fractions is None:
|
|
62
|
+
fractions = np.linspace(0.2, 1.0, n_snapshots).tolist()
|
|
63
|
+
chosen_items = select_snapshots_fractional(items, season_start, season_end, fractions)
|
|
64
|
+
elif snapshot_strategy == "fixed_date":
|
|
65
|
+
if target_date is None:
|
|
66
|
+
raise ValueError("target_date must be provided for 'fixed_date' strategy.")
|
|
67
|
+
dt = datetime.fromisoformat(target_date)
|
|
68
|
+
chosen_item = select_snapshot_fixed_date(items, dt)
|
|
69
|
+
chosen_items = [chosen_item] if chosen_item else []
|
|
70
|
+
elif snapshot_strategy == "top_n_cloudfree":
|
|
71
|
+
chosen_items = select_top_n_cloudfree(items, n_snapshots)
|
|
72
|
+
else:
|
|
73
|
+
raise ValueError(f"Unknown snapshot_strategy: {snapshot_strategy}")
|
|
74
|
+
|
|
75
|
+
if not chosen_items:
|
|
76
|
+
raise RuntimeError("No snapshots selected after applying strategy.")
|
|
77
|
+
|
|
78
|
+
# Determine bands to fetch: either user-defined or all required for indices
|
|
79
|
+
if bands is None:
|
|
80
|
+
# Collect all bands in assets whose keys start with 'B'
|
|
81
|
+
# (assumes items share same band set)
|
|
82
|
+
sample_assets = chosen_items[0]["assets"]
|
|
83
|
+
bands = sorted([k for k in sample_assets.keys() if k.startswith("B")])
|
|
84
|
+
|
|
85
|
+
rows = []
|
|
86
|
+
|
|
87
|
+
# Precompute time fractions for each chosen item
|
|
88
|
+
dur = (season_end - season_start).total_seconds()
|
|
89
|
+
item_meta = []
|
|
90
|
+
for it in chosen_items:
|
|
91
|
+
dt = datetime.fromisoformat(it["properties"]["datetime"].replace("Z", ""))
|
|
92
|
+
frac = (dt - season_start).total_seconds() / dur
|
|
93
|
+
frac = float(np.clip(frac, 0.0, 1.0))
|
|
94
|
+
item_meta.append((it, dt, frac))
|
|
95
|
+
|
|
96
|
+
for _, field in tqdm(fields.iterrows(), total=len(fields), desc="Fields"):
|
|
97
|
+
field_id = field[field_id_col]
|
|
98
|
+
geom = field.geometry
|
|
99
|
+
|
|
100
|
+
index_time_series: Dict[str, List[Dict[str, Any]]] = {idx: [] for idx in indices}
|
|
101
|
+
|
|
102
|
+
for it, dt, frac in item_meta:
|
|
103
|
+
# Map requested bands to asset hrefs
|
|
104
|
+
assets = it["assets"]
|
|
105
|
+
band_hrefs = {}
|
|
106
|
+
for b in bands:
|
|
107
|
+
if b in assets:
|
|
108
|
+
band_hrefs[b] = assets[b]["href"]
|
|
109
|
+
|
|
110
|
+
if not band_hrefs:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
# Clip raster bands to field geometry
|
|
114
|
+
band_arrays = clip_raster_to_geom(asset_href=None, geom=geom, bands=band_hrefs)
|
|
115
|
+
|
|
116
|
+
# If no overlap (empty dict), skip this snapshot for this field
|
|
117
|
+
if not band_arrays:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Compute indices
|
|
121
|
+
idx_arrays = compute_indices(band_arrays)
|
|
122
|
+
|
|
123
|
+
for idx_name in indices:
|
|
124
|
+
if idx_name not in idx_arrays:
|
|
125
|
+
continue
|
|
126
|
+
index_time_series[idx_name].append(
|
|
127
|
+
{
|
|
128
|
+
"datetime": dt,
|
|
129
|
+
"fraction": frac,
|
|
130
|
+
"array": idx_arrays[idx_name],
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Aggregate per field
|
|
135
|
+
series = aggregate_field_indices(
|
|
136
|
+
field_id=field_id,
|
|
137
|
+
index_time_series=index_time_series,
|
|
138
|
+
season_start=season_start,
|
|
139
|
+
season_end=season_end,
|
|
140
|
+
)
|
|
141
|
+
rows.append(series)
|
|
142
|
+
|
|
143
|
+
return pd.DataFrame(rows)
|
agrs/config.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
DEFAULT_INDICES = [
|
|
2
|
+
"NDVI",
|
|
3
|
+
"EVI",
|
|
4
|
+
"SAVI",
|
|
5
|
+
"NDWI",
|
|
6
|
+
"NDMI",
|
|
7
|
+
"GCI",
|
|
8
|
+
"NDRE",
|
|
9
|
+
"RECI",
|
|
10
|
+
"NBR",
|
|
11
|
+
"NBR2",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
DEFAULT_SUMMARY_STATS = ["mean", "median", "min", "max", "std"]
|
|
15
|
+
|
|
16
|
+
# Early, mid, late as fractions of season duration
|
|
17
|
+
DEFAULT_STAGE_BOUNDS = {
|
|
18
|
+
"early": (0.0, 0.33),
|
|
19
|
+
"mid": (0.33, 0.66),
|
|
20
|
+
"late": (0.66, 1.01),
|
|
21
|
+
}
|
agrs/indices.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
# Helper to safely divide
|
|
8
|
+
def _safe_div(numer, denom, eps=1e-6):
|
|
9
|
+
denom_safe = np.where(np.abs(denom) < eps, np.nan, denom)
|
|
10
|
+
return np.divide(numer, denom_safe)
|
|
11
|
+
|
|
12
|
+
def compute_indices(bands: Dict[str, np.ndarray]) -> Dict[str, np.ndarray]:
|
|
13
|
+
"""
|
|
14
|
+
Compute all supported indices from a dict of bands.
|
|
15
|
+
bands: mapping like {"B02": arr, "B03": arr, ...}
|
|
16
|
+
Returns dict of index_name -> array (same shape).
|
|
17
|
+
Skips indices if required bands missing; logs warning.
|
|
18
|
+
"""
|
|
19
|
+
idx = {}
|
|
20
|
+
|
|
21
|
+
def has(*needed):
|
|
22
|
+
miss = [b for b in needed if b not in bands]
|
|
23
|
+
if miss:
|
|
24
|
+
logger.warning("Missing bands %s for index, skipping.", miss)
|
|
25
|
+
return False
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
# NDVI: (NIR - RED) / (NIR + RED) = (B08 - B04) / (B08 + B04)
|
|
29
|
+
if has("B08", "B04"):
|
|
30
|
+
num = bands["B08"] - bands["B04"]
|
|
31
|
+
den = bands["B08"] + bands["B04"]
|
|
32
|
+
idx["NDVI"] = _safe_div(num, den)
|
|
33
|
+
|
|
34
|
+
# EVI: 2.5 * (NIR - RED) / (NIR + 6*RED - 7.5*BLUE + 1)
|
|
35
|
+
if has("B08", "B04", "B02"):
|
|
36
|
+
num = bands["B08"] - bands["B04"]
|
|
37
|
+
den = bands["B08"] + 6.0 * bands["B04"] - 7.5 * bands["B02"] + 1.0
|
|
38
|
+
idx["EVI"] = 2.5 * _safe_div(num, den)
|
|
39
|
+
|
|
40
|
+
# SAVI: (NIR - RED) / (NIR + RED + L) * (1 + L), L=0.5
|
|
41
|
+
if has("B08", "B04"):
|
|
42
|
+
L = 0.5
|
|
43
|
+
num = bands["B08"] - bands["B04"]
|
|
44
|
+
den = bands["B08"] + bands["B04"] + L
|
|
45
|
+
idx["SAVI"] = (1 + L) * _safe_div(num, den)
|
|
46
|
+
|
|
47
|
+
# NDWI (Gao): (NIR - SWIR) / (NIR + SWIR) = (B08 - B11) / (B08 + B11)
|
|
48
|
+
if has("B08", "B11"):
|
|
49
|
+
num = bands["B08"] - bands["B11"]
|
|
50
|
+
den = bands["B08"] + bands["B11"]
|
|
51
|
+
idx["NDWI"] = _safe_div(num, den)
|
|
52
|
+
|
|
53
|
+
# NDMI: (NIR - SWIR2) / (NIR + SWIR2) = (B08 - B12) / (B08 + B12)
|
|
54
|
+
if has("B08", "B12"):
|
|
55
|
+
num = bands["B08"] - bands["B12"]
|
|
56
|
+
den = bands["B08"] + bands["B12"]
|
|
57
|
+
idx["NDMI"] = _safe_div(num, den)
|
|
58
|
+
|
|
59
|
+
# GCI: (NIR / GREEN) - 1 = (B08 / B03) - 1
|
|
60
|
+
if has("B08", "B03"):
|
|
61
|
+
idx["GCI"] = _safe_div(bands["B08"], bands["B03"]) - 1.0
|
|
62
|
+
|
|
63
|
+
# NDRE: (NIR - RE1) / (NIR + RE1) = (B08 - B05) / (B08 + B05)
|
|
64
|
+
if has("B08", "B05"):
|
|
65
|
+
num = bands["B08"] - bands["B05"]
|
|
66
|
+
den = bands["B08"] + bands["B05"]
|
|
67
|
+
idx["NDRE"] = _safe_div(num, den)
|
|
68
|
+
|
|
69
|
+
# RECI: (NIR / RE1) - 1 = (B08 / B05) - 1
|
|
70
|
+
if has("B08", "B05"):
|
|
71
|
+
idx["RECI"] = _safe_div(bands["B08"], bands["B05"]) - 1.0
|
|
72
|
+
|
|
73
|
+
# NBR: (NIR - SWIR2) / (NIR + SWIR2) = (B08 - B12) / (B08 + B12)
|
|
74
|
+
if has("B08", "B12"):
|
|
75
|
+
num = bands["B08"] - bands["B12"]
|
|
76
|
+
den = bands["B08"] + bands["B12"]
|
|
77
|
+
idx["NBR"] = _safe_div(num, den)
|
|
78
|
+
|
|
79
|
+
# NBR2: (SWIR1 - SWIR2) / (SWIR1 + SWIR2) = (B11 - B12) / (B11 + B12)
|
|
80
|
+
if has("B11", "B12"):
|
|
81
|
+
num = bands["B11"] - bands["B12"]
|
|
82
|
+
den = bands["B11"] + bands["B12"]
|
|
83
|
+
idx["NBR2"] = _safe_div(num, den)
|
|
84
|
+
|
|
85
|
+
return idx
|
agrs/selection.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import List, Dict, Any, Optional
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
def select_snapshots_fractional(
|
|
5
|
+
items: List[Dict[str, Any]],
|
|
6
|
+
season_start: datetime,
|
|
7
|
+
season_end: datetime,
|
|
8
|
+
fractions: List[float],
|
|
9
|
+
) -> List[Dict[str, Any]]:
|
|
10
|
+
"""
|
|
11
|
+
Select items closest to given fractions of the season duration.
|
|
12
|
+
items: list of STAC items with 'properties'['datetime'].
|
|
13
|
+
"""
|
|
14
|
+
if not items:
|
|
15
|
+
return []
|
|
16
|
+
|
|
17
|
+
# Sort by time
|
|
18
|
+
items_sorted = sorted(
|
|
19
|
+
items,
|
|
20
|
+
key=lambda x: datetime.fromisoformat(x["properties"]["datetime"].replace("Z", "")),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
start = season_start
|
|
24
|
+
end = season_end
|
|
25
|
+
dur = (end - start).total_seconds()
|
|
26
|
+
out = []
|
|
27
|
+
|
|
28
|
+
for f in fractions:
|
|
29
|
+
target_t = start.timestamp() + f * dur
|
|
30
|
+
best = min(
|
|
31
|
+
items_sorted,
|
|
32
|
+
key=lambda it: abs(
|
|
33
|
+
datetime.fromisoformat(it["properties"]["datetime"].replace("Z", "")).timestamp() - target_t
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
if best not in out:
|
|
37
|
+
out.append(best)
|
|
38
|
+
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
def select_snapshot_fixed_date(
|
|
42
|
+
items: List[Dict[str, Any]],
|
|
43
|
+
target_date: datetime,
|
|
44
|
+
) -> Optional[Dict[str, Any]]:
|
|
45
|
+
"""Select single item closest to target_date."""
|
|
46
|
+
if not items:
|
|
47
|
+
return None
|
|
48
|
+
return min(
|
|
49
|
+
items,
|
|
50
|
+
key=lambda it: abs(
|
|
51
|
+
datetime.fromisoformat(it["properties"]["datetime"].replace("Z", "")) - target_date
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def select_top_n_cloudfree(
|
|
56
|
+
items: List[Dict[str, Any]],
|
|
57
|
+
n: int,
|
|
58
|
+
cloud_prop_name: str = "eo:cloud_cover",
|
|
59
|
+
) -> List[Dict[str, Any]]:
|
|
60
|
+
"""Select N items with lowest cloud cover."""
|
|
61
|
+
if not items or n <= 0:
|
|
62
|
+
return []
|
|
63
|
+
items_with_cloud = [
|
|
64
|
+
it for it in items if cloud_prop_name in it.get("properties", {})
|
|
65
|
+
]
|
|
66
|
+
items_sorted = sorted(
|
|
67
|
+
items_with_cloud,
|
|
68
|
+
key=lambda it: it["properties"].get(cloud_prop_name, 100.0),
|
|
69
|
+
)
|
|
70
|
+
return items_sorted[:n]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from typing import List, Dict, Any, Tuple
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
import planetary_computer as pc
|
|
5
|
+
from pystac_client import Client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlanetaryComputerS2Source:
|
|
9
|
+
"""
|
|
10
|
+
Minimal Sentinel-2 L2A source backed by Microsoft Planetary Computer.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
max_cloud : float
|
|
15
|
+
Maximum allowed cloud cover fraction in [0, 1]. This is converted to
|
|
16
|
+
percentage for the STAC query (eo:cloud_cover < max_cloud * 100).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, max_cloud: float = 0.3) -> None:
|
|
20
|
+
self.max_cloud = float(max_cloud)
|
|
21
|
+
self._client = Client.open(
|
|
22
|
+
"https://planetarycomputer.microsoft.com/api/stac/v1",
|
|
23
|
+
modifier=pc.sign_inplace,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def search_items(
|
|
27
|
+
self,
|
|
28
|
+
bbox: Tuple[float, float, float, float],
|
|
29
|
+
start_date: str,
|
|
30
|
+
end_date: str,
|
|
31
|
+
limit: int = 100,
|
|
32
|
+
) -> List[Dict[str, Any]]:
|
|
33
|
+
"""
|
|
34
|
+
Search Sentinel-2 L2A items in Planetary Computer.
|
|
35
|
+
|
|
36
|
+
Returns a list of pystac Items converted to plain dicts.
|
|
37
|
+
"""
|
|
38
|
+
datetime_range = f"{start_date}/{end_date}"
|
|
39
|
+
|
|
40
|
+
search = self._client.search(
|
|
41
|
+
collections=["sentinel-2-l2a"],
|
|
42
|
+
bbox=bbox,
|
|
43
|
+
datetime=datetime_range,
|
|
44
|
+
max_items=limit,
|
|
45
|
+
query={
|
|
46
|
+
"eo:cloud_cover": {
|
|
47
|
+
"lt": self.max_cloud * 100.0
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
items = list(search.get_items())
|
|
53
|
+
return [item.to_dict() for item in items]
|
agrs/utils.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from typing import Tuple, Dict
|
|
2
|
+
|
|
3
|
+
from shapely.geometry import mapping
|
|
4
|
+
import geopandas as gpd
|
|
5
|
+
import rasterio
|
|
6
|
+
from rasterio.mask import mask
|
|
7
|
+
from rasterio.warp import transform_geom
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def gdf_to_bbox(gdf: gpd.GeoDataFrame) -> Tuple[float, float, float, float]:
|
|
12
|
+
"""
|
|
13
|
+
Convert a GeoDataFrame to a bounding box tuple (minx, miny, maxx, maxy).
|
|
14
|
+
"""
|
|
15
|
+
bounds = gdf.total_bounds # minx, miny, maxx, maxy
|
|
16
|
+
return tuple(bounds.tolist())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def clip_raster_to_geom(
|
|
20
|
+
asset_href: str,
|
|
21
|
+
geom,
|
|
22
|
+
bands: Dict[str, str],
|
|
23
|
+
) -> Dict[str, np.ndarray]:
|
|
24
|
+
"""
|
|
25
|
+
Clip raster bands to a geometry.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
asset_href : str
|
|
30
|
+
Unused in this MVP; kept for potential future extension.
|
|
31
|
+
geom : shapely geometry
|
|
32
|
+
Geometry in EPSG:4326 (WGS84).
|
|
33
|
+
bands : dict
|
|
34
|
+
Mapping band_code -> href for each band.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
dict
|
|
39
|
+
Mapping band_code -> clipped numpy array (H, W).
|
|
40
|
+
If geometry does not overlap any band raster, returns {}.
|
|
41
|
+
"""
|
|
42
|
+
out: Dict[str, np.ndarray] = {}
|
|
43
|
+
|
|
44
|
+
for band_code, href in bands.items():
|
|
45
|
+
with rasterio.open(href) as src:
|
|
46
|
+
# Reproject geometry from EPSG:4326 to raster CRS if needed
|
|
47
|
+
geom_geojson = [mapping(geom)]
|
|
48
|
+
if src.crs is not None and src.crs.to_string() != "EPSG:4326":
|
|
49
|
+
geom_geojson = [
|
|
50
|
+
transform_geom("EPSG:4326", src.crs, g) for g in geom_geojson
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
arr, _ = mask(src, geom_geojson, crop=True)
|
|
55
|
+
except ValueError as e:
|
|
56
|
+
# Shapes do not overlap raster: skip this item entirely
|
|
57
|
+
if "do not overlap raster" in str(e):
|
|
58
|
+
return {}
|
|
59
|
+
else:
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
# arr shape: (1, H, W)
|
|
63
|
+
out[band_code] = arr[0].astype("float32")
|
|
64
|
+
|
|
65
|
+
return out
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agrs
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: AGRS – Agricultural Remote Sensing Library
|
|
5
|
+
Home-page: https://github.com/abdelghanibelgaid/agrs
|
|
6
|
+
Author: AGRS Developers
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: numpy
|
|
12
|
+
Requires-Dist: pandas
|
|
13
|
+
Requires-Dist: geopandas
|
|
14
|
+
Requires-Dist: shapely
|
|
15
|
+
Requires-Dist: rasterio
|
|
16
|
+
Requires-Dist: pystac-client
|
|
17
|
+
Requires-Dist: planetary-computer
|
|
18
|
+
Requires-Dist: xarray
|
|
19
|
+
Requires-Dist: tqdm
|
|
20
|
+
Dynamic: home-page
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
Dynamic: requires-python
|
|
23
|
+
|
|
24
|
+
# AGRS – Agricultural Remote Sensing Library
|
|
25
|
+
[](https://pypi.org/project/agrs/)
|
|
26
|
+
[](https://doi.org/10.5281/zenodo.17699363)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## Description
|
|
30
|
+
|
|
31
|
+
AGRS is a domain-focused Python library that turns **Sentinel-2 imagery** into **agronomy-ready features** for various agricultural modeling tasks, such as yield modeling, stress analysis, and NPK recommendation.
|
|
32
|
+
|
|
33
|
+
Instead of dealing with STAC queries, cloud masks, band math, and geometry clipping in every project, AGRS provides **opinionated, agriculture-centric pipelines**:
|
|
34
|
+
|
|
35
|
+
- Search & fetch Sentinel-2 scenes (via Microsoft Planetary Computer STAC).
|
|
36
|
+
- Compute standard indices (NDVI, EVI, SAVI, NDWI, NDMI, GCI, NDRE, RECI, NBR, NBR2).
|
|
37
|
+
- Select snapshots using agronomic strategies (fractions of season, specific dates, best cloud-free scenes).
|
|
38
|
+
- Aggregate to field-level features per index and **growth stage** (early/mid/late season) with statistics (mean, median, min, max, std and more).
|
|
39
|
+
- Return a tidy `DataFrame` ready to join with yield, NPK, and management tables.
|
|
40
|
+
|
|
41
|
+
The goal is to maximize value for agricultural ML workflows: **field trial analysis**, **site-specific NPK optimization**, and **crop monitoring**.
|
|
42
|
+
|
|
43
|
+
## Key features
|
|
44
|
+
|
|
45
|
+
- Sentinel-2 access via **Planetary Computer STAC API**.
|
|
46
|
+
- Automatic band selection (default: all available bands) or user-defined.
|
|
47
|
+
- Built-in index formulas:
|
|
48
|
+
- NDVI, EVI, SAVI, NDWI, NDMI, GCI, NDRE, RECI, NBR, NBR2
|
|
49
|
+
- Robust handling of missing bands and divide-by-zero (graceful skip + warnings).
|
|
50
|
+
- Snapshot selection strategies:
|
|
51
|
+
- `fractional` – e.g., snapshots near 30%, 60%, 90% of the season.
|
|
52
|
+
- `fixed_date` – snapshot closest to a given date.
|
|
53
|
+
- `top_n_cloudfree` – N lowest-cloud scenes in the period.
|
|
54
|
+
- Field-level aggregation:
|
|
55
|
+
- For each index and stage: `mean`, `median`, `min`, `max`, `std`.
|
|
56
|
+
- Early, mid, late stages defined as 0–33%, 33–66%, 66–100% of season duration.
|
|
57
|
+
|
|
58
|
+
## Quick example
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import geopandas as gpd
|
|
62
|
+
from shapely.geometry import Point
|
|
63
|
+
from agrs.client import s2agc
|
|
64
|
+
|
|
65
|
+
field_geom = Point(-8.0, 32.0)
|
|
66
|
+
|
|
67
|
+
fields = gpd.GeoDataFrame(
|
|
68
|
+
{
|
|
69
|
+
"field_id": [1],
|
|
70
|
+
"geometry": [field_geom],
|
|
71
|
+
},
|
|
72
|
+
crs="EPSG:4326",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
client = s2agc(
|
|
76
|
+
source="planetary_computer",
|
|
77
|
+
max_cloud=0.2,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
features_df = client.get_features(
|
|
81
|
+
fields=fields,
|
|
82
|
+
field_id_col="field_id",
|
|
83
|
+
start_date="2018-10-01",
|
|
84
|
+
end_date="2019-06-30",
|
|
85
|
+
crop="wheat",
|
|
86
|
+
n_snapshots=4,
|
|
87
|
+
snapshot_strategy="fractional",
|
|
88
|
+
fractions=[0.3, 0.6, 0.9, 1.0],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
print(features_df.head())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
agrs/__init__.py,sha256=jzb3ksFzdIbguvHOoNZNd6kceGGKN-IBcWCl53HzCE4,69
|
|
2
|
+
agrs/aggregation.py,sha256=PKfsumW6z_lBdoMvkDn9PQ1PxxNioORlj_sA_Y99QTQ,2887
|
|
3
|
+
agrs/client.py,sha256=04MvNcLdFvvVL_GVuSLCAUpyBApZV53umB-ZJaq2Rpg,5445
|
|
4
|
+
agrs/config.py,sha256=SgyzuXgPSPTKfouHDf3UZbO0ybJsfgZ1HsZx_5ZZNyQ,366
|
|
5
|
+
agrs/indices.py,sha256=AzDBSAq8XUDWnJizCDGuQrUDhWnoEx1ydFfo-Jvi8bM,2935
|
|
6
|
+
agrs/selection.py,sha256=rtwleMr24w5phmmg92alOeoSpVhWxO6Gm_dHMY8AeMA,1939
|
|
7
|
+
agrs/utils.py,sha256=1cKU0noVQqTVpNAIEDiyt6uZbibGhnfbugXDL4eL3WI,1902
|
|
8
|
+
agrs/sources/planetary_computer_source.py,sha256=z5ibFVjrz-Hrtj9z0aK4ua5rBh5ikKXQQ-YMKLbUoCI,1521
|
|
9
|
+
agrs-0.1.3.dist-info/licenses/LICENSE,sha256=svrXVwrP5tPtIcIWeivLVT36ndCaOoTTmGxgrM648WI,1075
|
|
10
|
+
agrs-0.1.3.dist-info/METADATA,sha256=Hvpf-bN-LDeAH2cZ6a-T_UI38KKscdzlcsJasqAKtmA,3207
|
|
11
|
+
agrs-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
+
agrs-0.1.3.dist-info/top_level.txt,sha256=eCWckqshAAsCBr73_QnDYujAuZfKSihBosvF2AXP6Ek,5
|
|
13
|
+
agrs-0.1.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Abdelghani Belgaid
|
|
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 @@
|
|
|
1
|
+
agrs
|