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.
- earthdiff/__init__.py +28 -0
- earthdiff/__main__.py +3 -0
- earthdiff/aoi.py +161 -0
- earthdiff/cause.py +144 -0
- earthdiff/change.py +337 -0
- earthdiff/cli.py +202 -0
- earthdiff/composite.py +169 -0
- earthdiff/geocode.py +186 -0
- earthdiff/history.py +86 -0
- earthdiff/i18n.py +201 -0
- earthdiff/indices.py +52 -0
- earthdiff/mcp_prompts.py +54 -0
- earthdiff/mcp_server.py +338 -0
- earthdiff/mcp_service.py +887 -0
- earthdiff/periods.py +87 -0
- earthdiff/pipeline.py +476 -0
- earthdiff/reader.py +59 -0
- earthdiff/render.py +146 -0
- earthdiff/report.py +113 -0
- earthdiff/safety.py +93 -0
- earthdiff/seasonal.py +155 -0
- earthdiff/stac.py +163 -0
- earthdiff/static/ui.html +239 -0
- earthdiff/templates/report.html +850 -0
- earthdiff/terrain.py +120 -0
- earthdiff/ui.py +227 -0
- earthdiff/vendor/LEAFLET-LICENSE.txt +26 -0
- earthdiff/vendor/leaflet.css +661 -0
- earthdiff/vendor/leaflet.js +6 -0
- earthdiff/watch.py +138 -0
- earthdiff/xlsx.py +183 -0
- earthdiff/yearly.py +109 -0
- earthdiff-0.2.0.dist-info/METADATA +368 -0
- earthdiff-0.2.0.dist-info/RECORD +37 -0
- earthdiff-0.2.0.dist-info/WHEEL +4 -0
- earthdiff-0.2.0.dist-info/entry_points.txt +3 -0
- earthdiff-0.2.0.dist-info/licenses/LICENSE +21 -0
earthdiff/mcp_service.py
ADDED
|
@@ -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}
|