satchange 0.1.0__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.
satchange/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """satchange — multipurpose satellite change detection.
2
+
3
+ Map deforestation, mining, urbanisation, floods, burns, surface-water change and
4
+ multi-epoch urban growth from free Sentinel-1/2 and Landsat data, via Google
5
+ Earth Engine or Microsoft Planetary Computer (no account needed). Pure Python.
6
+ """
7
+
8
+ __version__ = "0.1.0"
satchange/detect.py ADDED
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env python3
2
+ """Multipurpose satellite change detection.
3
+
4
+ Pick a SCENARIO and a LOCATION; the scenario selects the remote-sensing method
5
+ (NDVI/NDBI/NDWI/NBR change, SIRAD radar, or SAR flood water). Results download
6
+ straight to disk as a PNG quick-look, a georeferenced GeoTIFF, and a stats JSON.
7
+
8
+ Examples
9
+ --------
10
+ # List available scenarios
11
+ python3 detect.py --list
12
+
13
+ # Deforestation around a coordinate (radius 6 km)
14
+ python3 detect.py -s deforestation --lat -3.333 --lon 122.25 -r 6
15
+
16
+ # Same, coordinate as "lat,lon" (quote/`=` because lat is negative)
17
+ python3 detect.py -s mining -l=-3.333,122.25
18
+
19
+ # Flood: baseline window vs event window (both required)
20
+ python3 detect.py -s flood --lat 24.9 --lon 67.9 \
21
+ --pre 2022-06-01:2022-06-30 --post 2022-08-15:2022-09-05
22
+
23
+ # Use a named preset from sites.py instead of a coordinate
24
+ python3 detect.py -s mining --site konawe
25
+
26
+ Outputs (per run):
27
+ images/<scenario>_<product>_<name>.png quick-look
28
+ data/<scenario>_<product>_<name>.tif full-resolution GeoTIFF
29
+ data/<scenario>_<name>_stats.json statistics
30
+ """
31
+
32
+ import os
33
+ import sys
34
+ import json
35
+ import uuid
36
+ import argparse
37
+ from datetime import datetime
38
+
39
+ from .gee_utils import (
40
+ download_png, download_geotiff, initialize_ee, square_aoi)
41
+ from .scenarios import SCENARIOS, run_optical_change
42
+ from .indices import (
43
+ INDEX_FN, BUILTUP_METHODS, THERMAL_METHODS, METHOD_DEFAULTS)
44
+
45
+ # Outputs and credentials are resolved against the current working directory,
46
+ # so an installed package writes results where the user runs it (not site-packages).
47
+ OUTPUT_ROOT = os.path.join(os.getcwd(), "output")
48
+ CONFIG_KEY = os.path.join(os.getcwd(), "scripts", "config", "ee-geodetic.json")
49
+
50
+
51
+ def new_run_dir(scenario, name):
52
+ """Create output/<timestamp>_<scenario>_<name>_<token>/ and return it."""
53
+ run_id = (f"{datetime.now():%Y%m%d-%H%M%S}_{scenario}_{name}"
54
+ f"_{uuid.uuid4().hex[:6]}")
55
+ run_dir = os.path.join(OUTPUT_ROOT, run_id)
56
+ os.makedirs(run_dir, exist_ok=True)
57
+ return run_id, run_dir
58
+
59
+
60
+ def list_outputs(run_dir):
61
+ """Print the run folder and everything written to it."""
62
+ print(f"\nAll outputs → output/{os.path.basename(run_dir)}/")
63
+ for f in sorted(os.listdir(run_dir)):
64
+ print(f" {f}")
65
+
66
+
67
+ def parse_period(text):
68
+ """'YYYY-MM-DD:YYYY-MM-DD' -> (start, end)."""
69
+ try:
70
+ start, end = text.split(":")
71
+ return start.strip(), end.strip()
72
+ except ValueError:
73
+ raise SystemExit(f"Bad date window '{text}'. Use START:END "
74
+ "(e.g. 2023-01-01:2023-12-31).")
75
+
76
+
77
+ def parse_location(text):
78
+ """'lat,lon' -> (lat, lon)."""
79
+ try:
80
+ lat, lon = (float(x) for x in text.split(","))
81
+ return lat, lon
82
+ except ValueError:
83
+ raise SystemExit(f"Bad location '{text}'. Use 'lat,lon' "
84
+ "(e.g. -3.333,122.25).")
85
+
86
+
87
+ def safe_name(text):
88
+ return (text.replace(" ", "_").replace(",", "_")
89
+ .replace(".", "p").replace("-", "m"))
90
+
91
+
92
+ def print_scenarios():
93
+ print("Available scenarios (-s):\n")
94
+ for key, cfg in SCENARIOS.items():
95
+ print(f" {key:<14} {cfg['label']}")
96
+ print(f"\nBuilt-up methods (--method) · Sentinel-2: {', '.join(BUILTUP_METHODS)}")
97
+ print(f" · Landsat (thermal, auto): {', '.join(THERMAL_METHODS)}")
98
+ print("\nLocation: --lat LAT --lon LON | -l 'lat,lon' | --site NAME")
99
+
100
+
101
+ def resolve_location(args):
102
+ """Return (lat, lon, radius_km, name)."""
103
+ if args.site:
104
+ from .sites import get_site
105
+ site = get_site(["--site", args.site])
106
+ return site["lat"], site["lon"], site["radius_km"], args.site
107
+ if args.location:
108
+ lat, lon = parse_location(args.location)
109
+ elif args.lat is not None and args.lon is not None:
110
+ lat, lon = args.lat, args.lon
111
+ else:
112
+ raise SystemExit("Provide a location: --lat/--lon, -l 'lat,lon', "
113
+ "or --site NAME. See --help.")
114
+ name = args.name or safe_name(f"{lat}_{lon}")
115
+ return lat, lon, None, name
116
+
117
+
118
+ def build_params(scenario, args):
119
+ """Assemble the params dict a scenario's run() expects."""
120
+ cfg = SCENARIOS[scenario]
121
+ needs = cfg.get("needs")
122
+ p = {}
123
+
124
+ if needs in ("sirad", "epochs"):
125
+ # Both take exactly 3 date windows (R/G/B). --epochs overrides the
126
+ # scenario default; SIRAD stores them as sirad_periods, urban-trend as epochs.
127
+ default = cfg["sirad_periods"] if needs == "sirad" else cfg["epochs"]
128
+ windows = ([parse_period(w) for w in args.epochs.split(",")]
129
+ if args.epochs else default)
130
+ if len(windows) != 3:
131
+ raise SystemExit("--epochs needs exactly 3 windows: W1,W2,W3 "
132
+ "(each START:END, e.g. 2024-01-01:2024-12-31)")
133
+ p["sirad_periods" if needs == "sirad" else "epochs"] = windows
134
+ return p
135
+
136
+ # pre/post windows (optical + flood)
137
+ pre = parse_period(args.pre) if args.pre else cfg.get("pre")
138
+ post = parse_period(args.post) if args.post else cfg.get("post")
139
+ if needs == "pre_post_required" and (not pre or not post):
140
+ raise SystemExit(
141
+ f"Scenario '{scenario}' needs explicit windows: "
142
+ "--pre START:END --post START:END")
143
+ p["pre"], p["post"] = pre, post
144
+ return p
145
+
146
+
147
+ def apply_overrides(cfg, args):
148
+ """Return a cfg copy with --method/--thr/--severe applied (optical only)."""
149
+ cfg = dict(cfg)
150
+ if cfg.get("method") != "optical":
151
+ if args.method:
152
+ print(f"(--method ignored — '{args.scenario}' is not index-based)")
153
+ return cfg
154
+ if args.method:
155
+ m = args.method.upper()
156
+ if m not in INDEX_FN:
157
+ raise SystemExit(f"Unknown --method '{args.method}'. "
158
+ f"Options: {', '.join(sorted(INDEX_FN))}")
159
+ direction, thr, severe, vmax = METHOD_DEFAULTS[m]
160
+ sensor = "Landsat" if m in THERMAL_METHODS else "Sentinel-2"
161
+ cfg.update(index=m, direction=direction, thr=thr, severe=severe, vmax=vmax,
162
+ label=f"{args.scenario.capitalize()} — {m} change ({sensor})")
163
+ cfg.setdefault("vmax", METHOD_DEFAULTS.get(cfg["index"], (None, 0, 0, 0.6))[3])
164
+ if args.thr is not None:
165
+ cfg["thr"] = args.thr
166
+ if args.severe is not None:
167
+ cfg["severe"] = args.severe
168
+ return cfg
169
+
170
+
171
+ def _write_gee_product(prod, aoi, run_dir, common, do_map, basemap):
172
+ """Download one GEE product (png + tif), write its meta, optionally its map."""
173
+ base = f"{common['scenario']}_{prod['key']}_{common['name']}"
174
+ png = os.path.join(run_dir, base + ".png")
175
+ tif = os.path.join(run_dir, base + ".tif")
176
+ print(f"Downloading {prod['key']} PNG...")
177
+ download_png(prod["thumb"], aoi, png, vis=prod["thumb_vis"])
178
+ print(f"Downloading {prod['key']} GeoTIFF...")
179
+ tif_ok = download_geotiff(prod["tif"], aoi, tif, scale=prod.get("scale", 10))
180
+
181
+ is_rgb = "bands" in prod["thumb_vis"]
182
+ vis = dict(prod["thumb_vis"])
183
+ if not is_rgb and "label" not in vis:
184
+ k = prod["key"]
185
+ vis["label"] = ("Δ" + k[1:].upper()) if k.startswith("d") else k.upper()
186
+ meta = {"tif": tif, "product_key": prod["key"], "vis": vis, "is_rgb": is_rgb,
187
+ "metric": vis.get("label"), **common}
188
+ with open(os.path.join(run_dir, base + ".meta.json"), "w") as mf:
189
+ json.dump(meta, mf, indent=2)
190
+
191
+ if do_map and tif_ok:
192
+ from .mapmaker import render_map
193
+ render_map(meta, os.path.join(run_dir, base + "_map"), basemap=basemap)
194
+ elif do_map:
195
+ print(" (map skipped — GeoTIFF unavailable for this product)")
196
+
197
+
198
+ def run_gee(args, cfg, lat, lon, radius, name, params, run_dir, run_id, provider, window):
199
+ """Run the Google Earth Engine backend and write outputs to run_dir."""
200
+ try:
201
+ import ee # noqa: F401 — GEE backend only
202
+ except ImportError:
203
+ sys.exit("The GEE backend needs earthengine-api: "
204
+ "pip install 'satchange[gee]' (or use --backend mpc)")
205
+ initialize_ee(CONFIG_KEY)
206
+ aoi = square_aoi(lon, lat, radius) # square clip (not a circle)
207
+
208
+ if cfg.get("method") == "optical":
209
+ result = run_optical_change(aoi, params, cfg["index"], cfg["direction"],
210
+ cfg["thr"], cfg["severe"], cfg.get("vmax", 0.6))
211
+ else:
212
+ result = cfg["run"](aoi, params)
213
+
214
+ common = {"scenario": args.scenario, "label": cfg["label"], "name": name,
215
+ "run_id": run_id, "source": "Google Earth Engine", "provider": provider,
216
+ "lat": lat, "lon": lon, "radius_km": radius, "window": window,
217
+ "interpretation": result.get("interpretation",
218
+ cfg.get("interpretation", "")),
219
+ "stats": result["stats"]}
220
+ for prod in result["products"]:
221
+ _write_gee_product(prod, aoi, run_dir, common, args.map, args.basemap)
222
+
223
+ stats = {"run_id": run_id, "scenario": args.scenario,
224
+ "location": {"lat": lat, "lon": lon},
225
+ "radius_km": radius, "results": result["stats"]}
226
+ with open(os.path.join(run_dir, "stats.json"), "w") as f:
227
+ json.dump(stats, f, indent=2)
228
+ print("\n=== Results ===")
229
+ print(json.dumps(result["stats"], indent=2))
230
+ print(f"\n{result.get('interpretation', cfg.get('interpretation', ''))}")
231
+
232
+
233
+ def main():
234
+ ap = argparse.ArgumentParser(
235
+ description="Multipurpose satellite change detection.",
236
+ formatter_class=argparse.RawDescriptionHelpFormatter,
237
+ epilog=__doc__)
238
+ ap.add_argument("-s", "--scenario", choices=list(SCENARIOS))
239
+ ap.add_argument("-l", "--location", help="'lat,lon' (use -l=-3.3,122.2 if lat<0)")
240
+ ap.add_argument("--lat", type=float)
241
+ ap.add_argument("--lon", type=float)
242
+ ap.add_argument("--site", help="named preset from sites.py")
243
+ ap.add_argument("-r", "--radius", type=float, help="AOI radius in km")
244
+ ap.add_argument("--pre", help="baseline window START:END")
245
+ ap.add_argument("--post", help="recent/event window START:END")
246
+ ap.add_argument("--epochs", help="three date windows W1,W2,W3 (each "
247
+ "START:END) for urban-trend epochs OR mining SIRAD periods "
248
+ "(R/G/B), e.g. 2024-01-01:2024-12-31,2025-...,2026-...")
249
+ ap.add_argument("-n", "--name", help="output label (default from coords)")
250
+ ap.add_argument("--map", action="store_true",
251
+ help="also render an A4 map layout (PDF + PNG) per product")
252
+ ap.add_argument("--basemap", choices=["osm", "gray", "none"], default="osm",
253
+ help="map basemap (default osm)")
254
+ ap.add_argument("--backend", choices=["gee", "mpc"], default="gee",
255
+ help="data backend: gee (Earth Engine) or mpc "
256
+ "(Microsoft Planetary Computer, no account needed)")
257
+ ap.add_argument("--method", help="override the index for optical scenarios "
258
+ "(e.g. urbanization: NDBI|UI|BU|IBI; also NDVI/NDWI/NBR)")
259
+ ap.add_argument("--thr", type=float, help="override the 'affected' threshold")
260
+ ap.add_argument("--severe", type=float, help="override the 'severe' threshold")
261
+ ap.add_argument("--list", action="store_true", help="list scenarios and exit")
262
+ args = ap.parse_args()
263
+
264
+ if args.list or not args.scenario:
265
+ print_scenarios()
266
+ return
267
+
268
+ cfg = SCENARIOS[args.scenario]
269
+ lat, lon, site_radius, name = resolve_location(args)
270
+ radius = args.radius or site_radius or cfg["radius"]
271
+ params = build_params(args.scenario, args)
272
+
273
+ cfg = apply_overrides(cfg, args)
274
+
275
+ print(f"=== Change detection: {args.scenario} ===")
276
+ print(f"{cfg['label']}")
277
+ print(f"Location: {lat}, {lon} radius {radius} km [{name}]\n")
278
+
279
+ # temporal window label for the map subtitle (both backends)
280
+ if params.get("pre"):
281
+ window = f"{params['pre'][0]} → {params['post'][1]}"
282
+ elif params.get("sirad_periods"):
283
+ sp = params["sirad_periods"]
284
+ window = f"{sp[0][0]} → {sp[-1][1]}"
285
+ elif params.get("epochs"):
286
+ ep = params["epochs"]
287
+ window = " · ".join(w[0][:4] for w in ep)
288
+ else:
289
+ window = None
290
+
291
+ landsat = cfg.get("method") == "trend" or cfg.get("index") in THERMAL_METHODS
292
+ provider = "Landsat C2-L2 (USGS/NASA)" if landsat else "Copernicus Sentinel (ESA)"
293
+
294
+ run_id, run_dir = new_run_dir(args.scenario, name)
295
+ print(f"Output folder: output/{run_id}/\n")
296
+
297
+ if args.backend == "mpc":
298
+ from .mpc_backend import run_mpc
299
+ run_mpc(args.scenario, cfg, lat, lon, radius, name, params,
300
+ run_dir, run_id, window, provider,
301
+ do_map=args.map, basemap=args.basemap)
302
+ list_outputs(run_dir)
303
+ return
304
+
305
+ run_gee(args, cfg, lat, lon, radius, name, params,
306
+ run_dir, run_id, provider, window)
307
+ list_outputs(run_dir)
308
+
309
+
310
+ if __name__ == "__main__":
311
+ main()
satchange/gee_utils.py ADDED
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env python3
2
+ """Helpers to download Earth Engine results directly to local disk.
3
+
4
+ Two ways to get a result out of GEE:
5
+ * download_png() — a quick-look RGB thumbnail (capped resolution)
6
+ * download_geotiff() — the full-resolution, georeferenced GeoTIFF you can
7
+ open in QGIS/rasterio. Uses ee.Image.getDownloadURL,
8
+ which has a per-request size limit (~32-48 MB); for
9
+ very large AOIs, fall back to a Drive export.
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import requests
15
+
16
+
17
+ def _fetch(url, out_path):
18
+ resp = requests.get(url, stream=True)
19
+ resp.raise_for_status()
20
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
21
+ with open(out_path, "wb") as f:
22
+ for chunk in resp.iter_content(chunk_size=8192):
23
+ f.write(chunk)
24
+ return out_path
25
+
26
+
27
+ def download_png(image, region, out_path, dimensions="1920x1920", vis=None):
28
+ """Download an RGB quick-look PNG thumbnail."""
29
+ params = {"region": region, "dimensions": dimensions, "format": "png"}
30
+ if vis:
31
+ params.update(vis)
32
+ _fetch(image.getThumbURL(params), out_path)
33
+ print(f"Saved: {os.path.normpath(out_path)}")
34
+ return out_path
35
+
36
+
37
+ def download_geotiff(image, region, out_path, scale=10, max_scale_mult=16):
38
+ """Download a full-resolution single-file GeoTIFF.
39
+
40
+ Earth Engine's direct download has a per-request size/compute limit, so a
41
+ large AOI can 400. Retry at progressively coarser scale until it fits.
42
+ Returns the path on success, or None if it fails even coarsened.
43
+ """
44
+ s, mult = scale, 1
45
+ last_err = None
46
+ while mult <= max_scale_mult:
47
+ try:
48
+ url = image.getDownloadURL({
49
+ "region": region, "scale": s,
50
+ "format": "GEO_TIFF", "filePerBand": False,
51
+ })
52
+ _fetch(url, out_path) # the pixel fetch can also fail for large AOIs
53
+ size_mb = os.path.getsize(out_path) / 1e6
54
+ note = f" (coarsened to {s:.0f} m to fit)" if s != scale else ""
55
+ print(f"Saved: {os.path.normpath(out_path)} ({size_mb:.1f} MB GeoTIFF){note}")
56
+ return out_path
57
+ except Exception as e: # noqa: BLE001 — retry coarser
58
+ last_err = e
59
+ mult *= 2
60
+ s = scale * mult
61
+ print(f"NOTE: GeoTIFF download failed even at {scale * max_scale_mult:.0f} m "
62
+ f"({last_err}).")
63
+ print(" Reduce --radius, or add --drive to export the full-res GeoTIFF "
64
+ "to Google Drive.")
65
+ return None
66
+
67
+
68
+ def wants_drive_export(argv=None):
69
+ """True if the user passed --drive (opt-in full-res Drive export)."""
70
+ argv = sys.argv if argv is None else argv
71
+ return "--drive" in argv
72
+
73
+
74
+ def square_aoi(lon, lat, radius_km):
75
+ """Square AOI centred on (lon, lat), half-side = radius_km.
76
+
77
+ Side length = 2 * radius_km (the square that circumscribes the old circle),
78
+ axis-aligned in lon/lat. Use instead of Point.buffer() (a circle).
79
+ """
80
+ import ee
81
+ return ee.Geometry.Point([lon, lat]).buffer(radius_km * 1000).bounds()
82
+
83
+
84
+ def mask_s2_clouds(img):
85
+ """Mask cloud / shadow / cirrus / snow using Sentinel-2 SCL band.
86
+
87
+ Applied per pixel so a median of many scenes yields a near cloud-free
88
+ composite even when individual scenes are partly cloudy.
89
+ """
90
+ scl = img.select("SCL")
91
+ keep = (scl.neq(3) # cloud shadow
92
+ .And(scl.neq(8)) # cloud medium probability
93
+ .And(scl.neq(9)) # cloud high probability
94
+ .And(scl.neq(10)) # thin cirrus
95
+ .And(scl.neq(11))) # snow / ice
96
+ return img.updateMask(keep)
97
+
98
+
99
+ def initialize_ee(config_key=None):
100
+ """Initialise Earth Engine with a service-account key if one is present."""
101
+ import json
102
+ import ee
103
+
104
+ candidates = [
105
+ p for p in (config_key,
106
+ os.path.expanduser("~/.config/earthengine/ee-geodetic.json"))
107
+ if p
108
+ ]
109
+ for key_path in candidates:
110
+ if os.path.exists(key_path):
111
+ with open(key_path) as f:
112
+ email = json.load(f).get("client_email")
113
+ ee.Initialize(ee.ServiceAccountCredentials(email, key_file=key_path))
114
+ print(f"GEE: service account {email}")
115
+ return
116
+ ee.Initialize() # falls back to `earthengine authenticate`
117
+ print("GEE: user credentials (earthengine authenticate)")
satchange/indices.py ADDED
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ """Spectral indices, composites, and Sentinel-1 helpers for change detection.
3
+
4
+ All functions operate server-side in Google Earth Engine. Sentinel-2 composites
5
+ mask cloud per pixel (SCL) and take a median over many scenes, so the result is
6
+ near cloud-free regardless of any single scene's cloud cover.
7
+ """
8
+
9
+ try:
10
+ import ee # only needed for the GEE backend
11
+ except ImportError:
12
+ ee = None
13
+ from .gee_utils import mask_s2_clouds
14
+
15
+ S2 = "COPERNICUS/S2_SR_HARMONIZED"
16
+ S1 = "COPERNICUS/S1_GRD"
17
+ ORBITS = ("ASCENDING", "DESCENDING")
18
+
19
+
20
+ def s2_median(aoi, start, end, scene_cloud_max=60):
21
+ """Cloud-masked median Sentinel-2 SR composite over a date window.
22
+
23
+ Returns (image, scene_count). scene_count is a Python int.
24
+ """
25
+ coll = (ee.ImageCollection(S2)
26
+ .filterBounds(aoi)
27
+ .filterDate(start, end)
28
+ .filter(ee.Filter.lte("CLOUDY_PIXEL_PERCENTAGE", scene_cloud_max))
29
+ .map(mask_s2_clouds))
30
+ return coll.median(), coll.size().getInfo()
31
+
32
+
33
+ # --- Normalised-difference indices on a Sentinel-2 SR image ---
34
+ def ndvi(img): # vegetation
35
+ return img.normalizedDifference(["B8", "B4"]).rename("NDVI")
36
+
37
+
38
+ def ndbi(img): # built-up
39
+ return img.normalizedDifference(["B11", "B8"]).rename("NDBI")
40
+
41
+
42
+ def ndwi(img): # open water (McFeeters)
43
+ return img.normalizedDifference(["B3", "B8"]).rename("NDWI")
44
+
45
+
46
+ def nbr(img): # burn
47
+ return img.normalizedDifference(["B8", "B12"]).rename("NBR")
48
+
49
+
50
+ # --- Alternative built-up indices (all computable on Sentinel-2) ---
51
+ def ui(img): # Urban Index — (SWIR2-NIR)/(SWIR2+NIR)
52
+ return img.normalizedDifference(["B12", "B8"]).rename("UI")
53
+
54
+
55
+ def _savi(img, L=0.5): # Soil-Adjusted Vegetation Index (reflectance-scaled)
56
+ nir = img.select("B8").divide(10000)
57
+ red = img.select("B4").divide(10000)
58
+ return nir.subtract(red).multiply(1 + L).divide(nir.add(red).add(L))
59
+
60
+
61
+ def bu(img): # Built-Up index = NDBI - NDVI (Kawamura 1996)
62
+ return ndbi(img).subtract(ndvi(img)).rename("BU")
63
+
64
+
65
+ def ibi(img): # Index-Based Built-up Index (Xu 2008)
66
+ nd = img.normalizedDifference(["B11", "B8"]) # NDBI
67
+ mndwi = img.normalizedDifference(["B3", "B11"]) # water
68
+ x = _savi(img).add(mndwi).divide(2)
69
+ # The ratio form is unstable where the denominator crosses zero; clamp it.
70
+ return nd.subtract(x).divide(nd.add(x)).clamp(-1, 1).rename("IBI")
71
+
72
+
73
+ INDEX_FN = {"NDVI": ndvi, "NDBI": ndbi, "NDWI": ndwi, "NBR": nbr,
74
+ "UI": ui, "BU": bu, "IBI": ibi}
75
+ # Interchangeable built-up methods for the urbanization scenario (Sentinel-2).
76
+ # NDISI/EBBI need a thermal band (Landsat), so they are NOT listed here.
77
+ BUILTUP_METHODS = ["NDBI", "UI", "BU", "IBI"]
78
+
79
+ # Per-method change-detection defaults: (direction, affected_thr, severe_thr, vmax).
80
+ # Different indices have different ranges, so each needs its own thresholds.
81
+ METHOD_DEFAULTS = {
82
+ "NDVI": ("loss", -0.15, -0.30, 0.6),
83
+ "NDBI": ("gain", 0.10, 0.20, 0.5),
84
+ "UI": ("gain", 0.08, 0.18, 0.5),
85
+ "BU": ("gain", 0.10, 0.25, 0.8),
86
+ "IBI": ("gain", 0.10, 0.25, 1.0),
87
+ "NDWI": ("gain", 0.10, 0.25, 0.6),
88
+ "NBR": ("loss", -0.10, -0.27, 0.6),
89
+ }
90
+
91
+
92
+ # --- Landsat 8/9 Collection-2 Level-2 (adds a thermal band for NDISI/EBBI) ---
93
+ L8_COL, L9_COL = "LANDSAT/LC08/C02/T1_L2", "LANDSAT/LC09/C02/T1_L2"
94
+
95
+
96
+ def _l2_prep(img):
97
+ """Scale a Landsat C2-L2 scene to reflectance + °C and mask clouds."""
98
+ qa = img.select("QA_PIXEL")
99
+ clear = qa.bitwiseAnd((1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4)).eq(0)
100
+ opt = (img.select(["SR_B3", "SR_B4", "SR_B5", "SR_B6", "SR_B7"])
101
+ .multiply(0.0000275).add(-0.2))
102
+ tir = img.select("ST_B10").multiply(0.00341802).add(149.0).subtract(273.15)
103
+ return (opt.addBands(tir)
104
+ .rename(["GREEN", "RED", "NIR", "SWIR1", "SWIR2", "TIR"])
105
+ .updateMask(clear))
106
+
107
+
108
+ def l2_median(aoi, start, end, cloud_max=60):
109
+ """Cloud-masked median Landsat 8/9 composite. Returns (image, scene_count)."""
110
+ col = (ee.ImageCollection(L8_COL).merge(ee.ImageCollection(L9_COL))
111
+ .filterBounds(aoi).filterDate(start, end)
112
+ .filter(ee.Filter.lt("CLOUD_COVER", cloud_max)).map(_l2_prep))
113
+ return col.median(), col.size().getInfo()
114
+
115
+
116
+ def _prep_l_sr(img, bands):
117
+ """Scale a Landsat C2-L2 scene to surface reflectance (no thermal), masked."""
118
+ qa = img.select("QA_PIXEL")
119
+ clear = qa.bitwiseAnd((1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4)).eq(0)
120
+ opt = img.select(bands).multiply(0.0000275).add(-0.2)
121
+ return opt.rename(["GREEN", "RED", "NIR", "SWIR1", "SWIR2"]).updateMask(clear)
122
+
123
+
124
+ def l_sr_median(aoi, start, end, cloud_max=60):
125
+ """Median Landsat surface-reflectance composite (archive back to 1984).
126
+
127
+ Uses Landsat 5 (TM) and 8/9 (OLI); Landsat 7 is EXCLUDED because its
128
+ Scan Line Corrector failed in 2003 (SLC-off gaps stripe every scene).
129
+ Uniform band naming across sensors so historical epochs (e.g. 2010) work.
130
+ """
131
+ tm = (ee.ImageCollection("LANDSAT/LT05/C02/T1_L2") # L5 TM (no SLC-off; ends 2011)
132
+ .filterBounds(aoi).filterDate(start, end)
133
+ .filter(ee.Filter.lt("CLOUD_COVER", cloud_max))
134
+ .map(lambda i: _prep_l_sr(i, ["SR_B2", "SR_B3", "SR_B4", "SR_B5", "SR_B7"])))
135
+ oli = (ee.ImageCollection("LANDSAT/LC08/C02/T1_L2")
136
+ .merge(ee.ImageCollection("LANDSAT/LC09/C02/T1_L2"))
137
+ .filterBounds(aoi).filterDate(start, end)
138
+ .filter(ee.Filter.lt("CLOUD_COVER", cloud_max))
139
+ .map(lambda i: _prep_l_sr(i, ["SR_B3", "SR_B4", "SR_B5", "SR_B6", "SR_B7"])))
140
+ col = tm.merge(oli)
141
+ return col.median(), col.size().getInfo()
142
+
143
+
144
+ def _norm01(band, lo, hi):
145
+ return band.subtract(lo).divide(hi - lo).clamp(0, 1)
146
+
147
+
148
+ def ndisi(img): # Normalized Difference Impervious Surface Index (Xu 2010)
149
+ tir = _norm01(img.select("TIR"), 0, 50) # °C -> [0,1]
150
+ nir = img.select("NIR").clamp(0, 1)
151
+ swir1 = img.select("SWIR1").clamp(0, 1)
152
+ mndwi = img.normalizedDifference(["GREEN", "SWIR1"]).add(1).divide(2) # ->[0,1]
153
+ x = mndwi.add(nir).add(swir1).divide(3)
154
+ return tir.subtract(x).divide(tir.add(x)).clamp(-1, 1).rename("NDISI")
155
+
156
+
157
+ def ebbi(img): # Enhanced Built-up & Bareness Index (As-syakur 2012), x100
158
+ swir1, nir, tir = img.select("SWIR1"), img.select("NIR"), img.select("TIR")
159
+ denom = swir1.add(tir).max(1e-6).sqrt().multiply(10)
160
+ return swir1.subtract(nir).divide(denom).multiply(100).rename("EBBI")
161
+
162
+
163
+ INDEX_FN.update({"NDISI": ndisi, "EBBI": ebbi})
164
+
165
+ # Which sensor each index needs. Thermal indices require Landsat (not Sentinel-2).
166
+ SENSOR = {k: "S2" for k in ("NDVI", "NDBI", "NDWI", "NBR", "UI", "BU", "IBI")}
167
+ SENSOR.update({"NDISI": "L8", "EBBI": "L8"})
168
+ THERMAL_METHODS = ["NDISI", "EBBI"] # Landsat-only built-up/impervious methods
169
+
170
+ METHOD_DEFAULTS.update({
171
+ "NDISI": ("gain", 0.05, 0.12, 0.5),
172
+ "EBBI": ("gain", 0.10, 0.25, 1.0),
173
+ })
174
+
175
+
176
+ # --- Sentinel-1 SAR helpers ---
177
+ def s1(aoi, start, end, orbit, pol):
178
+ """Sentinel-1 IW collection for one polarisation and orbit direction."""
179
+ return (ee.ImageCollection(S1)
180
+ .filterBounds(aoi)
181
+ .filterDate(start, end)
182
+ .filter(ee.Filter.listContains("transmitterReceiverPolarisation", pol))
183
+ .filter(ee.Filter.eq("instrumentMode", "IW"))
184
+ .filter(ee.Filter.eq("orbitProperties_pass", orbit))
185
+ .select(pol))
186
+
187
+
188
+ def best_orbit(aoi, periods, pol="VH", forced=None):
189
+ """Pick the orbit direction (ASC/DESC) that has imagery in every period.
190
+
191
+ `periods` is a list of (start, end) tuples. Returns (orbit, covered, counts).
192
+ """
193
+ orbits = [forced] if forced else list(ORBITS)
194
+ best = None # (covered, total, orbit, counts)
195
+ for orbit in orbits:
196
+ counts = [s1(aoi, s, e, orbit, pol).size().getInfo() for (s, e) in periods]
197
+ cand = (all(c > 0 for c in counts), sum(counts), orbit, counts)
198
+ if best is None or cand[:2] > best[:2]:
199
+ best = cand
200
+ covered, _total, orbit, counts = best
201
+ return orbit, covered, counts