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/cli.py ADDED
@@ -0,0 +1,202 @@
1
+ """Command line interface: run | plan | watch | ui | mcp."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from datetime import date
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .aoi import AOIError, area_km2, load_aoi, parse_bbox
11
+ from .aoi import MAX_AREA_KM2
12
+ from .change import SENSITIVITY
13
+ from .periods import parse_period
14
+ from .pipeline import InsufficientDataError, RunOptions, plan, run_analysis
15
+ from .stac import BACKENDS, STACError
16
+
17
+
18
+ def _say(msg: str) -> None:
19
+ print(f" {msg}", flush=True)
20
+
21
+
22
+ def _add_common(p: argparse.ArgumentParser) -> None:
23
+ g = p.add_argument_group("area of interest")
24
+ g.add_argument("--aoi", help="GeoJSON file (Polygon/MultiPolygon, Feature or FeatureCollection; WGS84)")
25
+ g.add_argument("--bbox", help="west,south,east,north in degrees (alternative to --aoi)")
26
+ g = p.add_argument_group("time windows")
27
+ g.add_argument("--after", required=True, help="YYYY-MM | YYYY-MM-DD | START/END (the 'now' window)")
28
+ g.add_argument("--before", help="baseline window; default = the --after window one year earlier (same season)")
29
+ g = p.add_argument_group("processing")
30
+ g.add_argument("--backend", choices=list(BACKENDS), default="earth-search", help="free public STAC API (default: earth-search)")
31
+ g.add_argument("--resolution", type=float, help="analysis pixel size in metres (default: auto 10/20/30/60)")
32
+ g.add_argument("--max-scenes", type=int, default=8, help="max scenes per composite (default 8)")
33
+ g.add_argument("--max-cloud", type=float, default=80.0, help="discard scenes with more scene-level cloud %% (default 80)")
34
+ g.add_argument("--sensitivity", choices=list(SENSITIVITY), default="default")
35
+ g.add_argument("--mmu-ha", type=float, default=0.5, help="minimum mapping unit in hectares (default 0.5)")
36
+ g.add_argument("--max-area-km2", type=float, default=MAX_AREA_KM2, help=f"AOI size limit (default {MAX_AREA_KM2:,.0f} km2, the same limit as the web UI and the MCP server)")
37
+ g.add_argument("--no-likely-cause", action="store_true", help="do not read B12 / label vegetation loss as fire-like, clearing-like or unclear (dNBR)")
38
+ g.add_argument("--any-season", action="store_true", help="run even when the two windows are in different seasons (refused by default: almost everything would look changed)")
39
+ g.add_argument("--no-yearly-frames", action="store_true", help="windows years apart: do not add one small timeline frame per in-between year")
40
+ g.add_argument("--no-terrain", action="store_true", help="do not read the Copernicus DEM for the report's offline 3D view and relief basemap")
41
+ g.add_argument("--seasonal-check", action="store_true", help="compare vegetation loss with the same windows of earlier years and flag recurring (harvest-like) loss; adds a few small downloads")
42
+ g.add_argument("--seasonal-years", type=int, default=2, choices=[1, 2, 3], help="how many earlier years the seasonal check looks at (default 2)")
43
+
44
+
45
+ def _geometry(args):
46
+ if bool(args.aoi) == bool(args.bbox):
47
+ raise AOIError("give exactly one of --aoi or --bbox")
48
+ return load_aoi(args.aoi) if args.aoi else parse_bbox(args.bbox)
49
+
50
+
51
+ def _options(args, name: str) -> RunOptions:
52
+ return RunOptions(
53
+ name=name,
54
+ backend=args.backend,
55
+ resolution=args.resolution,
56
+ max_scenes=args.max_scenes,
57
+ max_cloud=args.max_cloud,
58
+ sensitivity=args.sensitivity,
59
+ mmu_ha=args.mmu_ha,
60
+ max_area_km2=args.max_area_km2,
61
+ force=getattr(args, "force", False),
62
+ likely_cause=not args.no_likely_cause,
63
+ terrain=not args.no_terrain,
64
+ allow_season_mismatch=args.any_season,
65
+ yearly_frames=not args.no_yearly_frames,
66
+ seasonal_check=args.seasonal_check,
67
+ seasonal_years=args.seasonal_years,
68
+ )
69
+
70
+
71
+ def _periods(args):
72
+ after = parse_period(args.after)
73
+ before = parse_period(args.before) if args.before else after.shift_years(-1)
74
+ return before, after
75
+
76
+
77
+ def cmd_run(args) -> int:
78
+ geom = _geometry(args)
79
+ before, after = _periods(args)
80
+ name = args.name or (Path(args.aoi).stem if args.aoi else "bbox area")
81
+ print(f"earthdiff {__version__}: {name} | before {before.label()} -> after {after.label()}")
82
+ res = run_analysis(geom, before, after, args.out, _options(args, name), progress=_say)
83
+ print_summary(res.summary)
84
+ print(f"\nOpen: {res.files['report']}")
85
+ return 0
86
+
87
+
88
+ def cmd_plan(args) -> int:
89
+ geom = _geometry(args)
90
+ before, after = _periods(args)
91
+ info = plan(geom, before, after, _options(args, "plan"))
92
+ print(f"AOI {info['aoi_km2']} km2, analysis resolution {info['resolution_m']:g} m")
93
+ print(f"before {before.label()}: {info['before_candidates']} candidate scenes {', '.join(info['before_dates'][:12])}")
94
+ print(f"after {after.label()}: {info['after_candidates']} candidate scenes {', '.join(info['after_dates'][:12])}")
95
+ return 0
96
+
97
+
98
+ def print_summary(s: dict) -> None:
99
+ print("\nResult")
100
+ print(f" comparable (cloud-free in both periods): {s['valid_fraction'] * 100:.0f}% of the AOI")
101
+ for t in s["totals"].values():
102
+ print(f" {t['label']:<26} {t['area_ha']:>10,.1f} ha ({t['polygons']} polygons)")
103
+ lc = s.get("likely_cause")
104
+ if lc:
105
+ t = lc["totals"]
106
+ print(" vegetation loss, likely cause (a hint from dNBR, brightness and shape, not a verdict): "
107
+ + ", ".join(f"{k} {t[k]['area_ha']:,.1f} ha" for k in ("fire-like", "clearing-like", "unclear")))
108
+ sc = s.get("seasonal_check")
109
+ if sc and sc.get("enabled"):
110
+ ls = sc["likely_seasonal"]
111
+ print(f" seasonal check ({sc['years']} earlier year(s), {sc['extra_composites']} extra composites, {sc.get('elapsed_s', 0)} s): likely seasonal {ls['area_ha']:,.1f} ha in {ls['polygons']} polygons")
112
+ for w in s["warnings"]:
113
+ print(f" ! {w}")
114
+ print(f" {s['attribution']}")
115
+
116
+
117
+ def cmd_watch(args) -> int:
118
+ from .watch import EXAMPLE, run_watch
119
+
120
+ if args.init:
121
+ p = Path(args.config)
122
+ if p.exists():
123
+ print(f"{p} already exists; not overwriting", file=sys.stderr)
124
+ return 2
125
+ p.write_text(EXAMPLE, encoding="utf-8")
126
+ print(f"wrote example config to {p}")
127
+ return 0
128
+ today = date.fromisoformat(args.today) if args.today else None
129
+ outcomes = run_watch(args.config, today=today, force=args.force, progress=_say)
130
+ failed = 0
131
+ for o in outcomes:
132
+ extra = ""
133
+ if o.delta:
134
+ extra = " (vs previous run: " + ", ".join(f"{k} {v:+.1f} ha" for k, v in o.delta.items() if v) + ")"
135
+ print(f"[{o.status}] {o.name}: {o.message}{extra}")
136
+ failed += o.status == "failed"
137
+ return 3 if failed else 0
138
+
139
+
140
+ def cmd_ui(args) -> int:
141
+ from .ui import serve
142
+
143
+ serve(port=args.port, workdir=Path(args.workdir), open_browser=not args.no_browser)
144
+ return 0
145
+
146
+
147
+ def build_parser() -> argparse.ArgumentParser:
148
+ p = argparse.ArgumentParser(prog="earthdiff", description="What changed here? Explainable change reports from free Sentinel-2 imagery.")
149
+ p.add_argument("--version", action="version", version=f"earthdiff {__version__}")
150
+ sub = p.add_subparsers(dest="cmd", required=True)
151
+
152
+ r = sub.add_parser("run", help="analyse one area and write a report folder")
153
+ _add_common(r)
154
+ r.add_argument("--out", default="report", help="output folder (default ./report)")
155
+ r.add_argument("--name", help="title shown in the report")
156
+ r.add_argument("--force", action="store_true", help="run even when the cloud-free fraction is very low")
157
+ r.set_defaults(fn=cmd_run)
158
+
159
+ pl = sub.add_parser("plan", help="dry run: only search scenes, download nothing")
160
+ _add_common(pl)
161
+ pl.set_defaults(fn=cmd_plan)
162
+
163
+ w = sub.add_parser("watch", help="re-run the areas of a TOML config for the last full month (schedule it yourself)")
164
+ w.add_argument("config", help="path of the TOML file")
165
+ w.add_argument("--init", action="store_true", help="write an example config to the given path and exit")
166
+ w.add_argument("--today", help="pretend today is YYYY-MM-DD (testing/backfill)")
167
+ w.add_argument("--force", action="store_true", help="redo periods that already have results")
168
+ w.set_defaults(fn=cmd_watch)
169
+
170
+ sub.add_parser("mcp", help="MCP server (stdio) for AI assistants; same as `earthdiff-mcp` (needs: pip install \"earthdiff[mcp]\")")
171
+
172
+ u = sub.add_parser("ui", help="local web UI (127.0.0.1 only): draw an area, pick dates, run")
173
+ u.add_argument("--port", type=int, default=8765)
174
+ u.add_argument("--workdir", default="earthdiff-runs", help="where UI runs are stored")
175
+ u.add_argument("--no-browser", action="store_true")
176
+ u.set_defaults(fn=cmd_ui)
177
+ return p
178
+
179
+
180
+ def main(argv: list[str] | None = None) -> int:
181
+ raw = sys.argv[1:] if argv is None else argv
182
+ if raw and raw[0] == "mcp": # `earthdiff mcp [options]` is the same server as `earthdiff-mcp`
183
+ from .mcp_server import main as mcp_main
184
+
185
+ mcp_main(raw[1:])
186
+ return 0
187
+ args = build_parser().parse_args(argv)
188
+ try:
189
+ return args.fn(args)
190
+ except (AOIError, ValueError) as exc:
191
+ print(f"error: {exc}", file=sys.stderr)
192
+ return 2
193
+ except (InsufficientDataError, STACError) as exc:
194
+ print(f"error: {exc}", file=sys.stderr)
195
+ return 3
196
+ except KeyboardInterrupt:
197
+ print("interrupted", file=sys.stderr)
198
+ return 130
199
+
200
+
201
+ if __name__ == "__main__":
202
+ raise SystemExit(main())
earthdiff/composite.py ADDED
@@ -0,0 +1,169 @@
1
+ """Cloud masking (SCL) and per-period median composites."""
2
+ from __future__ import annotations
3
+
4
+ import gc
5
+ import warnings
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import dataclass, field
8
+ from typing import Callable
9
+
10
+ import numpy as np
11
+ from scipy.ndimage import binary_dilation
12
+
13
+ from .aoi import Grid
14
+ from .reader import cog_reader
15
+ from .stac import Scene
16
+
17
+ BANDS = ("blue", "green", "red", "nir", "swir16")
18
+ BANDS_WITH_B12 = BANDS + ("swir22",) # swir22 (B12) feeds NBR / dNBR; it is optional: an unreadable B12 never blocks a run
19
+ OPTIONAL_BANDS = ("swir22",)
20
+
21
+ # Sentinel-2 L2A Scene Classification Layer values
22
+ SCL_NODATA, SCL_SATURATED, SCL_DARK, SCL_CLOUD_SHADOW = 0, 1, 2, 3
23
+ SCL_CLOUD_MEDIUM, SCL_CLOUD_HIGH, SCL_CIRRUS, SCL_SNOW = 8, 9, 10, 11
24
+ # Rejected by default: no data, saturated/defective, cloud shadow, clouds (all), cirrus, snow/ice.
25
+ # Kept: dark area pixels (2), vegetation (4), not-vegetated (5), water (6), unclassified (7).
26
+ DEFAULT_INVALID_SCL = (0, 1, 3, 8, 9, 10, 11)
27
+ _CLOUDY = (3, 8, 9, 10)
28
+
29
+ Reader = Callable[[Scene, str, Grid], np.ndarray]
30
+ Progress = Callable[[str], None]
31
+
32
+
33
+ def valid_from_scl(
34
+ scl: np.ndarray, res: float = 20.0, invalid: tuple[int, ...] = DEFAULT_INVALID_SCL, buffer_m: float = 30.0
35
+ ) -> np.ndarray:
36
+ """True where the pixel is usable. Cloud/shadow pixels are dilated by ``buffer_m`` metres."""
37
+ bad = np.isin(scl, invalid)
38
+ iters = int(round(buffer_m / res))
39
+ if iters > 0:
40
+ cloudy = np.isin(scl, _CLOUDY)
41
+ if cloudy.any():
42
+ bad |= binary_dilation(cloudy, iterations=iters)
43
+ return ~bad
44
+
45
+
46
+ def to_reflectance(dn: np.ndarray, scale: float, offset: float) -> np.ndarray:
47
+ refl = dn.astype(np.float32) * np.float32(scale) + np.float32(offset)
48
+ np.clip(refl, 0.0, 2.0, out=refl)
49
+ return refl
50
+
51
+
52
+ @dataclass
53
+ class Composite:
54
+ bands: dict[str, np.ndarray] # float32 reflectance; NaN where no valid observation
55
+ count: np.ndarray # uint8, number of valid observations per pixel
56
+ scenes: list[Scene]
57
+ candidates: int
58
+ scene_valid_fraction: dict[str, float] = field(default_factory=dict)
59
+ # per-scene true-colour bands, subsampled by ``frame_step`` (float16, NaN = cloud / no data), for the report timeline
60
+ frames: dict[str, np.ndarray] = field(default_factory=dict)
61
+ frame_step: int = 1
62
+
63
+
64
+ def strip_median(stack: np.ndarray, max_elems: int = 12_000_000) -> np.ndarray:
65
+ """nanmedian over axis 0, in row strips to bound temporary memory."""
66
+ n, h, w = stack.shape
67
+ out = np.empty((h, w), dtype=np.float32)
68
+ rows = max(1, max_elems // max(1, n * w))
69
+ with warnings.catch_warnings():
70
+ warnings.simplefilter("ignore", RuntimeWarning)
71
+ for r0 in range(0, h, rows):
72
+ out[r0 : r0 + rows] = np.nanmedian(stack[:, r0 : r0 + rows], axis=0)
73
+ return out
74
+
75
+
76
+ def build_composite(
77
+ candidates: list[Scene],
78
+ grid: Grid,
79
+ mask: np.ndarray,
80
+ max_scenes: int = 8,
81
+ min_scene_fraction: float = 0.02,
82
+ reader: Reader = cog_reader,
83
+ workers: int = 6,
84
+ progress: Progress | None = None,
85
+ label: str = "",
86
+ bands: tuple[str, ...] = BANDS,
87
+ frame_max_dim: int = 0,
88
+ ) -> Composite:
89
+ """Select the scenes that really cover the AOI cloud-free, then median-composite them."""
90
+ say = progress or (lambda m: None)
91
+ h, w = grid.shape
92
+ n_aoi = max(1, int(mask.sum()))
93
+
94
+ # 1) read SCL of every candidate (cheap, 20 m) and rank by cloud-free AOI coverage
95
+ say(f"{label}: checking cloud cover of {len(candidates)} candidate scenes")
96
+ def read_scl(s: Scene):
97
+ try:
98
+ return reader(s, "scl", grid)
99
+ except Exception as exc: # unreadable scene: skip it instead of failing the whole run
100
+ say(f"{label}: skipping {s.id} ({str(exc)[:80]})")
101
+ return None
102
+
103
+ with ThreadPoolExecutor(max_workers=workers) as ex:
104
+ scls = list(ex.map(read_scl, candidates))
105
+ ranked = []
106
+ for s, scl in zip(candidates, scls):
107
+ if scl is None:
108
+ continue
109
+ valid = valid_from_scl(scl, grid.res) & mask
110
+ ranked.append((float(valid.sum()) / n_aoi, s, valid))
111
+ del scls
112
+ ranked.sort(key=lambda t: (-t[0], t[1].date))
113
+ chosen = [t for t in ranked if t[0] >= min_scene_fraction][:max_scenes]
114
+ chosen.sort(key=lambda t: t[1].date)
115
+ fractions = {t[1].id: round(t[0], 4) for t in ranked}
116
+ if not chosen:
117
+ empty = {b: np.full((h, w), np.nan, dtype=np.float32) for b in bands}
118
+ return Composite(empty, np.zeros((h, w), dtype=np.uint8), [], len(candidates), fractions)
119
+
120
+ scenes = [t[1] for t in chosen]
121
+ valid_stack = np.stack([t[2] for t in chosen]) # (n,h,w) bool
122
+ del ranked, chosen
123
+ gc.collect()
124
+
125
+ # 2) one band at a time: read -> reflectance -> mask -> strip-wise median
126
+ out: dict[str, np.ndarray] = {}
127
+ # small copies of each scene's red/green/blue (a few MB) let the report play the scenes one by one
128
+ step = max(1, -(-max(h, w) // frame_max_dim)) if frame_max_dim else 0
129
+ frames: dict[str, np.ndarray] = {}
130
+ for band in bands:
131
+ say(f"{label}: downloading + compositing band '{band}' from {len(scenes)} scenes")
132
+ stack = np.full((len(scenes), h, w), np.nan, dtype=np.float32)
133
+
134
+ def work(i: int, band=band, stack=stack):
135
+ s = scenes[i]
136
+ try:
137
+ dn = reader(s, band, grid)
138
+ except Exception:
139
+ if band in OPTIONAL_BANDS: # leave this scene out of the optional band, keep the run going
140
+ return
141
+ raise
142
+ scale, offset = s.radiometry.get(band, (1e-4, 0.0))
143
+ refl = to_reflectance(dn, scale, offset)
144
+ refl[(dn == 0) | ~valid_stack[i]] = np.nan
145
+ stack[i] = refl
146
+
147
+ with ThreadPoolExecutor(max_workers=min(workers, len(scenes))) as ex:
148
+ list(ex.map(work, range(len(scenes))))
149
+ if step and band in ("red", "green", "blue"):
150
+ frames[band] = stack[:, ::step, ::step].astype(np.float16)
151
+ out[band] = strip_median(stack)
152
+ del stack
153
+ gc.collect()
154
+
155
+ count = valid_stack.sum(axis=0).astype(np.uint8)
156
+ del valid_stack
157
+ # a pixel is only usable if all bands have a value
158
+ for b in [b for b in bands if b in OPTIONAL_BANDS]:
159
+ if not np.isfinite(out[b]).any(): # B12 unreadable everywhere: drop the band instead of the whole composite
160
+ del out[b]
161
+ required = [b for b in bands if b not in OPTIONAL_BANDS]
162
+ ok = np.ones((h, w), dtype=bool)
163
+ for b in required:
164
+ ok &= np.isfinite(out[b])
165
+ for b in out:
166
+ if b in required:
167
+ out[b][~ok] = np.nan
168
+ count[~ok] = 0
169
+ return Composite(out, count, scenes, len(candidates), fractions, frames, step or 1)
earthdiff/geocode.py ADDED
@@ -0,0 +1,186 @@
1
+ """Place-name search through OSM Nominatim, following the public usage policy
2
+ (https://operations.osmfoundation.org/policies/nominatim/): identifying User-Agent, at most one request per second,
3
+ cached results, no bulk use, attribution. Results are (c) OpenStreetMap contributors (ODbL)."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import math
8
+ import os
9
+ import threading
10
+ import time
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Callable
14
+
15
+ import httpx
16
+
17
+ from . import __version__
18
+ from .safety import NOMINATIM_HOST, clean_text
19
+
20
+ NOMINATIM_URL = f"https://{NOMINATIM_HOST}/search"
21
+ ATTRIBUTION = "Place search: (c) OpenStreetMap contributors (ODbL), via Nominatim"
22
+ REPO_URL = "https://github.com/mveyseldelibas/earthdiff"
23
+
24
+
25
+ class GeocodeError(RuntimeError):
26
+ """The place search failed (network, rate limit, bad response). The message is safe to show to the model."""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Place:
31
+ name: str
32
+ display_name: str
33
+ kind: str # "class/type" as reported by OSM, e.g. "natural/water"
34
+ lon: float
35
+ lat: float
36
+ bbox: tuple[float, float, float, float] # west, south, east, north
37
+ importance: float
38
+ osm: str # "relation/123"
39
+
40
+
41
+ def user_agent(contact: str | None = None) -> str:
42
+ ua = f"earthdiff-mcp/{__version__} (+{REPO_URL})"
43
+ if contact:
44
+ ua += f" contact:{clean_text(contact, 80)}"
45
+ return ua
46
+
47
+
48
+ def _place_from(raw: dict[str, Any]) -> Place | None:
49
+ try:
50
+ south, north, west, east = (float(x) for x in raw["boundingbox"])
51
+ lon, lat = float(raw["lon"]), float(raw["lat"])
52
+ except (KeyError, TypeError, ValueError):
53
+ return None
54
+ vals = (south, north, west, east, lon, lat)
55
+ if not all(math.isfinite(v) for v in vals) or not (-90 <= south <= north <= 90 and -180 <= west <= east <= 180):
56
+ return None
57
+ display = clean_text(raw.get("display_name", ""), 240)
58
+ name = clean_text(raw.get("name") or display.split(",")[0], 120)
59
+ return Place(
60
+ name=name,
61
+ display_name=display,
62
+ kind=clean_text(f"{raw.get('category') or raw.get('class', '')}/{raw.get('type', '')}", 60),
63
+ lon=lon,
64
+ lat=lat,
65
+ bbox=(west, south, east, north),
66
+ importance=float(raw.get("importance") or 0.0),
67
+ osm=clean_text(f"{raw.get('osm_type', '')}/{raw.get('osm_id', '')}", 40),
68
+ )
69
+
70
+
71
+ class Nominatim:
72
+ """Rate-limited, caching Nominatim client. One instance per server process."""
73
+
74
+ def __init__(
75
+ self,
76
+ contact: str | None = None,
77
+ cache_path: Path | None = None,
78
+ client: httpx.Client | None = None,
79
+ min_interval_s: float = 1.1,
80
+ max_per_hour: int = 60,
81
+ ttl_s: float = 30 * 86400,
82
+ timeout_s: float = 15.0,
83
+ clock: Callable[[], float] = time.monotonic,
84
+ sleep: Callable[[float], None] = time.sleep,
85
+ now: Callable[[], float] = time.time,
86
+ ):
87
+ self._client = client or httpx.Client(
88
+ headers={"User-Agent": user_agent(contact), "Accept": "application/json"},
89
+ timeout=timeout_s,
90
+ follow_redirects=False,
91
+ )
92
+ self._cache_path = cache_path
93
+ self._min_interval = min_interval_s
94
+ self._max_per_hour = max_per_hour
95
+ self._ttl = ttl_s
96
+ self._clock, self._sleep, self._now = clock, sleep, now
97
+ self._lock = threading.Lock()
98
+ self._last = -1e9
99
+ self._sent: list[float] = []
100
+ self._cache: dict[str, dict[str, Any]] = self._load()
101
+
102
+ # -- cache
103
+ def _load(self) -> dict[str, dict[str, Any]]:
104
+ if not self._cache_path:
105
+ return {}
106
+ try:
107
+ data = json.loads(Path(self._cache_path).read_text(encoding="utf-8"))
108
+ return data if isinstance(data, dict) else {}
109
+ except (OSError, ValueError):
110
+ return {}
111
+
112
+ def _save(self) -> None:
113
+ if not self._cache_path:
114
+ return
115
+ try:
116
+ path = Path(self._cache_path)
117
+ path.parent.mkdir(parents=True, exist_ok=True)
118
+ if len(self._cache) > 500: # keep the newest entries only
119
+ keep = sorted(self._cache.items(), key=lambda kv: kv[1].get("t", 0), reverse=True)[:400]
120
+ self._cache = dict(keep)
121
+ tmp = path.with_suffix(".tmp")
122
+ tmp.write_text(json.dumps(self._cache, ensure_ascii=False), encoding="utf-8")
123
+ os.replace(tmp, path)
124
+ except OSError:
125
+ pass # a cache that cannot be written is not an error
126
+
127
+ @staticmethod
128
+ def _key(q: str, countrycodes: str | None, language: str | None, limit: int) -> str:
129
+ return json.dumps([q.casefold(), countrycodes or "", (language or "").casefold(), limit], ensure_ascii=False)
130
+
131
+ # -- search
132
+ def search(self, q: str, countrycodes: str | None = None, language: str | None = None, limit: int = 5) -> tuple[list[Place], bool]:
133
+ """Return ``(places, from_cache)``. Raises :class:`GeocodeError`."""
134
+ q = clean_text(q, 200)
135
+ if len(q) < 2:
136
+ raise GeocodeError("the place query is too short")
137
+ key = self._key(q, countrycodes, language, limit)
138
+ with self._lock:
139
+ hit = self._cache.get(key)
140
+ if hit and self._now() - float(hit.get("t", 0)) < self._ttl:
141
+ return [p for p in (_place_from(r) for r in hit["data"]) if p], True
142
+
143
+ t = self._clock()
144
+ self._sent = [x for x in self._sent if t - x < 3600]
145
+ if len(self._sent) >= self._max_per_hour:
146
+ raise GeocodeError(
147
+ f"place search limit reached ({self._max_per_hour} requests per hour per server, to respect the Nominatim usage policy); "
148
+ "give a bounding box instead or try again later"
149
+ )
150
+ wait = self._min_interval - (t - self._last)
151
+ if wait > 0:
152
+ self._sleep(wait) # <= 1 request per second, process wide
153
+ params: dict[str, Any] = {"q": q, "format": "jsonv2", "limit": limit, "addressdetails": 0, "dedupe": 1}
154
+ if countrycodes:
155
+ params["countrycodes"] = countrycodes
156
+ if language:
157
+ params["accept-language"] = language
158
+ self._last = self._clock()
159
+ self._sent.append(self._last)
160
+ try:
161
+ resp = self._client.get(NOMINATIM_URL, params=params)
162
+ except httpx.HTTPError as exc:
163
+ raise GeocodeError(f"place search failed ({type(exc).__name__}); try again or give a bounding box") from None
164
+ if resp.status_code in (403, 429, 503):
165
+ raise GeocodeError(
166
+ f"the Nominatim service refused or throttled the request (HTTP {resp.status_code}); wait a minute or give a bounding box"
167
+ )
168
+ if resp.status_code != 200:
169
+ raise GeocodeError(f"the Nominatim service answered HTTP {resp.status_code}")
170
+ try:
171
+ data = resp.json()
172
+ except ValueError:
173
+ raise GeocodeError("the Nominatim service returned an unreadable answer") from None
174
+ if not isinstance(data, list):
175
+ raise GeocodeError("the Nominatim service returned an unexpected answer")
176
+ slim = [
177
+ {k: r.get(k) for k in ("name", "display_name", "category", "class", "type", "lon", "lat", "boundingbox", "importance", "osm_type", "osm_id")}
178
+ for r in data[:limit]
179
+ if isinstance(r, dict)
180
+ ]
181
+ self._cache[key] = {"t": self._now(), "data": slim}
182
+ self._save()
183
+ return [p for p in (_place_from(r) for r in slim) if p], False
184
+
185
+ def close(self) -> None:
186
+ self._client.close()
earthdiff/history.py ADDED
@@ -0,0 +1,86 @@
1
+ """When did a vegetation loss happen, and what was it? Using the in-between years.
2
+
3
+ Comparing only the two ends hides events that happened in between and have partly recovered since: three years after a
4
+ wildfire the burn scar is green again in places, so the before/after dNBR is weak and the loss looks "unclear". When the
5
+ windows are years apart earthdiff already reads one pass per in-between year (``yearly.py``); here each vegetation-loss
6
+ polygon gets a short series of NBR, NDVI and visible brightness (before, each in-between year, after), the largest
7
+ one-step NBR drop is taken as the event, and the cause hint is judged on that step (same rules as ``cause.classify_cause``).
8
+
9
+ The result is stored on each polygon: ``event_after`` (date of the first pass that shows the loss), ``event_before``
10
+ (the pass just before), ``event_dnbr`` (NBR drop of that step) and, when the step says so, a new ``likely_cause``.
11
+ It is a hint like the rest: one pass per year, at the coarse frame resolution."""
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+
18
+ from .cause import LABELS, _bincount_mean, _summary, classify_cause
19
+
20
+
21
+ def composite_point(kind: str, date: str, comp, step: int) -> dict[str, Any] | None:
22
+ """A series point from a period composite, subsampled to the frame grid."""
23
+ b = comp.bands
24
+ if "swir22" not in b or "nir" not in b:
25
+ return None
26
+ return point(kind, date, {k: b[k][::step, ::step] for k in ("red", "green", "blue", "nir", "swir22")})
27
+
28
+
29
+ def point(kind: str, date: str, bands: dict[str, np.ndarray]) -> dict[str, Any]:
30
+ nir, sw2, red = bands["nir"], bands["swir22"], bands["red"]
31
+ with np.errstate(invalid="ignore", divide="ignore"):
32
+ nbr = (nir - sw2) / (nir + sw2)
33
+ ndvi = (nir - red) / (nir + red)
34
+ vis = (bands["red"] + bands["green"] + bands["blue"]) / 3.0
35
+ return {"kind": kind, "date": date, "nbr": nbr.astype(np.float32), "ndvi": ndvi.astype(np.float32), "vis": vis.astype(np.float32)}
36
+
37
+
38
+ def refine_with_history(result, series: list[dict[str, Any]], step: int) -> dict[str, Any] | None:
39
+ """Re-judge vegetation-loss polygons on their largest one-step NBR drop. Changes features in place; returns new cause totals."""
40
+ series = [s for s in series if s is not None]
41
+ if len(series) < 3 or not any(s["kind"] == "between" for s in series):
42
+ return None
43
+ labels = result.labels[::step, ::step]
44
+ h, w = labels.shape
45
+ n = int(result.labels.max())
46
+ if n == 0:
47
+ return None
48
+ flat = labels.ravel()
49
+ sel = flat > 0
50
+ lab = flat[sel]
51
+ means = []
52
+ for s in series:
53
+ means.append({k: _bincount_mean(lab, s[k][:h, :w].ravel()[sel], n) for k in ("nbr", "ndvi", "vis")})
54
+ changed = 0
55
+ totals: dict[str, dict[str, float]] = {k: {"polygons": 0, "area_ha": 0.0} for k in LABELS}
56
+ for f, lid in zip(result.features, result.label_ids):
57
+ p = f["properties"]
58
+ if p["class"] != "vegetation_loss":
59
+ continue
60
+ nbr = [float(m["nbr"][lid]) for m in means]
61
+ ok = [i for i, v in enumerate(nbr) if np.isfinite(v)]
62
+ if len(ok) >= 3:
63
+ steps = [(nbr[a] - nbr[b], a, b) for a, b in zip(ok, ok[1:])]
64
+ drop, a, b = max(steps)
65
+ if drop > 0.05:
66
+ p["event_before"], p["event_after"] = series[a]["date"], series[b]["date"]
67
+ p["event_dnbr"] = round(drop, 3)
68
+ end = nbr[ok[0]] - nbr[ok[-1]]
69
+ # only re-judge when the step is clearly larger than the end-to-end difference (the scar has faded since)
70
+ if drop >= max(end + 0.05, 1.25 * end):
71
+ shape = result.shape_stats.get(lid, {})
72
+ m = {"dnbr": drop, "vis_after": float(means[b]["vis"][lid]), "vis_delta": float(means[b]["vis"][lid] - means[a]["vis"][lid]),
73
+ "rectangularity": float(shape.get("rectangularity", p.get("rectangularity", 0.0))), "area_ha": float(p["area_ha"])}
74
+ label, basis = classify_cause(m)
75
+ if label != p.get("likely_cause"):
76
+ changed += 1
77
+ p["likely_cause"] = label
78
+ p["likely_cause_basis"] = f"largest one-step drop between {series[a]['date']} and {series[b]['date']}; " + "; ".join(basis)
79
+ p["likely_cause_from"] = "in-between years"
80
+ lc = p.get("likely_cause")
81
+ if lc in totals:
82
+ totals[lc]["polygons"] += 1
83
+ totals[lc]["area_ha"] = round(totals[lc]["area_ha"] + p["area_ha"], 2)
84
+ out = _summary(totals)
85
+ out["from_in_between_years"] = {"series_points": [s["date"] for s in series], "relabelled_polygons": changed}
86
+ return out