earthdiff 0.2.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.
@@ -0,0 +1,887 @@
1
+ """Tool logic of the MCP server, independent of the MCP wiring (so it can be unit-tested with fake STAC and raster readers).
2
+
3
+ The numerical method is not re-implemented here: ``detect_change`` calls :func:`earthdiff.pipeline.run_analysis`, ``plan_change``
4
+ calls :func:`earthdiff.pipeline.plan`. This module adds what an assistant needs around them: input validation and limits,
5
+ place search with disambiguation, quality assessment, a compact answer and captioned images.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ import math
12
+ import os
13
+ import re
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from datetime import date
18
+ from pathlib import Path
19
+ from typing import Any, Callable
20
+
21
+ from PIL import Image, ImageDraw
22
+ from shapely.geometry import box
23
+ from shapely.geometry.base import BaseGeometry
24
+
25
+ from . import __version__
26
+ from .aoi import MAX_AREA_KM2, AOIError, area_km2, bbox_geometry, choose_resolution, geometry_from_geojson, make_grid
27
+ from .change import CLASSES, SENSITIVITY, Thresholds, thresholds_for
28
+ from .composite import Reader
29
+ from .geocode import ATTRIBUTION as PLACE_ATTRIBUTION, GeocodeError, Nominatim, Place
30
+ from .periods import Period, parse_period, season_gap_days
31
+ from .pipeline import MAX_WINDOW_DAYS as PIPELINE_MAX_WINDOW_DAYS
32
+ from .pipeline import SEASON_GAP_MAX as PIPELINE_SEASON_GAP_MAX
33
+ from .pipeline import FIRE_EXPLAINS_SHARE, LARGE_CHANGE_SHARE, InsufficientDataError, RunOptions, fire_like_share, period_warnings, plan, run_analysis
34
+ from .reader import cog_reader
35
+ from .render import _font
36
+ from .safety import DATA_HOSTS, clean_text, host_allowed, redact, run_folder
37
+ from .stac import BACKENDS, STACError, Scene, search_scenes
38
+
39
+ DEFAULT_MAX_AREA_KM2 = MAX_AREA_KM2 # same limit as the CLI and the web UI; operators may lower it (EARTHDIFF_MAX_AREA_KM2)
40
+ HARD_MAX_AREA_KM2 = MAX_AREA_KM2
41
+ MIN_AREA_KM2 = 0.1
42
+ MAX_WINDOW_DAYS = PIPELINE_MAX_WINDOW_DAYS # same limit as the CLI and the web UI
43
+ LONG_WINDOW_DAYS = 92
44
+ EARLIEST_L2A = date(2017, 1, 1)
45
+ MAX_GEOJSON_CHARS = 200_000
46
+ MAX_VERTICES = 50_000
47
+ RESOLUTIONS = (10, 20, 30, 60)
48
+ LEVEL_ORDER = ("high", "medium", "low")
49
+
50
+ SCREENING_NOTE = "earthdiff is a screening tool: it flags where free Sentinel-2 imagery changed. It is not an official damage, burned-area, flood or deforestation assessment."
51
+
52
+
53
+ class ToolInputError(ValueError):
54
+ """Bad or unsupported tool input. The message is written for the model and the user."""
55
+
56
+
57
+ class RunStopped(RuntimeError):
58
+ """The analysis was cancelled by the client or exceeded the time limit."""
59
+
60
+
61
+ # ------------------------------------------------------------------------------------------------ settings
62
+ @dataclass(frozen=True)
63
+ class Settings:
64
+ output_dir: Path = field(default_factory=lambda: Path.home() / "earthdiff-mcp-output")
65
+ max_area_km2: float = DEFAULT_MAX_AREA_KM2
66
+ max_runtime_s: int = 480
67
+ contact: str | None = None
68
+ open_report: bool = False
69
+
70
+ @classmethod
71
+ def from_env(cls, env: dict[str, str] | None = None) -> "Settings":
72
+ e = os.environ if env is None else env
73
+
74
+ def num(name: str, default: float, lo: float, hi: float) -> float:
75
+ try:
76
+ v = float(e.get(name, "") or default)
77
+ except ValueError:
78
+ v = default
79
+ return min(max(v, lo), hi)
80
+
81
+ return cls(
82
+ output_dir=Path(e.get("EARTHDIFF_OUTPUT_DIR") or (Path.home() / "earthdiff-mcp-output")),
83
+ max_area_km2=num("EARTHDIFF_MAX_AREA_KM2", DEFAULT_MAX_AREA_KM2, 1.0, HARD_MAX_AREA_KM2),
84
+ max_runtime_s=int(num("EARTHDIFF_MAX_RUNTIME_S", 480, 60, 1800)),
85
+ contact=clean_text(e.get("EARTHDIFF_CONTACT", ""), 80) or None,
86
+ open_report=(e.get("EARTHDIFF_OPEN_REPORT", "") or "").strip().lower() in {"1", "true", "yes"},
87
+ )
88
+
89
+
90
+ # ------------------------------------------------------------------------------------------------ input parsing
91
+ def parse_area(bbox: list[float] | None, geojson: Any) -> BaseGeometry:
92
+ """Exactly one of ``bbox`` ([west, south, east, north], degrees) or ``geojson`` (Polygon/MultiPolygon, WGS84)."""
93
+ if (bbox is None) == (geojson is None):
94
+ raise ToolInputError("give exactly one of bbox ([west, south, east, north] in degrees) or geojson (Polygon/MultiPolygon, WGS84)")
95
+ if isinstance(bbox, str): # models sometimes send "w,s,e,n"
96
+ try:
97
+ bbox = [float(x) for x in bbox.replace(" ", "").split(",")]
98
+ except ValueError:
99
+ raise ToolInputError("bbox must be four numbers: [west, south, east, north]") from None
100
+ if bbox is not None:
101
+ if len(bbox) != 4 or not all(isinstance(v, (int, float)) and math.isfinite(v) for v in bbox):
102
+ raise ToolInputError("bbox must be four finite numbers: [west, south, east, north] = [lon_min, lat_min, lon_max, lat_max]")
103
+ try:
104
+ return bbox_geometry(*(float(v) for v in bbox))
105
+ except AOIError as exc:
106
+ raise ToolInputError(str(exc)) from None
107
+ obj = geojson
108
+ if isinstance(obj, str):
109
+ if len(obj) > MAX_GEOJSON_CHARS:
110
+ raise ToolInputError(f"geojson is too large (limit {MAX_GEOJSON_CHARS:,} characters); simplify it or use a bbox")
111
+ try:
112
+ obj = json.loads(obj)
113
+ except ValueError:
114
+ raise ToolInputError("geojson is not valid JSON") from None
115
+ if not isinstance(obj, dict):
116
+ raise ToolInputError("geojson must be a GeoJSON object (Polygon, MultiPolygon, Feature or FeatureCollection)")
117
+ if len(json.dumps(obj)) > MAX_GEOJSON_CHARS:
118
+ raise ToolInputError(f"geojson is too large (limit {MAX_GEOJSON_CHARS:,} characters); simplify it or use a bbox")
119
+ try:
120
+ geom = geometry_from_geojson(obj)
121
+ except AOIError as exc:
122
+ raise ToolInputError(str(exc)) from None
123
+ except Exception: # malformed coordinates etc.
124
+ raise ToolInputError("could not read the GeoJSON geometry") from None
125
+ from shapely import get_num_coordinates
126
+
127
+ if int(get_num_coordinates(geom)) > MAX_VERTICES:
128
+ raise ToolInputError(f"the area has more than {MAX_VERTICES:,} vertices; simplify it or use a bbox")
129
+ return geom
130
+
131
+
132
+ def check_area(geom: BaseGeometry, max_area_km2: float) -> float:
133
+ km2 = area_km2(geom)
134
+ if km2 < MIN_AREA_KM2:
135
+ raise ToolInputError(f"the area is only {km2:.3f} km2; at least {MIN_AREA_KM2:g} km2 is needed (Sentinel-2 pixels are 10 to 60 m)")
136
+ if km2 > max_area_km2:
137
+ raise ToolInputError(
138
+ f"the area is {km2:,.0f} km2 but this server's limit is {max_area_km2:,.0f} km2 (operator setting EARTHDIFF_MAX_AREA_KM2). "
139
+ "Narrow it: pick the part the user cares about, or use the narrowing_options returned by find_place; "
140
+ "do not split a bigger area silently, tell the user which part you analysed"
141
+ )
142
+ return km2
143
+
144
+
145
+ def area_info(geom: BaseGeometry, km2: float) -> dict[str, Any]:
146
+ w, s, e, n = geom.bounds
147
+ c = geom.representative_point()
148
+ return {
149
+ "bbox_west_south_east_north": [round(w, 5), round(s, 5), round(e, 5), round(n, 5)],
150
+ "center_lon_lat": [round(c.x, 5), round(c.y, 5)],
151
+ "area_km2": round(km2, 2),
152
+ }
153
+
154
+
155
+ def parse_periods(after: str, before: str | None, today: date) -> tuple[Period, Period, bool, list[str]]:
156
+ """Parse and validate the two windows. Returns ``(before, after, before_was_defaulted, warnings)``."""
157
+ try:
158
+ p_after = parse_period(clean_text(after, 40))
159
+ p_before = parse_period(clean_text(before, 40)) if before else None
160
+ except ValueError as exc:
161
+ raise ToolInputError(clean_text(exc, 200)) from None
162
+ defaulted = p_before is None
163
+ if p_before is None:
164
+ p_before = p_after.shift_years(-1)
165
+ for label, p in (("after", p_after), ("before", p_before)):
166
+ if p.days() > MAX_WINDOW_DAYS:
167
+ raise ToolInputError(f"the {label} window is {p.days()} days long; the limit is {MAX_WINDOW_DAYS} days. Use a shorter window (one to three months of the same season works best)")
168
+ gap0 = season_gap_days(p_before, p_after)
169
+ if gap0 > PIPELINE_SEASON_GAP_MAX:
170
+ raise ToolInputError(f"the windows are {gap0} days apart in the seasonal cycle (limit {PIPELINE_SEASON_GAP_MAX}): compare the same months of different years, e.g. before 2020-08 and after 2024-08; different seasons make almost everything look changed")
171
+ if p_after.start > today:
172
+ raise ToolInputError(f"the after window starts in the future ({p_after.start.isoformat()}); today is {today.isoformat()}")
173
+ if p_before.start >= p_after.start or p_before.end >= p_after.start:
174
+ raise ToolInputError("the before window must end before the after window starts (before is the earlier period)")
175
+ warns: list[str] = []
176
+ if p_after.end > today:
177
+ warns.append(f"The after window extends beyond today ({today.isoformat()}); only scenes acquired so far can be used.")
178
+ if p_before.start < EARLIEST_L2A:
179
+ warns.append("Sentinel-2 L2A cloud-optimized scenes in the free archives are sparse before 2017; the before period may have no usable scenes.")
180
+ longest = max(p_before.days(), p_after.days())
181
+ if longest > LONG_WINDOW_DAYS:
182
+ warns.append(
183
+ f"A window of {longest} days spans several seasons. The composite uses the clearest scenes, which can come from different times of the year in the two periods; "
184
+ "prefer one to three months of the same season."
185
+ )
186
+ lo, hi = sorted((p_before.days(), p_after.days()))
187
+ if hi > 2 * lo and hi - lo > 30:
188
+ warns.append(f"The windows have very different lengths ({p_before.days()} and {p_after.days()} days), which makes the composites less comparable.")
189
+ return p_before, p_after, defaulted, warns
190
+
191
+
192
+ # ------------------------------------------------------------------------------------------------ bbox helpers
193
+ def expand_bbox(bbox: tuple[float, float, float, float], margin_km: float) -> tuple[float, float, float, float]:
194
+ if margin_km <= 0:
195
+ return bbox
196
+ w, s, e, n = bbox
197
+ lat_c = (s + n) / 2
198
+ dlat = margin_km / 111.32
199
+ dlon = margin_km / (111.32 * max(math.cos(math.radians(lat_c)), 0.05))
200
+ return (max(-180.0, w - dlon), max(-90.0, s - dlat), min(180.0, e + dlon), min(90.0, n + dlat))
201
+
202
+
203
+ def _bbox_km2(b: tuple[float, float, float, float]) -> float:
204
+ return area_km2(box(*b))
205
+
206
+
207
+ def centered_bbox_within(bbox: tuple[float, float, float, float], limit_km2: float) -> tuple[float, float, float, float]:
208
+ """The largest box around the centre of ``bbox`` whose area is at most ``limit_km2`` (90 % of it, to leave room)."""
209
+ w, s, e, n = bbox
210
+ cx, cy = (w + e) / 2, (s + n) / 2
211
+ k = math.sqrt(0.9 * limit_km2 / max(_bbox_km2(bbox), 1e-9))
212
+ if k >= 1:
213
+ return bbox
214
+ for _ in range(40):
215
+ hw, hh = (e - w) / 2 * k, (n - s) / 2 * k
216
+ cand = (cx - hw, cy - hh, cx + hw, cy + hh)
217
+ if _bbox_km2(cand) <= limit_km2:
218
+ return cand
219
+ k *= 0.97
220
+ return cand
221
+
222
+
223
+ _ROWS = {1: [""], 2: ["north", "south"], 3: ["north", "middle", "south"]}
224
+ _COLS = {1: [""], 2: ["west", "east"], 3: ["west", "middle", "east"]}
225
+
226
+
227
+ def tile_bbox(bbox: tuple[float, float, float, float], limit_km2: float) -> list[dict[str, Any]]:
228
+ """Cover ``bbox`` with the smallest grid (at most 3x3) whose tiles are each within ``limit_km2``; [] if impossible."""
229
+ w, s, e, n = bbox
230
+ for nx, ny in ((1, 2), (2, 1), (2, 2), (1, 3), (3, 1), (2, 3), (3, 2), (3, 3)):
231
+ tiles = []
232
+ ok = True
233
+ for r in range(ny):
234
+ for c in range(nx):
235
+ b = (w + (e - w) * c / nx, n - (n - s) * (r + 1) / ny, w + (e - w) * (c + 1) / nx, n - (n - s) * r / ny)
236
+ a = _bbox_km2(b)
237
+ if a > limit_km2:
238
+ ok = False
239
+ break
240
+ parts = [p for p in (_ROWS[ny][r], _COLS[nx][c]) if p]
241
+ label = "-".join(parts) if parts else "all"
242
+ if label == "middle-middle":
243
+ label = "centre"
244
+ tiles.append({"label": label, "bbox_west_south_east_north": [round(x, 5) for x in b], "area_km2": round(a, 1)})
245
+ if not ok:
246
+ break
247
+ if ok:
248
+ return tiles
249
+ return []
250
+
251
+
252
+ # ------------------------------------------------------------------------------------------------ place disambiguation
253
+ def _hav_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
254
+ p1, p2 = math.radians(lat1), math.radians(lat2)
255
+ a = math.sin((p2 - p1) / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(math.radians(lon2 - lon1) / 2) ** 2
256
+ return 12742.0 * math.asin(math.sqrt(a))
257
+
258
+
259
+ def distinct_places(places: list[Place]) -> list[Place]:
260
+ """Drop near-duplicates (same name, centres within 10 km) so that 'ambiguous' means genuinely different places."""
261
+ kept: list[Place] = []
262
+ for p in places:
263
+ dup = any(p.name.casefold() == q.name.casefold() and _hav_km(p.lon, p.lat, q.lon, q.lat) < 10 for q in kept)
264
+ if not dup:
265
+ kept.append(p)
266
+ return kept
267
+
268
+
269
+ # ------------------------------------------------------------------------------------------------ results
270
+ def confidence_summary(features: list[dict[str, Any]], bright: dict[str, Any] | None = None) -> dict[str, Any]:
271
+ out = _confidence_summary(features)
272
+ if bright and not bright.get("reliable", True):
273
+ frac = float(bright["fraction_of_comparable_area"])
274
+ if out["area_weighted_mean"] is not None:
275
+ out["area_weighted_mean"] = round(out["area_weighted_mean"] * (1.0 - frac), 2)
276
+ out["level"] = "low"
277
+ out["reliable"] = False
278
+ out["note"] = (
279
+ f"LOWERED: {frac * 100:.0f}% of the comparable area is very bright non-vegetated surface (salt, sand, snow or similar) where change detection is not reliable. "
280
+ "Do not read the absence of change there as no change. " + out["note"]
281
+ )
282
+ return out
283
+
284
+
285
+ def _confidence_summary(features: list[dict[str, Any]]) -> dict[str, Any]:
286
+ total = sum(f["properties"]["area_ha"] for f in features)
287
+ if not features or total <= 0:
288
+ return {"area_weighted_mean": None, "level": None, "share_of_changed_area": {k: 0.0 for k in LEVEL_ORDER}, "note": "no change polygons"}
289
+ mean = sum(f["properties"]["confidence"] * f["properties"]["area_ha"] for f in features) / total
290
+ share = {k: 0.0 for k in LEVEL_ORDER}
291
+ for f in features:
292
+ share[f["properties"]["confidence_level"]] += f["properties"]["area_ha"] / total
293
+ from .change import confidence_level
294
+
295
+ return {
296
+ "area_weighted_mean": round(mean, 2),
297
+ "level": confidence_level(mean),
298
+ "share_of_changed_area": {k: round(v, 3) for k, v in share.items()},
299
+ "note": "Heuristic score from 0 to 1 (magnitude of the index change, number of valid observations, size). It is not a probability of being correct.",
300
+ }
301
+
302
+
303
+ def assess_quality(summary: dict[str, Any], before: Period, after: Period) -> dict[str, Any]:
304
+ """Data-quality grade of the comparison itself (not an accuracy claim)."""
305
+ poor: list[str] = []
306
+ fair: list[str] = []
307
+ vf = float(summary["valid_fraction"])
308
+ if vf < 0.60:
309
+ poor.append(f"only {vf * 100:.0f}% of the area is cloud-free in both periods")
310
+ elif vf < 0.85:
311
+ fair.append(f"{vf * 100:.0f}% of the area is cloud-free in both periods")
312
+ gap = season_gap_days(before, after)
313
+ if gap > 45:
314
+ poor.append(f"the windows are {gap} days apart in the seasonal cycle")
315
+ bs = summary.get("bright_surface") or {}
316
+ if bs and not bs.get("reliable", True):
317
+ poor.append(f"{bs['fraction_of_comparable_area'] * 100:.0f}% of the area is very bright non-vegetated surface (salt, sand, snow): change there is not reliable")
318
+ if float(summary.get("changed_fraction_of_aoi", 0.0)) > LARGE_CHANGE_SHARE:
319
+ # a large changed share is a mismatch symptom, unless dNBR says most of it is one burn (then it is the event itself)
320
+ if fire_like_share(summary.get("totals") or {}, summary.get("likely_cause")) < FIRE_EXPLAINS_SHARE:
321
+ poor.append(f"{summary['changed_fraction_of_aoi'] * 100:.0f}% of the area is flagged, which usually points to a seasonal or atmospheric mismatch")
322
+ for label in ("before", "after"):
323
+ n = int(summary[f"scenes_{label}"])
324
+ if n == 0:
325
+ poor.append(f"no usable scene in the {label} period")
326
+ elif n == 1:
327
+ fair.append(f"a single scene in the {label} period")
328
+ elif n < 3:
329
+ fair.append(f"only {n} scenes in the {label} period")
330
+ level = "poor" if poor else "fair" if fair else "good"
331
+ return {
332
+ "level": level,
333
+ "reasons": poor + fair,
334
+ "note": "Grades the data behind the comparison, not the correctness of the polygons; even 'good' is unverified by field data.",
335
+ }
336
+
337
+
338
+ def top_areas(features: list[dict[str, Any]], n: int = 8) -> list[dict[str, Any]]:
339
+ out = []
340
+ for f in features[:n]:
341
+ p = f["properties"]
342
+ out.append(
343
+ {
344
+ "id": p["id"],
345
+ "class": p["class"],
346
+ "area_ha": p["area_ha"],
347
+ "confidence": p["confidence"],
348
+ "confidence_level": p["confidence_level"],
349
+ "centroid_lon_lat": [p["centroid_lon"], p["centroid_lat"]],
350
+ "ndvi_before_after": [p["ndvi_before"], p["ndvi_after"]],
351
+ "mndwi_before_after": [p["mndwi_before"], p["mndwi_after"]],
352
+ "bsi_before_after": [p["bsi_before"], p["bsi_after"]],
353
+ "valid_obs_before_after": [p["valid_obs_before"], p["valid_obs_after"]],
354
+ }
355
+ )
356
+ for k in ("likely_cause", "likely_cause_basis", "dnbr", "likely_seasonal", "bright_share"):
357
+ if p.get(k) is not None:
358
+ out[-1][k] = p[k]
359
+ return out
360
+
361
+
362
+ def captioned_jpeg(path: Path, caption: str, max_dim: int = 960, quality: int = 82) -> bytes:
363
+ """Flatten an RGBA map image onto light grey, downscale, add a caption strip and return JPEG bytes (small enough for chat)."""
364
+ im = Image.open(path).convert("RGBA")
365
+ bg = Image.new("RGBA", im.size, (232, 232, 232, 255))
366
+ bg.alpha_composite(im)
367
+ im = bg.convert("RGB")
368
+ k = min(1.0, max_dim / max(im.size))
369
+ if k < 1.0:
370
+ im = im.resize((max(1, round(im.width * k)), max(1, round(im.height * k))), Image.LANCZOS)
371
+ font = _font(max(14, im.width // 45))
372
+ strip = font.size + 14
373
+ sheet = Image.new("RGB", (im.width, im.height + strip), (255, 255, 255))
374
+ ImageDraw.Draw(sheet).text((8, 6), clean_text(caption, 120), fill=(20, 20, 20), font=font)
375
+ sheet.paste(im, (0, strip))
376
+ buf = io.BytesIO()
377
+ sheet.save(buf, "JPEG", quality=quality, optimize=True)
378
+ return buf.getvalue()
379
+
380
+
381
+ # ------------------------------------------------------------------------------------------------ progress
382
+ _BAND_ORDER = ("blue", "green", "red", "nir", "swir16", "swir22")
383
+
384
+
385
+ class ProgressMapper:
386
+ """Maps the pipeline's progress lines to a monotonic 0..100 value and a path-free message."""
387
+
388
+ def __init__(self) -> None:
389
+ self.value = 0.0
390
+ self.text = "starting"
391
+
392
+ def update(self, msg: str) -> tuple[float, str]:
393
+ m = msg.strip()
394
+ v, text = self.value, None
395
+ if m.startswith("AOI"):
396
+ v, text = 2, "area prepared"
397
+ elif m.startswith("searching"):
398
+ v, text = 4, "searching Sentinel-2 scenes"
399
+ elif m.startswith("found"):
400
+ v, text = 7, m
401
+ elif (mm := re.match(r"(before|after): checking cloud cover", m)):
402
+ base = 8 if mm.group(1) == "before" else 47
403
+ v, text = base, f"{mm.group(1)}: checking cloud cover of candidate scenes"
404
+ elif (mm := re.match(r"(before|after): downloading \+ compositing band '(\w+)'", m)):
405
+ base = 14 if mm.group(1) == "before" else 53
406
+ idx = _BAND_ORDER.index(mm.group(2)) if mm.group(2) in _BAND_ORDER else 0
407
+ v, text = base + idx * 5.4, f"{mm.group(1)}: downloading band {idx + 1} of 6 ({mm.group(2)})"
408
+ elif m.startswith("seasonal"):
409
+ v, text = max(self.value, 84), "seasonal check: comparing with earlier years"
410
+ elif m.startswith("detecting"):
411
+ v, text = 88, "detecting changes"
412
+ elif m.startswith("rendering"):
413
+ v, text = 94, "rendering report and maps"
414
+ elif m.startswith("done"):
415
+ v, text = 99, "finished"
416
+ if text is None:
417
+ text = redact(m, ())[:100]
418
+ self.value = max(self.value, float(v))
419
+ self.text = text
420
+ return self.value, self.text
421
+
422
+
423
+ # ------------------------------------------------------------------------------------------------ runtime estimate
424
+ def runtime_hint(pixels: int, scenes: int) -> str:
425
+ """Coarse expectation, from the validation runs on a home connection (see validation/mcp_questions.md)."""
426
+ work = pixels * max(scenes, 1)
427
+ if work < 3_000_000:
428
+ return "usually under 1 minute"
429
+ if work < 20_000_000:
430
+ return "usually 1 to 3 minutes"
431
+ if work < 60_000_000:
432
+ return "usually 2 to 5 minutes"
433
+ return "can take 4 to 8 minutes; consider a smaller area or a coarser resolution_m"
434
+
435
+
436
+ SEASONAL_YEARS = 2
437
+
438
+
439
+ def seasonal_plan(before: Period, after: Period, years: int, enabled: bool) -> dict[str, Any]:
440
+ """What the optional seasonal check would do and cost (measured on the validation runs, see validation/mcp_questions.md)."""
441
+ from .seasonal import prior_windows
442
+
443
+ windows: dict[tuple, str] = {}
444
+ for b, a in prior_windows(before, after, years):
445
+ for p in (b, a):
446
+ windows[(p.start, p.end)] = p.label()
447
+ known = {(before.start, before.end), (after.start, after.end)}
448
+ new = [lab for k, lab in windows.items() if k not in known]
449
+ return {
450
+ "enabled": enabled,
451
+ "earlier_years_checked": years,
452
+ "extra_windows": new,
453
+ "extra_composites_at_most": len(new),
454
+ "extra_time": "typically adds about 45 seconds or more (measured +47 s on a 39 km2 test: 4 small composites, +75% on a 61 s run); it is off by default",
455
+ "what_it_does": "flags vegetation-loss polygons whose NDVI also fell in the same windows of earlier years (likely harvest or a seasonal cycle); polygons are never removed",
456
+ }
457
+
458
+
459
+ @dataclass
460
+ class DetectOutcome:
461
+ result: dict[str, Any]
462
+ images: list[tuple[str, bytes]] # (caption, JPEG)
463
+ report_path: Path
464
+
465
+
466
+ # ------------------------------------------------------------------------------------------------ service
467
+ class EarthDiffService:
468
+ """All tool logic. Data sources are injectable for tests; the defaults are the real STAC API, the real COGs and Nominatim."""
469
+
470
+ def __init__(
471
+ self,
472
+ settings: Settings,
473
+ search=search_scenes,
474
+ reader: Reader = cog_reader,
475
+ geocoder: Nominatim | None = None,
476
+ today: Callable[[], date] = date.today,
477
+ ):
478
+ self.settings = settings
479
+ self._search = search
480
+ self._reader = reader
481
+ self._geocoder = geocoder
482
+ self._today = today
483
+ self._run_lock = threading.Lock()
484
+
485
+ # -- network allow-list
486
+ def _scene_ok(self, s: Scene) -> bool:
487
+ return all(host_allowed(h, DATA_HOSTS) for h in s.hrefs.values())
488
+
489
+ def guarded_search(self, backend, geom, period, max_cloud=80.0, limit=30, retries=3):
490
+ scenes = self._search(backend, geom, period, max_cloud)
491
+ return [s for s in scenes if self._scene_ok(s)]
492
+
493
+ def guarded_reader(self, scene: Scene, band: str, grid) -> Any:
494
+ url = scene.hrefs.get(band)
495
+ if url is not None and not host_allowed(url, DATA_HOSTS):
496
+ raise RuntimeError("refusing to read from a host outside the Sentinel-2 data allow-list")
497
+ return self._reader(scene, band, grid)
498
+
499
+ def _geo(self) -> Nominatim:
500
+ if self._geocoder is None:
501
+ cache = Path(self.settings.output_dir) / ".cache" / "nominatim.json"
502
+ self._geocoder = Nominatim(contact=self.settings.contact, cache_path=cache)
503
+ return self._geocoder
504
+
505
+ def _redact(self, text: object) -> str:
506
+ return redact(text, (str(Path.home()), str(self.settings.output_dir), os.getcwd()))
507
+
508
+ # -- find_place
509
+ def find_place(self, query: str, country_codes: str | None = None, language: str | None = None, limit: int = 5, margin_km: float = 0.0) -> dict[str, Any]:
510
+ q = clean_text(query, 200)
511
+ if len(q) < 2:
512
+ raise ToolInputError("query must be a place name of at least two characters")
513
+ if country_codes is not None:
514
+ country_codes = country_codes.strip().lower().replace(" ", "")
515
+ if not re.fullmatch(r"[a-z]{2}(,[a-z]{2}){0,9}", country_codes):
516
+ raise ToolInputError("country_codes must be comma-separated ISO 3166-1 alpha-2 codes, e.g. 'tr' or 'us,ca'")
517
+ if language is not None and not re.fullmatch(r"[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})?", language.strip()):
518
+ raise ToolInputError("language must be a language tag such as 'en' or 'tr'")
519
+ limit = int(limit)
520
+ if not 1 <= limit <= 5:
521
+ raise ToolInputError("limit must be between 1 and 5")
522
+ if not 0 <= margin_km <= 20:
523
+ raise ToolInputError("margin_km must be between 0 and 20")
524
+ try:
525
+ places, cached = self._geo().search(q, country_codes, language.strip() if language else None, limit)
526
+ except GeocodeError as exc:
527
+ raise ToolInputError(str(exc)) from None
528
+ max_km2 = self.settings.max_area_km2
529
+ distinct = distinct_places(places)
530
+ cands = []
531
+ for i, p in enumerate(distinct, 1):
532
+ b = expand_bbox(p.bbox, margin_km)
533
+ km2 = _bbox_km2(b)
534
+ c: dict[str, Any] = {
535
+ "rank": i,
536
+ "name": p.name,
537
+ "display_name": p.display_name,
538
+ "osm_kind": p.kind,
539
+ "osm": p.osm,
540
+ "center_lon_lat": [round(p.lon, 5), round(p.lat, 5)],
541
+ "bbox_west_south_east_north": [round(x, 5) for x in b],
542
+ "area_km2": round(km2, 2),
543
+ "fits_analysis_limit": km2 <= max_km2,
544
+ }
545
+ if km2 < MIN_AREA_KM2:
546
+ c["note"] = "This is a point-like feature with a tiny bounding box; pass margin_km (for example 2 to 5) or give a larger bbox."
547
+ if km2 > max_km2:
548
+ tiles = tile_bbox(b, max_km2)
549
+ cb = centered_bbox_within(b, max_km2)
550
+ c["narrowing_options"] = {
551
+ "why": f"The bounding box is {km2:,.0f} km2 and the analysis limit is {max_km2:,.0f} km2. Ask the user which part matters, or offer one of these.",
552
+ "centered_bbox": {"bbox_west_south_east_north": [round(x, 5) for x in cb], "area_km2": round(_bbox_km2(cb), 1)},
553
+ "tiles": tiles,
554
+ "tiles_note": None if tiles else "The area is too large to cover with at most 9 tiles; ask the user for a smaller area of interest.",
555
+ }
556
+ cands.append(c)
557
+ if not cands:
558
+ status, nxt = "not_found", "No match. Ask the user for a more specific name (add the country or region) or for a bounding box."
559
+ elif len(cands) == 1:
560
+ status = "ok"
561
+ nxt = "One candidate. Confirm with the user if the place name could mean something else, then call plan_change or detect_change with its bbox."
562
+ if not cands[0]["fits_analysis_limit"]:
563
+ nxt = "The only candidate is larger than the analysis limit: offer the narrowing_options to the user; do not shrink it silently."
564
+ else:
565
+ status = "ambiguous"
566
+ nxt = (
567
+ "Several different places match. Do not guess: show the candidates and ask which one the user means "
568
+ "(unless the user already made it clear, for example 'the lake'; then say which candidate you used)."
569
+ )
570
+ return {
571
+ "status": status,
572
+ "query": q,
573
+ "candidates": cands,
574
+ "analysis_limit_km2": max_km2,
575
+ "margin_km": margin_km,
576
+ "next_step": nxt,
577
+ "from_cache": cached,
578
+ "attribution": PLACE_ATTRIBUTION,
579
+ }
580
+
581
+ # -- plan_change
582
+ def _prepare(self, bbox, geojson, after, before) -> tuple[BaseGeometry, float, Period, Period, bool, list[str]]:
583
+ geom = parse_area(bbox, geojson)
584
+ km2 = check_area(geom, self.settings.max_area_km2)
585
+ p_before, p_after, defaulted, warns = parse_periods(after, before, self._today())
586
+ return geom, km2, p_before, p_after, defaulted, warns
587
+
588
+ def plan_change(
589
+ self,
590
+ bbox: list[float] | None = None,
591
+ geojson: Any = None,
592
+ after: str = "",
593
+ before: str | None = None,
594
+ max_cloud: float = 80.0,
595
+ resolution_m: int | None = None,
596
+ backend: str = "earth-search",
597
+ seasonal_check: bool = False,
598
+ ) -> dict[str, Any]:
599
+ self._check_common(max_cloud, resolution_m, backend)
600
+ geom, km2, p_before, p_after, defaulted, warns = self._prepare(bbox, geojson, after, before)
601
+ res = float(resolution_m) if resolution_m else choose_resolution(geom)
602
+ grid = make_grid(geom, res)
603
+ opts = RunOptions(name="plan", backend=backend, resolution=res, max_cloud=max_cloud, max_area_km2=self.settings.max_area_km2)
604
+ try:
605
+ info = plan(geom, p_before, p_after, opts, search=self.guarded_search)
606
+ except (STACError, AOIError) as exc:
607
+ raise ToolInputError(self._redact(exc)) from None
608
+ warnings = list(period_warnings(p_before, p_after)) + warns
609
+ status = "ready"
610
+ for label, p, n, clouds in (("before", p_before, info["before_candidates"], info["before_cloud"]), ("after", p_after, info["after_candidates"], info["after_cloud"])):
611
+ if n == 0:
612
+ status = "no_data"
613
+ warnings.append(f"No Sentinel-2 L2A scene with less than {max_cloud:g}% cloud found for the {label} window ({p.label()}). Use another window or raise max_cloud.")
614
+ elif n == 1:
615
+ warnings.append(f"Only one candidate scene in the {label} window: the composite would be a single acquisition.")
616
+ elif n < 3:
617
+ warnings.append(f"Only {n} candidate scenes in the {label} window: little averaging of residual cloud or haze.")
618
+ if clouds and sorted(clouds)[len(clouds) // 2] > 50:
619
+ warnings.append(f"Most candidate scenes in the {label} window are cloudy (median scene cloud cover {sorted(clouds)[len(clouds) // 2]:.0f}%); the cloud-free share of the area may be low.")
620
+ scenes_used = min(8, info["before_candidates"]) + min(8, info["after_candidates"])
621
+ return {
622
+ "status": status,
623
+ "today": self._today().isoformat(),
624
+ "area": area_info(geom, km2),
625
+ "before_window": {"label": p_before.label(), "start": p_before.start.isoformat(), "end": p_before.end.isoformat(), "defaulted_to_same_period_last_year": defaulted},
626
+ "after_window": {"label": p_after.label(), "start": p_after.start.isoformat(), "end": p_after.end.isoformat()},
627
+ "season_gap_days": season_gap_days(p_before, p_after),
628
+ "resolution_m": info["resolution_m"],
629
+ "grid_pixels": grid.width * grid.height,
630
+ "candidate_scenes": {
631
+ "before": info["before_candidates"],
632
+ "after": info["after_candidates"],
633
+ "before_dates": info["before_dates"][:20],
634
+ "after_dates": info["after_dates"][:20],
635
+ },
636
+ "expected_runtime": runtime_hint(grid.width * grid.height, scenes_used),
637
+ "seasonal_check": seasonal_plan(p_before, p_after, SEASONAL_YEARS, seasonal_check),
638
+ "warnings": warnings,
639
+ "next_step": "Call detect_change with the same arguments." if status == "ready" else "Adjust the windows (or area) and plan again before running detect_change.",
640
+ "attribution": f"Scene search: {BACKENDS[backend].attribution}",
641
+ }
642
+
643
+ def _check_common(self, max_cloud: float, resolution_m: int | None, backend: str) -> None:
644
+ if backend not in BACKENDS:
645
+ raise ToolInputError(f"backend must be one of: {', '.join(BACKENDS)}")
646
+ if not 10 <= max_cloud <= 100:
647
+ raise ToolInputError("max_cloud must be between 10 and 100 (percent scene-level cloud cover)")
648
+ if resolution_m is not None and resolution_m not in RESOLUTIONS:
649
+ raise ToolInputError("resolution_m must be one of 10, 20, 30, 60 (metres) or omitted for automatic")
650
+
651
+ # -- detect_change
652
+ def detect_change(
653
+ self,
654
+ bbox: list[float] | None = None,
655
+ geojson: Any = None,
656
+ after: str = "",
657
+ before: str | None = None,
658
+ name: str | None = None,
659
+ sensitivity: str = "default",
660
+ resolution_m: int | None = None,
661
+ mmu_ha: float = 0.5,
662
+ max_cloud: float = 80.0,
663
+ backend: str = "earth-search",
664
+ seasonal_check: bool = False,
665
+ progress: Callable[[float, str], None] | None = None,
666
+ should_stop: Callable[[], bool] | None = None,
667
+ ) -> DetectOutcome:
668
+ self._check_common(max_cloud, resolution_m, backend)
669
+ if sensitivity not in SENSITIVITY:
670
+ raise ToolInputError(f"sensitivity must be one of: {', '.join(SENSITIVITY)}")
671
+ if not 0.1 <= mmu_ha <= 50:
672
+ raise ToolInputError("mmu_ha must be between 0.1 and 50 hectares")
673
+ geom, km2, p_before, p_after, defaulted, extra_warns = self._prepare(bbox, geojson, after, before)
674
+ display = clean_text(name, 80) if name else "Area of interest"
675
+ if not display:
676
+ display = "Area of interest"
677
+ if not self._run_lock.acquire(blocking=False):
678
+ raise ToolInputError("another analysis is still running in this server; wait for it to finish (one at a time protects memory)")
679
+ t0 = time.monotonic()
680
+ out: Path | None = None
681
+ try:
682
+ out = run_folder(self.settings.output_dir, display)
683
+ mapper = ProgressMapper()
684
+
685
+ def say(msg: str) -> None:
686
+ if (should_stop and should_stop()) or time.monotonic() - t0 > self.settings.max_runtime_s:
687
+ raise RunStopped(
688
+ f"the analysis was stopped after {time.monotonic() - t0:.0f} s (limit {self.settings.max_runtime_s} s, operator setting EARTHDIFF_MAX_RUNTIME_S). "
689
+ "Try a smaller area, a coarser resolution_m or shorter windows."
690
+ )
691
+ if progress:
692
+ progress(*mapper.update(msg))
693
+
694
+ opts = RunOptions(
695
+ name=display, backend=backend, resolution=float(resolution_m) if resolution_m else None, max_scenes=8, max_cloud=max_cloud,
696
+ sensitivity=sensitivity, mmu_ha=mmu_ha, max_area_km2=self.settings.max_area_km2, force=False,
697
+ likely_cause=True, seasonal_check=seasonal_check, seasonal_years=SEASONAL_YEARS,
698
+ )
699
+ try:
700
+ res = run_analysis(geom, p_before, p_after, out, opts, progress=say, reader=self.guarded_reader, search=self.guarded_search)
701
+ except InsufficientDataError as exc:
702
+ msg = re.sub(r"\s*Widen the date windows, use other months, or pass --force to run anyway\.", " Widen the windows or use other months.", str(exc))
703
+ msg = msg.replace("Use a longer window, a different month, or raise --max-cloud.", "Use a longer window, a different month, or raise max_cloud.")
704
+ raise ToolInputError(self._redact(msg)) from None
705
+ except (STACError, AOIError) as exc:
706
+ raise ToolInputError(self._redact(exc)) from None
707
+ geojson_data = json.loads(res.files["geojson"].read_text(encoding="utf-8"))
708
+ result = self._build_result(res, geojson_data["features"], p_before, p_after, defaulted, extra_warns, display, time.monotonic() - t0)
709
+ images = [
710
+ (f"Before {p_before.label()}", captioned_jpeg(res.files["before"], f"Before {p_before.label()} (Sentinel-2 true colour)")),
711
+ (f"After {p_after.label()}", captioned_jpeg(res.files["after"], f"After {p_after.label()} (Sentinel-2 true colour)")),
712
+ ("Detected changes", captioned_jpeg(res.files["change_map"], "Detected changes (hectares per class in the legend)", max_dim=1000)),
713
+ ]
714
+ return DetectOutcome(result, images, res.files["report"])
715
+ except BaseException:
716
+ if out is not None:
717
+ try:
718
+ out.rmdir() # only succeeds when empty: nothing is ever deleted
719
+ except OSError:
720
+ pass
721
+ raise
722
+ finally:
723
+ self._run_lock.release()
724
+
725
+ def _build_result(self, res, features, p_before, p_after, defaulted, extra_warns, display, elapsed_s) -> dict[str, Any]:
726
+ s = res.summary
727
+ warnings = list(s["warnings"])
728
+ for w in extra_warns:
729
+ if w not in warnings:
730
+ warnings.append(w)
731
+ totals = s["totals"]
732
+ changes = [
733
+ {"class": key, "label": t["label"], "area_ha": t["area_ha"], "polygons": t["polygons"]}
734
+ for key, t in totals.items()
735
+ ]
736
+ total_ha = round(sum(t["area_ha"] for t in totals.values()), 2)
737
+ files = {
738
+ "output_dir": str(res.out_dir),
739
+ "report_html": str(res.files["report"]),
740
+ "changes_geojson": str(res.files["geojson"]),
741
+ "summary_json": str(res.files["summary"]),
742
+ "before_png": str(res.files["before"]),
743
+ "after_png": str(res.files["after"]),
744
+ "change_map_png": str(res.files["change_map"]),
745
+ "overview_png": str(res.files["overview"]),
746
+ "change_class_tif": str(res.files["class_tif"]),
747
+ }
748
+ if "xlsx" in res.files:
749
+ files["changes_xlsx"] = str(res.files["xlsx"])
750
+ return {
751
+ "name": display,
752
+ "earthdiff_version": __version__,
753
+ "before_window": {"label": p_before.label(), "defaulted_to_same_period_last_year": defaulted},
754
+ "after_window": {"label": p_after.label()},
755
+ "area_km2": s["aoi_km2"],
756
+ "resolution_m": s["resolution_m"],
757
+ "comparable_fraction": s["valid_fraction"],
758
+ "scenes": {
759
+ "before": s["scenes_before"],
760
+ "after": s["scenes_after"],
761
+ "detail": [{"period": x["period"], "date": x["date"], "scene_cloud_cover": round(x["cloud_cover"], 1), "cloud_free_share_of_area": x["valid_fraction"]} for x in s["scenes"]],
762
+ },
763
+ "changes": changes,
764
+ "total_changed_ha": total_ha,
765
+ "changed_fraction_of_area": s["changed_fraction_of_aoi"],
766
+ "confidence": confidence_summary(features, s.get("bright_surface")),
767
+ "vegetation_loss_likely_cause": s.get("likely_cause"),
768
+ "seasonal_check": s.get("seasonal_check"),
769
+ "bright_surface": s.get("bright_surface"),
770
+ "data_quality": assess_quality(s, p_before, p_after),
771
+ "largest_areas": top_areas(features),
772
+ "warnings": warnings,
773
+ "files": files,
774
+ "attribution": s["attribution"],
775
+ "backend": s["backend_attribution"],
776
+ "elapsed_seconds": round(elapsed_s, 1),
777
+ "present_to_user": [
778
+ "The three images (before, after, detected changes) are returned with this result; refer to them.",
779
+ "Give hectares and polygon counts per class, the comparable share of the area, the confidence and the data quality, and EVERY warning above.",
780
+ "Say what the classes mean (a spectral change). likely_cause is only a hint from dNBR, brightness and shape: say 'fire-like' or 'clearing-like', never 'it was a fire'; drought, harvest and construction can look alike.",
781
+ "If seasonal_check is present, report the hectares flagged likely_seasonal (they repeat in earlier years, so probably harvest or a seasonal cycle); those polygons are kept.",
782
+ "If bright_surface.reliable is false, say that change on salt, sand or snow is not reliable and that no change there must not be read as no change; the confidence was lowered.",
783
+ SCREENING_NOTE,
784
+ f"Credit the data: {s['attribution']}.",
785
+ "Tell the user where report.html (interactive swipe map) and changes.geojson were saved.",
786
+ ],
787
+ }
788
+
789
+ # -- explain_method
790
+ def explain_method(self, topic: str = "all") -> dict[str, Any]:
791
+ topics = ("all", "rules", "pipeline", "likely_cause", "seasonal", "bright_surfaces", "confidence", "limits", "data")
792
+ if topic not in topics:
793
+ raise ToolInputError(f"topic must be one of: {', '.join(topics)}")
794
+ t = Thresholds()
795
+ rules = [
796
+ {"class": c.key, "label": c.label, "rule": c.rule}
797
+ for c in CLASSES.values()
798
+ ]
799
+ sections: dict[str, Any] = {
800
+ "rules": {
801
+ "summary": "Five plain spectral-index rules on median composites; no machine-learning model. d = after minus before.",
802
+ "indices": {
803
+ "NDVI": "(NIR - Red) / (NIR + Red), bands B08 and B04",
804
+ "MNDWI": "(Green - SWIR1) / (Green + SWIR1), bands B03 and B11",
805
+ "BSI": "((SWIR1 + Red) - (NIR + Blue)) / ((SWIR1 + Red) + (NIR + Blue))",
806
+ },
807
+ "classes": rules,
808
+ "default_thresholds": t.as_dict(),
809
+ "sensitivity_presets": {k: v for k, v in SENSITIVITY.items()},
810
+ "priority_when_rules_overlap": "new water > water loss > vegetation loss > vegetation gain > new bare soil / built-up",
811
+ },
812
+ "pipeline": [
813
+ "Search Sentinel-2 L2A scenes of each window in a public STAC API (Element 84 Earth Search by default).",
814
+ "Read only the pixels of the area from cloud-optimized GeoTIFFs and warp them to a UTM grid (10, 20, 30 or 60 m).",
815
+ "Mask no-data, saturated, cloud, cloud shadow, cirrus and snow pixels with the scene classification layer; clouds and shadows are buffered by 30 m.",
816
+ "Keep the best scenes by cloud-free coverage of YOUR area (up to 8 per window) and take the per-pixel median.",
817
+ "Apply the rules, remove narrow noise (opening for pixels of 20 m or finer), drop blobs below the minimum mapping unit (default 0.5 ha), polygonize.",
818
+ "Area is the pixel count times the pixel area on the UTM grid.",
819
+ ],
820
+ "likely_cause": {
821
+ "applies_to": "vegetation_loss polygons only (not a new class: a field of each polygon)",
822
+ "labels": {
823
+ "fire-like": "dNBR at least 0.27 (moderate-low burn severity) AND a dark surface after (visible reflectance <= 0.09) AND more evidence for fire (also an irregular outline) than for clearing",
824
+ "clearing-like": "little or no burn signal (dNBR below 0.27), or a straight-edged, bright outline: felling, harvest, ploughing, construction",
825
+ "unclear": "mixed or weak evidence; the default whenever the measurements disagree or B12 is missing",
826
+ },
827
+ "measurements": ["dnbr = NBR before - NBR after, NBR = (NIR - SWIR2) / (NIR + SWIR2), bands B08 and B12", "visible reflectance after = mean(blue, green, red)", "rectangularity = area / area of the minimum rotated rectangle"],
828
+ "thresholds": {
829
+ "dnbr": "Key & Benson (2006) FIREMON severity classes, also used by Copernicus EFFIS: 0.10 / 0.27 / 0.44 / 0.66 (derived for Landsat in specific ecosystems, indicative only)",
830
+ "own_heuristics": "visible reflectance after <= 0.09 dark, >= 0.20 bright; rectangularity >= 0.80 straight-edged, <= 0.60 irregular. These are this project's, checked on two public cases only.",
831
+ },
832
+ "caveat": "A hint, not a verdict. 'fire-like' does not prove a fire and 'clearing-like' does not prove a clearing; a wrong hint is expected in some cases. Never state a cause as fact.",
833
+ },
834
+ "seasonal": {
835
+ "what": "Optional (seasonal_check=true, off by default). For each vegetation-loss polygon the same two windows of the previous 2 years are compared (red and NIR only, up to 3 scenes per window, only the area around the loss). If NDVI fell by at least max(0.12, half the current drop) in at least half of the earlier years that had data, the polygon is flagged likely_seasonal.",
836
+ "use": "Recurring annual drops are typically harvest, senescence or irrigation cycles. Polygons are never removed; report the flagged hectares separately.",
837
+ "limits": "Needs scenes in earlier years (nothing before 2017); a one-off event that happens to repeat is flagged too; slow multi-year trends are not captured.",
838
+ },
839
+ "bright_surfaces": {
840
+ "rule": "A pixel is very bright when mean(blue, green, red) >= 0.25 and NDVI < 0.15 in the before or after composite (salt pans, sand, snow, bare limestone, concrete).",
841
+ "effect": f"If at least 20% of the comparable area is bright, a warning is added, bright_surface.reliable is false, the overall confidence is capped at 'low' and scaled down, and polygons on bright pixels get up to 50% lower confidence. data_quality becomes 'poor'.",
842
+ "meaning": "The vegetation and water rules are unreliable on such surfaces and true-colour images saturate; absence of detected change there must not be read as no change.",
843
+ "exploratory_signal": "On the bright pixels the share of water-like pixels by NDWI = (green - NIR) / (green + NIR) > 0 is reported before and after (water_like_share_of_bright_pixels). A large change suggests a salt flat or shallow lake got wetter or drier, which MNDWI cannot show because dry salt scores as water in MNDWI. It is not a mapped change.",
844
+ },
845
+ "confidence": {
846
+ "formula": "0.5 x magnitude + 0.3 x valid observations + 0.2 x size",
847
+ "magnitude": "how far the mean index change exceeds the class threshold (saturates at twice the threshold)",
848
+ "valid_observations": "the smaller number of cloud-free observations of the two periods (saturates at 3)",
849
+ "size": "log10(area / minimum mapping unit), saturates at 10 times the unit",
850
+ "levels": "0.70 and above is high, 0.45 and above is medium, otherwise low",
851
+ "caveat": "A heuristic score, not a probability. data_quality in the result grades the comparison itself (cloud-free share, seasons, scene count).",
852
+ },
853
+ "limits": {
854
+ "intended_use": "Preliminary screening of land, water, vegetation and surface changes at area scale.",
855
+ "not": [
856
+ "an official damage, burned-area, flood or deforestation assessment",
857
+ "an explanation of the cause (fire, felling, harvest, drought and construction can look alike)",
858
+ "able to see built-up growth on ground that was already bare",
859
+ "reliable for features narrower than a few pixels or smaller than the minimum mapping unit",
860
+ ],
861
+ "known_error_sources": [
862
+ "seasonal change and agriculture (compare the same season of different years)",
863
+ "cloud, haze, cloud shadow, terrain shadow, snow, bright salt or sand surfaces (flagged by the bright-surface warning)",
864
+ "turbid, shallow or algae-covered water, wet soil",
865
+ "co-registration of about 10 m and sun/view-angle differences, which create edge false alarms",
866
+ "scene availability: some regions and years have few cloud-free scenes",
867
+ ],
868
+ "server_limits": {
869
+ "max_area_km2": self.settings.max_area_km2,
870
+ "max_window_days": MAX_WINDOW_DAYS,
871
+ "min_area_km2": MIN_AREA_KM2,
872
+ "max_runtime_s": self.settings.max_runtime_s,
873
+ "one_analysis_at_a_time": True,
874
+ },
875
+ "not_for": "surveillance, targeting, or tracking people or vehicles",
876
+ },
877
+ "data": {
878
+ "imagery": "Copernicus Sentinel-2 L2A, free and open. Served as cloud-optimized GeoTIFFs by Element 84 Earth Search (AWS Open Data) or Microsoft Planetary Computer.",
879
+ "place_search": "OpenStreetMap Nominatim, (c) OpenStreetMap contributors (ODbL), at most one request per second, results cached.",
880
+ "attribution": "Contains modified Copernicus Sentinel data [years of the scenes used]",
881
+ "network": "Only the STAC API, Sentinel-2 COG storage hosts and nominatim.openstreetmap.org are contacted. No account or API key.",
882
+ "files": "Written only to the configured output folder (EARTHDIFF_OUTPUT_DIR).",
883
+ },
884
+ }
885
+ if topic == "all":
886
+ return {"version": __version__, **sections, "screening_note": SCREENING_NOTE}
887
+ return {"version": __version__, topic: sections[topic], "screening_note": SCREENING_NOTE}