edmt 1.0.7.dev1__tar.gz → 1.0.7.post1__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.
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/PKG-INFO +4 -3
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/contrib/utils.py +5 -0
- edmt-1.0.7.post1/edmt/mapping/__init__.py +7 -0
- edmt-1.0.7.post1/edmt/mapping/carto.py +94 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/workflow/__init__.py +17 -5
- edmt-1.0.7.post1/edmt/workflow/analysis.py +493 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/workflow/builder.py +31 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/workflow/connector.py +9 -1
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/PKG-INFO +4 -3
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/SOURCES.txt +2 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/requires.txt +3 -2
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/pyproject.toml +4 -3
- edmt-1.0.7.dev1/edmt/mapping/__init__.py +0 -3
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/LICENSE +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/README.md +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/__init__.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/_edmt.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/analysis/__init__.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/base/__init__.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/base/base.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/contrib/__init__.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/conversion/__init__.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/conversion/conversion.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/models/__init__.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/models/drones.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/plotting/__init__.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/workflow/workflow.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/dependency_links.txt +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/entry_points.txt +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/top_level.txt +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/setup.cfg +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/tests/test_analysis.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/tests/test_base.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/tests/test_conversion.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/tests/test_mapping.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/tests/test_models.py +0 -0
- {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/tests/test_plotting.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: edmt
|
|
3
|
-
Version: 1.0.7.
|
|
3
|
+
Version: 1.0.7.post1
|
|
4
4
|
Summary: Environmental Data Management Toolbox
|
|
5
5
|
Author-email: "Odero, Kuloba & musasia" <francisodero10@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -47,12 +47,13 @@ Description-Content-Type: text/markdown
|
|
|
47
47
|
License-File: LICENSE
|
|
48
48
|
Requires-Dist: duckdb<1.3.2,>=0.9
|
|
49
49
|
Requires-Dist: earthengine-api<1.8,>=0.1.324
|
|
50
|
-
Requires-Dist: geopandas
|
|
51
|
-
Requires-Dist: pandas<3
|
|
50
|
+
Requires-Dist: geopandas<2,>=1.0
|
|
51
|
+
Requires-Dist: pandas<3,>=2.2
|
|
52
52
|
Requires-Dist: fiona<1.10.1,>=1.9.6
|
|
53
53
|
Requires-Dist: tqdm>=4
|
|
54
54
|
Requires-Dist: requests<3,>=2.28
|
|
55
55
|
Requires-Dist: matplotlib>=3.9
|
|
56
|
+
Requires-Dist: qrcode
|
|
56
57
|
Dynamic: license-file
|
|
57
58
|
|
|
58
59
|
<h1 align="center">EDMT — Environmental Data Management Toolbox</h1>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# qr_utils.py
|
|
2
|
+
|
|
3
|
+
import qrcode
|
|
4
|
+
from qrcode.constants import ERROR_CORRECT_H
|
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _load_font(name: str, size: int):
|
|
9
|
+
"""Safely load a TrueType font with a fallback."""
|
|
10
|
+
try:
|
|
11
|
+
return ImageFont.truetype(name, size)
|
|
12
|
+
except OSError:
|
|
13
|
+
return ImageFont.load_default()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_qr(
|
|
17
|
+
url: str,
|
|
18
|
+
label: str,
|
|
19
|
+
short_url: str | None = None,
|
|
20
|
+
size: int = 180,
|
|
21
|
+
foreground: str = "#1B3A2D",
|
|
22
|
+
background: str = "white",
|
|
23
|
+
muted: str = "#555555",
|
|
24
|
+
) -> Image.Image:
|
|
25
|
+
"""
|
|
26
|
+
Generate a QR code image with a label and optional short URL.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
url : str
|
|
31
|
+
URL to encode in the QR code.
|
|
32
|
+
label : str
|
|
33
|
+
Title displayed below the QR code.
|
|
34
|
+
short_url : str, optional
|
|
35
|
+
Shortened URL displayed below the label.
|
|
36
|
+
size : int, default=180
|
|
37
|
+
Width and height of the QR code in pixels.
|
|
38
|
+
foreground : str, default='#1B3A2D'
|
|
39
|
+
QR code and label color.
|
|
40
|
+
background : str, default='white'
|
|
41
|
+
Background color.
|
|
42
|
+
muted : str, default='#555555'
|
|
43
|
+
Color of the short URL text.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
PIL.Image.Image
|
|
48
|
+
QR code image with annotations.
|
|
49
|
+
"""
|
|
50
|
+
f_label = _load_font("DejaVuSans-Bold.ttf", 12)
|
|
51
|
+
f_url = _load_font("DejaVuSans.ttf", 10)
|
|
52
|
+
|
|
53
|
+
qr = qrcode.QRCode(
|
|
54
|
+
version=2,
|
|
55
|
+
error_correction=ERROR_CORRECT_H,
|
|
56
|
+
box_size=6,
|
|
57
|
+
border=2,
|
|
58
|
+
)
|
|
59
|
+
qr.add_data(url)
|
|
60
|
+
qr.make(fit=True)
|
|
61
|
+
|
|
62
|
+
qr_img = (
|
|
63
|
+
qr.make_image(fill_color=foreground, back_color=background)
|
|
64
|
+
.convert("RGB")
|
|
65
|
+
.resize((size, size), Image.LANCZOS)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
label_height = 42
|
|
69
|
+
canvas = Image.new(
|
|
70
|
+
"RGB",
|
|
71
|
+
(size, size + label_height),
|
|
72
|
+
background,
|
|
73
|
+
)
|
|
74
|
+
canvas.paste(qr_img, (0, 0))
|
|
75
|
+
|
|
76
|
+
draw = ImageDraw.Draw(canvas)
|
|
77
|
+
draw.text(
|
|
78
|
+
(size // 2, size + 4),
|
|
79
|
+
label,
|
|
80
|
+
font=f_label,
|
|
81
|
+
fill=foreground,
|
|
82
|
+
anchor="mt",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if short_url:
|
|
86
|
+
draw.text(
|
|
87
|
+
(size // 2, size + 20),
|
|
88
|
+
short_url,
|
|
89
|
+
font=f_url,
|
|
90
|
+
fill=muted,
|
|
91
|
+
anchor="mt",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return canvas
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
from .builder import (
|
|
2
|
+
gdf_to_ee_geometry
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
from .connector import ee_to_points
|
|
6
|
+
|
|
1
7
|
from .workflow import (
|
|
2
8
|
compute_evi_timeseries,
|
|
3
9
|
compute_lst_timeseries,
|
|
@@ -13,12 +19,12 @@ from .workflow import (
|
|
|
13
19
|
get_chirps_image_collection,
|
|
14
20
|
)
|
|
15
21
|
|
|
16
|
-
from .
|
|
17
|
-
|
|
22
|
+
from .analysis import (
|
|
23
|
+
create_ROI,
|
|
24
|
+
classify_ndvi_seasons,
|
|
25
|
+
classify_climate_seasons
|
|
18
26
|
)
|
|
19
27
|
|
|
20
|
-
from .connector import ee_to_points
|
|
21
|
-
|
|
22
28
|
_builder_functions = [
|
|
23
29
|
"gdf_to_ee_geometry",
|
|
24
30
|
]
|
|
@@ -39,8 +45,14 @@ _workflow_functions = [
|
|
|
39
45
|
"ee_to_points"
|
|
40
46
|
]
|
|
41
47
|
|
|
48
|
+
_analysis_functions = [
|
|
49
|
+
"create_ROI",
|
|
50
|
+
"classify_ndvi_seasons",
|
|
51
|
+
"classify_climate_seasons"
|
|
52
|
+
]
|
|
42
53
|
|
|
43
54
|
__all__ = [
|
|
44
55
|
_builder_functions,
|
|
45
|
-
_workflow_functions
|
|
56
|
+
_workflow_functions,
|
|
57
|
+
_analysis_functions
|
|
46
58
|
]
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import numpy as np
|
|
4
|
+
import geopandas as gpd
|
|
5
|
+
from shapely.geometry import box
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import numpy as np
|
|
9
|
+
from typing import Optional, Tuple, List
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_ROI(
|
|
14
|
+
latitude: float,
|
|
15
|
+
longitude: float,
|
|
16
|
+
extent_km: float = 100.0,
|
|
17
|
+
name: str = "AOI",
|
|
18
|
+
) -> gpd.GeoDataFrame:
|
|
19
|
+
"""
|
|
20
|
+
Create a square bounding box polygon centered on a WGS84 coordinate.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
latitude : float
|
|
25
|
+
Center latitude in decimal degrees (WGS84 / EPSG:4326).
|
|
26
|
+
longitude : float
|
|
27
|
+
Center longitude in decimal degrees (WGS84 / EPSG:4326).
|
|
28
|
+
extent_km : float, optional
|
|
29
|
+
Full side length of the bounding box in kilometres (default: 100).
|
|
30
|
+
e.g. 10 → 10 km × 10 km box centered on the coordinate.
|
|
31
|
+
name : str, optional
|
|
32
|
+
Label for the polygon feature (default: "AOI").
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
gpd.GeoDataFrame
|
|
37
|
+
Single-row GeoDataFrame (EPSG:4326) with columns:
|
|
38
|
+
name, latitude, longitude, extent_km, geometry.
|
|
39
|
+
|
|
40
|
+
Notes
|
|
41
|
+
-----
|
|
42
|
+
Degree-to-metre conversion uses the WGS84 approximation:
|
|
43
|
+
1° latitude ≈ 111 320 m (constant)
|
|
44
|
+
1° longitude ≈ 111 320 × cos(lat) m (varies with latitude)
|
|
45
|
+
"""
|
|
46
|
+
_KM_TO_M: float = 1_000.0
|
|
47
|
+
extent_m: float = extent_km * _KM_TO_M
|
|
48
|
+
|
|
49
|
+
_METRES_PER_DEGREE_LAT: float = 111_320.0
|
|
50
|
+
metres_per_degree_lon: float = _METRES_PER_DEGREE_LAT * math.cos(
|
|
51
|
+
math.radians(latitude)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
half_extent_m: float = extent_m / 2.0
|
|
55
|
+
delta_lat: float = half_extent_m / _METRES_PER_DEGREE_LAT
|
|
56
|
+
delta_lon: float = half_extent_m / metres_per_degree_lon
|
|
57
|
+
|
|
58
|
+
west: float = longitude - delta_lon
|
|
59
|
+
east: float = longitude + delta_lon
|
|
60
|
+
south: float = latitude - delta_lat
|
|
61
|
+
north: float = latitude + delta_lat
|
|
62
|
+
|
|
63
|
+
bbox_geom = box(west, south, east, north)
|
|
64
|
+
|
|
65
|
+
return gpd.GeoDataFrame(
|
|
66
|
+
{
|
|
67
|
+
"name": [name],
|
|
68
|
+
"latitude": [latitude],
|
|
69
|
+
"longitude": [longitude],
|
|
70
|
+
"extent_km": [extent_km],
|
|
71
|
+
"geometry": [bbox_geom],
|
|
72
|
+
},
|
|
73
|
+
crs="EPSG:4326",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
NDVI_WET_THRESHOLD: float = 0.5
|
|
79
|
+
|
|
80
|
+
def _otsu_threshold(values: np.ndarray) -> float:
|
|
81
|
+
"""
|
|
82
|
+
1-D Otsu's method: find the cut-point that maximises
|
|
83
|
+
between-class variance in `values`.
|
|
84
|
+
"""
|
|
85
|
+
sorted_vals = np.sort(values)
|
|
86
|
+
best_thresh = sorted_vals[0]
|
|
87
|
+
best_var = -np.inf
|
|
88
|
+
|
|
89
|
+
for t in sorted_vals[1:]:
|
|
90
|
+
below = values[values < t]
|
|
91
|
+
above = values[values >= t]
|
|
92
|
+
|
|
93
|
+
if len(below) == 0 or len(above) == 0:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
w0, w1 = len(below) / len(values), len(above) / len(values)
|
|
97
|
+
between_var = w0 * w1 * (below.mean() - above.mean()) ** 2
|
|
98
|
+
|
|
99
|
+
if between_var > best_var:
|
|
100
|
+
best_var = between_var
|
|
101
|
+
best_thresh = float(t)
|
|
102
|
+
|
|
103
|
+
return best_thresh
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _minmax_normalize_fixed(
|
|
107
|
+
series: pd.Series,
|
|
108
|
+
scale_min: float,
|
|
109
|
+
scale_max: float,
|
|
110
|
+
) -> pd.Series:
|
|
111
|
+
"""
|
|
112
|
+
Rescale a Series to [0, 1] using a *fixed* global range instead of
|
|
113
|
+
the data's own min/max. Values outside [scale_min, scale_max] are
|
|
114
|
+
clipped before normalisation.
|
|
115
|
+
|
|
116
|
+
Using fixed bounds prevents the pathological amplification that occurs
|
|
117
|
+
when all data values occupy a very narrow band (e.g. NDVI 0.094–0.101
|
|
118
|
+
treated as if it spans the full 0–1 range).
|
|
119
|
+
"""
|
|
120
|
+
clipped = series.clip(lower=scale_min, upper=scale_max)
|
|
121
|
+
return (clipped - scale_min) / (scale_max - scale_min)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _aggregate_to_monthly(
|
|
125
|
+
df: pd.DataFrame,
|
|
126
|
+
date_col: str,
|
|
127
|
+
value_col: str,
|
|
128
|
+
agg_func: str,
|
|
129
|
+
out_col: str,
|
|
130
|
+
) -> pd.DataFrame:
|
|
131
|
+
"""Parse dates, extract year/month, and aggregate value_col."""
|
|
132
|
+
work = df[[date_col, value_col]].copy()
|
|
133
|
+
work[date_col] = pd.to_datetime(work[date_col])
|
|
134
|
+
work["year"] = work[date_col].dt.year
|
|
135
|
+
work["month"] = work[date_col].dt.month
|
|
136
|
+
monthly = (
|
|
137
|
+
work.groupby(["year", "month"])[value_col]
|
|
138
|
+
.agg(agg_func)
|
|
139
|
+
.reset_index()
|
|
140
|
+
.rename(columns={value_col: out_col})
|
|
141
|
+
)
|
|
142
|
+
return monthly
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Compute ndvi Seasons (WET & DRY)
|
|
147
|
+
|
|
148
|
+
def classify_ndvi_seasons(
|
|
149
|
+
df: pd.DataFrame,
|
|
150
|
+
date_col: str = "date",
|
|
151
|
+
ndvi_col: str = "ndvi",
|
|
152
|
+
threshold: float = None,
|
|
153
|
+
threshold_method: str = "discrete",
|
|
154
|
+
wet_label: str = "Wet",
|
|
155
|
+
dry_label: str = "Dry",
|
|
156
|
+
agg_func: str = "mean",
|
|
157
|
+
) -> pd.DataFrame:
|
|
158
|
+
"""
|
|
159
|
+
Aggregate NDVI observations to monthly means and classify each
|
|
160
|
+
month as a wet or dry period based on a vegetation threshold.
|
|
161
|
+
|
|
162
|
+
Parameters
|
|
163
|
+
----------
|
|
164
|
+
df : pd.DataFrame
|
|
165
|
+
Input DataFrame containing at minimum a date and NDVI column.
|
|
166
|
+
date_col : str, optional
|
|
167
|
+
Name of the date column (default: "date").
|
|
168
|
+
ndvi_col : str, optional
|
|
169
|
+
Name of the NDVI column (default: "ndvi").
|
|
170
|
+
threshold : float, optional
|
|
171
|
+
Explicit NDVI cut-off on the true -1 to +1 scale.
|
|
172
|
+
Overrides `threshold_method` when supplied.
|
|
173
|
+
Months with mean NDVI >= threshold → wet; below → dry.
|
|
174
|
+
|
|
175
|
+
threshold_method : str, optional
|
|
176
|
+
Auto-threshold strategy used when `threshold` is None:
|
|
177
|
+
|
|
178
|
+
"discrete" – Ecological fixed threshold from the NDVI scale
|
|
179
|
+
(NDVI_WET_THRESHOLD = 0.25 by default).
|
|
180
|
+
**Recommended** — anchors the result to real-world
|
|
181
|
+
vegetation meaning regardless of data range. (default)
|
|
182
|
+
"otsu" – Maximises between-class variance within the data.
|
|
183
|
+
Only meaningful when the data spans a wide dynamic
|
|
184
|
+
range (e.g. > 0.15 spread). Avoid for narrow-band data.
|
|
185
|
+
"median" – Median of monthly means.
|
|
186
|
+
"mean" – Mean of monthly means.
|
|
187
|
+
|
|
188
|
+
wet_label : str, optional
|
|
189
|
+
Label for wet months (default: "Wet").
|
|
190
|
+
dry_label : str, optional
|
|
191
|
+
Label for dry months (default: "Dry").
|
|
192
|
+
agg_func : str, optional
|
|
193
|
+
Aggregation function applied per month:
|
|
194
|
+
"mean" | "median" | "max" (default: "mean").
|
|
195
|
+
|
|
196
|
+
Returns
|
|
197
|
+
-------
|
|
198
|
+
pd.DataFrame
|
|
199
|
+
Monthly DataFrame ordered chronologically with columns:
|
|
200
|
+
year, month, month_name, ndvi_mean, threshold, season,
|
|
201
|
+
ndvi_vegetation_class.
|
|
202
|
+
|
|
203
|
+
ndvi_vegetation_class → human-readable land-cover interpretation
|
|
204
|
+
derived from the true NDVI scale (independent of the wet/dry split).
|
|
205
|
+
|
|
206
|
+
Notes
|
|
207
|
+
-----
|
|
208
|
+
The "discrete" method (default) anchors classification to the actual
|
|
209
|
+
NDVI scale (-1 to +1). Statistical methods (otsu/median/mean) find a
|
|
210
|
+
threshold *within* the observed data range, which can produce misleading
|
|
211
|
+
results when all values are clustered in a narrow band — e.g. labelling
|
|
212
|
+
a month as "Wet" simply because its NDVI is 0.001 above the rest, even
|
|
213
|
+
though 0.10 is objectively bare soil by any vegetation index standard.
|
|
214
|
+
"""
|
|
215
|
+
if date_col not in df.columns:
|
|
216
|
+
raise KeyError(f"Date column '{date_col}' not found in DataFrame.")
|
|
217
|
+
if ndvi_col not in df.columns:
|
|
218
|
+
raise KeyError(f"NDVI column '{ndvi_col}' not found in DataFrame.")
|
|
219
|
+
|
|
220
|
+
_VALID_AGG = {"mean", "median", "max"}
|
|
221
|
+
if agg_func not in _VALID_AGG:
|
|
222
|
+
raise ValueError(f"agg_func must be one of {_VALID_AGG}, got '{agg_func}'.")
|
|
223
|
+
|
|
224
|
+
_VALID_METHODS = {"discrete", "otsu", "median", "mean"}
|
|
225
|
+
if threshold_method not in _VALID_METHODS:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"threshold_method must be one of {_VALID_METHODS}, "
|
|
228
|
+
f"got '{threshold_method}'."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
work = df[[date_col, ndvi_col]].copy()
|
|
232
|
+
work[date_col] = pd.to_datetime(work[date_col])
|
|
233
|
+
work["year"] = work[date_col].dt.year
|
|
234
|
+
work["month"] = work[date_col].dt.month
|
|
235
|
+
|
|
236
|
+
monthly: pd.DataFrame = (
|
|
237
|
+
work.groupby(["year", "month"])[ndvi_col]
|
|
238
|
+
.agg(agg_func)
|
|
239
|
+
.reset_index()
|
|
240
|
+
.rename(columns={ndvi_col: "ndvi_mean"})
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
monthly["_period"] = pd.to_datetime(monthly[["year", "month"]].assign(day=1))
|
|
244
|
+
monthly = monthly.sort_values("_period").reset_index(drop=True)
|
|
245
|
+
monthly = monthly.drop(columns="_period")
|
|
246
|
+
|
|
247
|
+
monthly.insert(
|
|
248
|
+
2,
|
|
249
|
+
"month_name",
|
|
250
|
+
pd.to_datetime(monthly["month"], format="%m").dt.strftime("%B"),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if threshold is not None:
|
|
254
|
+
resolved_threshold = float(threshold)
|
|
255
|
+
elif threshold_method == "discrete":
|
|
256
|
+
resolved_threshold = NDVI_WET_THRESHOLD
|
|
257
|
+
elif threshold_method == "otsu":
|
|
258
|
+
resolved_threshold = _otsu_threshold(monthly["ndvi_mean"].to_numpy())
|
|
259
|
+
elif threshold_method == "median":
|
|
260
|
+
resolved_threshold = float(np.median(monthly["ndvi_mean"].to_numpy()))
|
|
261
|
+
else: # mean
|
|
262
|
+
resolved_threshold = float(np.mean(monthly["ndvi_mean"].to_numpy()))
|
|
263
|
+
|
|
264
|
+
# monthly["threshold"] = round(resolved_threshold, 6)
|
|
265
|
+
monthly["season"] = np.where(
|
|
266
|
+
monthly["ndvi_mean"] >= resolved_threshold, wet_label, dry_label
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
monthly["ndvi_class"] = pd.cut(
|
|
270
|
+
monthly["ndvi_mean"],
|
|
271
|
+
bins=[-1.0, 0.0, 0.10, 0.20, 0.35, 0.50, 0.70, 1.0],
|
|
272
|
+
labels=[
|
|
273
|
+
"Water / Non-vegetated",
|
|
274
|
+
"Bare Soil / Arid",
|
|
275
|
+
"Very Sparse Vegetation",
|
|
276
|
+
"Sparse Grassland / Shrubland",
|
|
277
|
+
"Moderate Vegetation",
|
|
278
|
+
"Dense Vegetation",
|
|
279
|
+
"Very Dense / Forest",
|
|
280
|
+
],
|
|
281
|
+
include_lowest=True,
|
|
282
|
+
).astype(str)
|
|
283
|
+
|
|
284
|
+
return monthly
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
_DEFAULT_SEASON_LABELS: List[str] = [
|
|
289
|
+
"Dry Season",
|
|
290
|
+
"Dry-Wet Transition",
|
|
291
|
+
"Rainfall Onset",
|
|
292
|
+
"Wet Season",
|
|
293
|
+
"Rainy Season",
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
_WET_LABELS = {"Rainy Season", "Wet Season", "Rainfall Onset"}
|
|
297
|
+
_TRANSITION_LABEL = "Dry-Wet Transition"
|
|
298
|
+
_DRY_LABEL = "Dry Season"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _minmax_normalize(series: pd.Series) -> pd.Series:
|
|
302
|
+
"""Rescale a Series to [0, 1]. Returns 0.5 if all values are identical."""
|
|
303
|
+
s_min, s_max = series.min(), series.max()
|
|
304
|
+
if s_max == s_min:
|
|
305
|
+
return pd.Series(0.5, index=series.index)
|
|
306
|
+
return (series - s_min) / (s_max - s_min)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _aggregate_to_monthly(
|
|
310
|
+
df: pd.DataFrame,
|
|
311
|
+
date_col: str,
|
|
312
|
+
value_col: str,
|
|
313
|
+
agg_func: str,
|
|
314
|
+
out_col: str,
|
|
315
|
+
) -> pd.DataFrame:
|
|
316
|
+
"""Parse dates, extract year/month, and aggregate value_col."""
|
|
317
|
+
work = df[[date_col, value_col]].copy()
|
|
318
|
+
work[date_col] = pd.to_datetime(work[date_col])
|
|
319
|
+
work["year"] = work[date_col].dt.year
|
|
320
|
+
work["month"] = work[date_col].dt.month
|
|
321
|
+
monthly = (
|
|
322
|
+
work.groupby(["year", "month"])[value_col]
|
|
323
|
+
.agg(agg_func)
|
|
324
|
+
.reset_index()
|
|
325
|
+
.rename(columns={value_col: out_col})
|
|
326
|
+
)
|
|
327
|
+
return monthly
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# Compute classification of climate seasons
|
|
331
|
+
|
|
332
|
+
def classify_climate_seasons(
|
|
333
|
+
df_ndvi: pd.DataFrame,
|
|
334
|
+
df_rainfall: pd.DataFrame,
|
|
335
|
+
df_lst: pd.DataFrame,
|
|
336
|
+
ndvi_date_col: str = "date",
|
|
337
|
+
ndvi_col: str = "ndvi",
|
|
338
|
+
rainfall_date_col: str = "date",
|
|
339
|
+
rainfall_col: str = "precipitation_mm",
|
|
340
|
+
lst_date_col: str = "date",
|
|
341
|
+
lst_col: str = "mean",
|
|
342
|
+
weights: Tuple[float, float, float] = (0.40, 0.35, 0.25),
|
|
343
|
+
category_labels: Optional[List[str]] = None,
|
|
344
|
+
rainfall_gate_mm: float = 1.0,
|
|
345
|
+
transition_gate_mm: float = 15.0,
|
|
346
|
+
) -> pd.DataFrame:
|
|
347
|
+
"""
|
|
348
|
+
Merge monthly NDVI, rainfall, and LST data, then classify each month
|
|
349
|
+
into one of five climate seasons using a normalised composite score.
|
|
350
|
+
|
|
351
|
+
Composite score (0 = driest, 1 = wettest)
|
|
352
|
+
------------------------------------------
|
|
353
|
+
score = w_rain × rainfall_norm
|
|
354
|
+
+ w_ndvi × ndvi_norm
|
|
355
|
+
+ w_temp × (1 − temp_norm) ← inverted: high temp → dry
|
|
356
|
+
|
|
357
|
+
Rainfall gate (applied after scoring)
|
|
358
|
+
--------------------------------------
|
|
359
|
+
Prevents non-dry labels when rainfall is negligible, regardless of
|
|
360
|
+
what NDVI or LST suggest:
|
|
361
|
+
|
|
362
|
+
rainfall_mm < rainfall_gate_mm → forced "Dry Season"
|
|
363
|
+
rainfall_mm < transition_gate_mm → capped at "Dry-Wet Transition"
|
|
364
|
+
(only if score-based label is wetter)
|
|
365
|
+
|
|
366
|
+
Parameters
|
|
367
|
+
----------
|
|
368
|
+
df_ndvi : pd.DataFrame
|
|
369
|
+
16-day or finer NDVI observations (aggregated to monthly mean).
|
|
370
|
+
df_rainfall : pd.DataFrame
|
|
371
|
+
Weekly or finer precipitation observations (aggregated to monthly sum).
|
|
372
|
+
df_lst : pd.DataFrame
|
|
373
|
+
Monthly or finer LST observations (aggregated to monthly mean).
|
|
374
|
+
ndvi_date_col : str
|
|
375
|
+
Date column in df_ndvi (default: "date").
|
|
376
|
+
ndvi_col : str
|
|
377
|
+
NDVI value column (default: "ndvi").
|
|
378
|
+
rainfall_date_col : str
|
|
379
|
+
Date column in df_rainfall (default: "date").
|
|
380
|
+
rainfall_col : str
|
|
381
|
+
Precipitation column (default: "precipitation_mm").
|
|
382
|
+
lst_date_col : str
|
|
383
|
+
Date column in df_lst (default: "date").
|
|
384
|
+
lst_col : str
|
|
385
|
+
LST value column (default: "mean").
|
|
386
|
+
weights : tuple of 3 floats
|
|
387
|
+
Relative importance of (rainfall, ndvi, temperature). Must sum to 1.0
|
|
388
|
+
(default: 0.40, 0.35, 0.25).
|
|
389
|
+
category_labels : list of 5 str, optional
|
|
390
|
+
Custom season names ordered driest → wettest.
|
|
391
|
+
rainfall_gate_mm : float, optional
|
|
392
|
+
Monthly rainfall (mm) below which a month is forced to "Dry Season",
|
|
393
|
+
regardless of NDVI or LST (default: 1.0 mm).
|
|
394
|
+
transition_gate_mm : float, optional
|
|
395
|
+
Monthly rainfall (mm) below which a month is capped at
|
|
396
|
+
"Dry-Wet Transition" if the score would place it in a wetter
|
|
397
|
+
category (default: 5.0 mm).
|
|
398
|
+
|
|
399
|
+
Returns
|
|
400
|
+
-------
|
|
401
|
+
pd.DataFrame
|
|
402
|
+
Chronologically sorted monthly DataFrame with columns:
|
|
403
|
+
year, month, month_name, rainfall_mm, ndvi_mean, lst_mean,
|
|
404
|
+
composite_score, season, season_source.
|
|
405
|
+
|
|
406
|
+
season_source: "score" if the label came from the composite score,
|
|
407
|
+
"rainfall_gate" if it was overridden.
|
|
408
|
+
"""
|
|
409
|
+
if rainfall_gate_mm > transition_gate_mm:
|
|
410
|
+
raise ValueError(
|
|
411
|
+
f"rainfall_gate_mm ({rainfall_gate_mm}) must be ≤ "
|
|
412
|
+
f"transition_gate_mm ({transition_gate_mm})."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
w_rain, w_ndvi, w_temp = weights
|
|
416
|
+
if not abs(sum(weights) - 1.0) < 1e-6:
|
|
417
|
+
raise ValueError(
|
|
418
|
+
f"weights must sum to 1.0, got {sum(weights):.4f}. "
|
|
419
|
+
f"Received: rainfall={w_rain}, ndvi={w_ndvi}, temperature={w_temp}."
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
labels = category_labels or _DEFAULT_SEASON_LABELS
|
|
423
|
+
if len(labels) != 5:
|
|
424
|
+
raise ValueError(
|
|
425
|
+
f"category_labels must contain exactly 5 labels, got {len(labels)}."
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
dry_label = labels[0]
|
|
429
|
+
transition_label = labels[1]
|
|
430
|
+
wet_labels = set(labels[2:])
|
|
431
|
+
|
|
432
|
+
monthly_rainfall = _aggregate_to_monthly(
|
|
433
|
+
df_rainfall, rainfall_date_col, rainfall_col, "sum", "rainfall_mm"
|
|
434
|
+
)
|
|
435
|
+
monthly_ndvi = _aggregate_to_monthly(
|
|
436
|
+
df_ndvi, ndvi_date_col, ndvi_col, "mean", "ndvi_mean"
|
|
437
|
+
)
|
|
438
|
+
monthly_lst = _aggregate_to_monthly(
|
|
439
|
+
df_lst, lst_date_col, lst_col, "mean", "lst_mean"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
merged = (
|
|
443
|
+
monthly_rainfall
|
|
444
|
+
.merge(monthly_ndvi, on=["year", "month"], how="inner")
|
|
445
|
+
.merge(monthly_lst, on=["year", "month"], how="inner")
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
merged["_period"] = pd.to_datetime(merged[["year", "month"]].assign(day=1))
|
|
449
|
+
merged = merged.sort_values("_period").reset_index(drop=True)
|
|
450
|
+
merged.insert(2, "month_name", merged["_period"].dt.strftime("%B"))
|
|
451
|
+
merged = merged.drop(columns="_period")
|
|
452
|
+
|
|
453
|
+
merged["_rain_norm"] = _minmax_normalize(merged["rainfall_mm"])
|
|
454
|
+
merged["_ndvi_norm"] = _minmax_normalize(merged["ndvi_mean"])
|
|
455
|
+
merged["_temp_norm"] = _minmax_normalize(merged["lst_mean"])
|
|
456
|
+
|
|
457
|
+
merged["composite_score"] = (
|
|
458
|
+
w_rain * merged["_rain_norm"]
|
|
459
|
+
+ w_ndvi * merged["_ndvi_norm"]
|
|
460
|
+
+ w_temp * (1 - merged["_temp_norm"])
|
|
461
|
+
).round(4)
|
|
462
|
+
|
|
463
|
+
merged = merged.drop(columns=["_rain_norm", "_ndvi_norm", "_temp_norm"])
|
|
464
|
+
|
|
465
|
+
merged["season"] = pd.cut(
|
|
466
|
+
merged["composite_score"],
|
|
467
|
+
bins=5,
|
|
468
|
+
labels=labels,
|
|
469
|
+
include_lowest=True,
|
|
470
|
+
).astype(str)
|
|
471
|
+
|
|
472
|
+
merged["season_source"] = "score"
|
|
473
|
+
|
|
474
|
+
transition_mask = (
|
|
475
|
+
(merged["rainfall_mm"] < transition_gate_mm) &
|
|
476
|
+
(merged["rainfall_mm"] >= rainfall_gate_mm) &
|
|
477
|
+
(merged["season"].isin(wet_labels))
|
|
478
|
+
)
|
|
479
|
+
merged.loc[transition_mask, "season"] = transition_label
|
|
480
|
+
merged.loc[transition_mask, "season_source"] = "rainfall_gate"
|
|
481
|
+
|
|
482
|
+
dry_mask = merged["rainfall_mm"] < rainfall_gate_mm
|
|
483
|
+
merged.loc[dry_mask, "season"] = dry_label
|
|
484
|
+
merged.loc[dry_mask, "season_source"] = "rainfall_gate"
|
|
485
|
+
|
|
486
|
+
return merged[[
|
|
487
|
+
"year", "month", "month_name",
|
|
488
|
+
"rainfall_mm", "ndvi_mean", "lst_mean",
|
|
489
|
+
"composite_score", "season"
|
|
490
|
+
]]
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
|
|
@@ -123,6 +123,7 @@ _PRODUCT_REGISTRY = {
|
|
|
123
123
|
"NDVI": "vegetation",
|
|
124
124
|
"EVI": "vegetation",
|
|
125
125
|
"CHIRPS": "chirps",
|
|
126
|
+
"FLOODING": "flooding",
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
|
|
@@ -182,6 +183,14 @@ _SAT_CONFIG = {
|
|
|
182
183
|
"scale_m": 250,
|
|
183
184
|
"direct": True,
|
|
184
185
|
},
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
"FLOODING": {
|
|
189
|
+
"SENTINEL1": {
|
|
190
|
+
"collection": "COPERNICUS/S1_GRD",
|
|
191
|
+
"band": "VH",
|
|
192
|
+
"scale_m": 10,
|
|
193
|
+
}
|
|
185
194
|
}
|
|
186
195
|
}
|
|
187
196
|
|
|
@@ -373,6 +382,27 @@ def _build_chirps(start_date, end_date):
|
|
|
373
382
|
return ic, {"bands": ["precipitation"], "scale_m": 5500}
|
|
374
383
|
|
|
375
384
|
|
|
385
|
+
# Flooding pipeline
|
|
386
|
+
|
|
387
|
+
def _build_flooding(satellite, start_date, end_date):
|
|
388
|
+
sat = _norm_sat(satellite)
|
|
389
|
+
|
|
390
|
+
if sat != "SENTINEL1":
|
|
391
|
+
raise ValueError(f"Unsupported flooding satellite: {satellite}")
|
|
392
|
+
|
|
393
|
+
ic = (
|
|
394
|
+
ee.ImageCollection(_SAT_CONFIG["FLOODING"]["SENTINEL1"]["collection"])
|
|
395
|
+
.filterDate(start_date, end_date)
|
|
396
|
+
.filter(ee.Filter.listContains("transmitterReceiverPolarisation", "VH"))
|
|
397
|
+
.select(["VH"])
|
|
398
|
+
.map(lambda img: img.rename("VH").copyProperties(img, ["system:time_start"]))
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
return ic, {
|
|
402
|
+
"bands": ["VH"],
|
|
403
|
+
"scale_m": _SAT_CONFIG["FLOODING"]["SENTINEL1"]["scale_m"],
|
|
404
|
+
"satellite": sat,
|
|
405
|
+
}
|
|
376
406
|
|
|
377
407
|
# 3 : COMPUTATION
|
|
378
408
|
|
|
@@ -456,6 +486,7 @@ _COMPUTE_REGISTRY = {
|
|
|
456
486
|
"NDVI": _compute_veg,
|
|
457
487
|
"EVI": _compute_veg,
|
|
458
488
|
"LST": _compute_lst,
|
|
489
|
+
"FLOOD":_build_flooding
|
|
459
490
|
}
|
|
460
491
|
|
|
461
492
|
|
|
@@ -11,6 +11,7 @@ from .builder import (
|
|
|
11
11
|
|
|
12
12
|
_PRODUCT_REGISTRY,
|
|
13
13
|
_build_vegetation,
|
|
14
|
+
_build_flooding,
|
|
14
15
|
|
|
15
16
|
_norm_sat,
|
|
16
17
|
_build_chirps,
|
|
@@ -92,6 +93,9 @@ def get_satellite_collection(
|
|
|
92
93
|
elif pipeline == "lst":
|
|
93
94
|
ic, meta = _build_lst(satellite, start_date, end_date)
|
|
94
95
|
|
|
96
|
+
elif pipeline == "flooding":
|
|
97
|
+
ic, meta = _build_flooding(satellite, start_date, end_date)
|
|
98
|
+
|
|
95
99
|
elif pipeline == "chirps":
|
|
96
100
|
ic, meta = _build_chirps(start_date, end_date)
|
|
97
101
|
|
|
@@ -200,7 +204,7 @@ def ComputeTimeseries(
|
|
|
200
204
|
roi_gdf: gpd.GeoDataFrame,
|
|
201
205
|
satellite: Optional[str] = None,
|
|
202
206
|
scale: Optional[int] = None,
|
|
203
|
-
) -> pd.DataFrame:
|
|
207
|
+
) -> pd.DataFrame:
|
|
204
208
|
"""
|
|
205
209
|
Generate a time series of environmental metrics (e.g., NDVI, LST, precipitation) over a region of interest.
|
|
206
210
|
|
|
@@ -316,6 +320,10 @@ def ComputeTimeseries(
|
|
|
316
320
|
if "precipitation_mm" in df.columns:
|
|
317
321
|
df = df[df["precipitation_mm"].notna()]
|
|
318
322
|
|
|
323
|
+
# elif prod == "CHIRPS":
|
|
324
|
+
# if "precipitation_mm" in df.columns:
|
|
325
|
+
# df = df[df["precipitation_mm"].notna()]
|
|
326
|
+
|
|
319
327
|
elif prod in ("NDVI", "EVI"):
|
|
320
328
|
key = prod.lower()
|
|
321
329
|
if key in df.columns:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: edmt
|
|
3
|
-
Version: 1.0.7.
|
|
3
|
+
Version: 1.0.7.post1
|
|
4
4
|
Summary: Environmental Data Management Toolbox
|
|
5
5
|
Author-email: "Odero, Kuloba & musasia" <francisodero10@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -47,12 +47,13 @@ Description-Content-Type: text/markdown
|
|
|
47
47
|
License-File: LICENSE
|
|
48
48
|
Requires-Dist: duckdb<1.3.2,>=0.9
|
|
49
49
|
Requires-Dist: earthengine-api<1.8,>=0.1.324
|
|
50
|
-
Requires-Dist: geopandas
|
|
51
|
-
Requires-Dist: pandas<3
|
|
50
|
+
Requires-Dist: geopandas<2,>=1.0
|
|
51
|
+
Requires-Dist: pandas<3,>=2.2
|
|
52
52
|
Requires-Dist: fiona<1.10.1,>=1.9.6
|
|
53
53
|
Requires-Dist: tqdm>=4
|
|
54
54
|
Requires-Dist: requests<3,>=2.28
|
|
55
55
|
Requires-Dist: matplotlib>=3.9
|
|
56
|
+
Requires-Dist: qrcode
|
|
56
57
|
Dynamic: license-file
|
|
57
58
|
|
|
58
59
|
<h1 align="center">EDMT — Environmental Data Management Toolbox</h1>
|
|
@@ -17,10 +17,12 @@ edmt/contrib/utils.py
|
|
|
17
17
|
edmt/conversion/__init__.py
|
|
18
18
|
edmt/conversion/conversion.py
|
|
19
19
|
edmt/mapping/__init__.py
|
|
20
|
+
edmt/mapping/carto.py
|
|
20
21
|
edmt/models/__init__.py
|
|
21
22
|
edmt/models/drones.py
|
|
22
23
|
edmt/plotting/__init__.py
|
|
23
24
|
edmt/workflow/__init__.py
|
|
25
|
+
edmt/workflow/analysis.py
|
|
24
26
|
edmt/workflow/builder.py
|
|
25
27
|
edmt/workflow/connector.py
|
|
26
28
|
edmt/workflow/workflow.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "edmt"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.7post1"
|
|
8
8
|
description = "Environmental Data Management Toolbox"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -47,12 +47,13 @@ keywords = [
|
|
|
47
47
|
dependencies = [
|
|
48
48
|
"duckdb>=0.9,<1.3.2",
|
|
49
49
|
"earthengine-api>=0.1.324,<1.8",
|
|
50
|
-
"geopandas>=1",
|
|
51
|
-
"pandas
|
|
50
|
+
"geopandas>=1.0,<2",
|
|
51
|
+
"pandas>=2.2,<3",
|
|
52
52
|
"fiona>=1.9.6,<1.10.1",
|
|
53
53
|
"tqdm>=4",
|
|
54
54
|
"requests>=2.28,<3",
|
|
55
55
|
"matplotlib>=3.9",
|
|
56
|
+
"qrcode",
|
|
56
57
|
]
|
|
57
58
|
|
|
58
59
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|