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.
Files changed (37) hide show
  1. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/PKG-INFO +4 -3
  2. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/contrib/utils.py +5 -0
  3. edmt-1.0.7.post1/edmt/mapping/__init__.py +7 -0
  4. edmt-1.0.7.post1/edmt/mapping/carto.py +94 -0
  5. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/workflow/__init__.py +17 -5
  6. edmt-1.0.7.post1/edmt/workflow/analysis.py +493 -0
  7. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/workflow/builder.py +31 -0
  8. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/workflow/connector.py +9 -1
  9. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/PKG-INFO +4 -3
  10. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/SOURCES.txt +2 -0
  11. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/requires.txt +3 -2
  12. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/pyproject.toml +4 -3
  13. edmt-1.0.7.dev1/edmt/mapping/__init__.py +0 -3
  14. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/LICENSE +0 -0
  15. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/README.md +0 -0
  16. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/__init__.py +0 -0
  17. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/_edmt.py +0 -0
  18. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/analysis/__init__.py +0 -0
  19. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/base/__init__.py +0 -0
  20. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/base/base.py +0 -0
  21. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/contrib/__init__.py +0 -0
  22. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/conversion/__init__.py +0 -0
  23. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/conversion/conversion.py +0 -0
  24. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/models/__init__.py +0 -0
  25. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/models/drones.py +0 -0
  26. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/plotting/__init__.py +0 -0
  27. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt/workflow/workflow.py +0 -0
  28. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/dependency_links.txt +0 -0
  29. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/entry_points.txt +0 -0
  30. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/edmt.egg-info/top_level.txt +0 -0
  31. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/setup.cfg +0 -0
  32. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/tests/test_analysis.py +0 -0
  33. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/tests/test_base.py +0 -0
  34. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/tests/test_conversion.py +0 -0
  35. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/tests/test_mapping.py +0 -0
  36. {edmt-1.0.7.dev1 → edmt-1.0.7.post1}/tests/test_models.py +0 -0
  37. {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.dev1
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>=1
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>
@@ -126,3 +126,8 @@ def dict_expand(data,cols):
126
126
  expanded_df = pd.concat(dfs_to_join, axis=1)
127
127
 
128
128
  return df_processed.join(expanded_df).drop(columns=cols, errors='ignore')
129
+
130
+
131
+ # generate a EDMT customized QR Code image
132
+
133
+
@@ -0,0 +1,7 @@
1
+ from .carto import (
2
+ make_qr
3
+ )
4
+
5
+ __all__ = [
6
+ "make_qr"
7
+ ]
@@ -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 .builder import (
17
- gdf_to_ee_geometry
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.dev1
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>=1
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
@@ -1,8 +1,9 @@
1
1
  duckdb<1.3.2,>=0.9
2
2
  earthengine-api<1.8,>=0.1.324
3
- geopandas>=1
4
- pandas<3
3
+ geopandas<2,>=1.0
4
+ pandas<3,>=2.2
5
5
  fiona<1.10.1,>=1.9.6
6
6
  tqdm>=4
7
7
  requests<3,>=2.28
8
8
  matplotlib>=3.9
9
+ qrcode
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "edmt"
7
- version = "1.0.7.dev1"
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<3",
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
 
@@ -1,3 +0,0 @@
1
- __all__ = [
2
-
3
- ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes