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 ADDED
@@ -0,0 +1,4 @@
1
+ from .client import s2agc
2
+
3
+ __all__ = ["s2agc"]
4
+ __version__ = "0.1.2"
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
+ [![PyPI](https://img.shields.io/pypi/v/agrs.svg?label=PyPI)](https://pypi.org/project/agrs/)
26
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.17699363.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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