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/terrain.py ADDED
@@ -0,0 +1,120 @@
1
+ """Terrain for the offline 3D view and the relief basemap of the report.
2
+
3
+ Elevation comes from the Copernicus DEM GLO-30 (30 m, free, public COGs on AWS Open Data). Only the area around the
4
+ analysis is read, decimated from the COG overviews, onto a small Web Mercator grid; the report embeds the result, so
5
+ opening the report needs no internet. Sea areas have no DEM tile: they get 0 m."""
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import math
10
+ from typing import Any, Callable
11
+
12
+ import numpy as np
13
+ import rasterio
14
+ from pyproj import Transformer
15
+ from rasterio.enums import Resampling
16
+ from rasterio.transform import from_bounds
17
+ from rasterio.vrt import WarpedVRT
18
+
19
+ from .reader import GDAL_ENV
20
+ from .render import webp_bytes
21
+ from .report import data_url
22
+
23
+ COP_DEM_HOST = "copernicus-dem-30m.s3.amazonaws.com"
24
+ DEM_ATTRIBUTION = ("Copernicus DEM GLO-30 © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018, "
25
+ "provided under COPERNICUS by the European Union and ESA")
26
+ PAD = 0.35 # the terrain reaches this fraction beyond the image on every side, for context around the area
27
+ RELIEF_MAX_DIM = 640 # relief basemap (2D) pixels on the longest side
28
+ MESH_MAX_DIM = 200 # 3D mesh vertices on the longest side (200 x 200 = 40,000 vertices, fits 16-bit indices)
29
+
30
+ DemReader = Callable[[str, Any, int, int], "np.ndarray | None"]
31
+
32
+
33
+ def cop_dem_url(lat: int, lon: int) -> str:
34
+ """URL of the 1x1 degree GLO-30 tile whose south-west corner is (lat, lon)."""
35
+ name = f"Copernicus_DSM_COG_10_{'N' if lat >= 0 else 'S'}{abs(lat):02d}_00_{'E' if lon >= 0 else 'W'}{abs(lon):03d}_00_DEM"
36
+ return f"https://{COP_DEM_HOST}/{name}/{name}.tif"
37
+
38
+
39
+ def tiles_for(south: float, west: float, north: float, east: float) -> list[tuple[int, int]]:
40
+ return [(la, lo) for la in range(math.floor(south), math.floor(north - 1e-9) + 1) for lo in range(math.floor(west), math.floor(east - 1e-9) + 1)]
41
+
42
+
43
+ def read_dem_window(url: str, transform, width: int, height: int) -> np.ndarray | None:
44
+ """Default reader: one DEM tile warped onto the Web Mercator target grid (NaN outside the tile); None if the tile does not exist."""
45
+ try:
46
+ with rasterio.Env(**GDAL_ENV):
47
+ with rasterio.open(url) as src:
48
+ with WarpedVRT(src, crs="EPSG:3857", transform=transform, width=width, height=height,
49
+ resampling=Resampling.bilinear, src_nodata=src.nodata, nodata=np.nan, dtype="float32") as vrt:
50
+ return vrt.read(1)
51
+ except Exception: # sea tiles do not exist (404); a network error only costs the 3D view
52
+ return None
53
+
54
+
55
+ def _shape(xmin: float, ymin: float, xmax: float, ymax: float, max_dim: int) -> tuple[int, int]:
56
+ k = max_dim / max(xmax - xmin, ymax - ymin)
57
+ return max(8, round((xmax - xmin) * k)), max(8, round((ymax - ymin) * k))
58
+
59
+
60
+ def hillshade(dem: np.ndarray, px_m: float, azimuth: float = 315.0, altitude: float = 45.0) -> np.ndarray:
61
+ """Classic hillshade (0..1) of ``dem`` with square pixels of ``px_m`` metres."""
62
+ # rows run north to south, so the northward slope is -d/drow; the light comes from ``azimuth`` (clockwise from north)
63
+ gy, gx = np.gradient(dem, px_m)
64
+ dzdx, dzdn = gx, -gy
65
+ az, alt = math.radians(azimuth), math.radians(altitude)
66
+ lx, ly, lz = math.sin(az) * math.cos(alt), math.cos(az) * math.cos(alt), math.sin(alt)
67
+ return np.clip((-dzdx * lx - dzdn * ly + lz) / np.sqrt(dzdx ** 2 + dzdn ** 2 + 1), 0, 1)
68
+
69
+
70
+ def build_terrain(image_bounds: list[list[float]], reader: DemReader = read_dem_window) -> dict[str, Any] | None:
71
+ """Terrain around ``image_bounds`` ([[south, west], [north, east]]): a relief PNG for the 2D basemap and a quantized
72
+ elevation grid for the 3D mesh. None when no DEM tile could be read (the report then has no 3D view)."""
73
+ (s, w), (n, e) = image_bounds
74
+ to_m = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
75
+ to_ll = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True)
76
+ x0, y0 = to_m.transform(w, s)
77
+ x1, y1 = to_m.transform(e, n)
78
+ dx, dy = (x1 - x0) * PAD, (y1 - y0) * PAD
79
+ x0, y0, x1, y1 = x0 - dx, y0 - dy, x1 + dx, y1 + dy
80
+ width, height = _shape(x0, y0, x1, y1, RELIEF_MAX_DIM)
81
+ transform = from_bounds(x0, y0, x1, y1, width, height)
82
+ tw, ts = to_ll.transform(x0, y0)
83
+ te, tn = to_ll.transform(x1, y1)
84
+
85
+ dem = np.full((height, width), np.nan, dtype=np.float32)
86
+ got = 0
87
+ for la, lo in tiles_for(ts, tw, tn, te):
88
+ arr = reader(cop_dem_url(la, lo), transform, width, height)
89
+ if arr is None:
90
+ continue
91
+ got += 1
92
+ ok = np.isfinite(arr)
93
+ dem[ok] = arr[ok]
94
+ if got == 0:
95
+ return None
96
+ dem = np.nan_to_num(dem, nan=0.0) # no tile = sea
97
+
98
+ lat0 = math.radians((ts + tn) / 2)
99
+ px_true = (x1 - x0) / width * math.cos(lat0) # mercator metres shrink by cos(lat) on the ground
100
+ shade = hillshade(dem, px_true)
101
+ lo_e, hi_e = float(dem.min()), float(dem.max())
102
+ tint = np.clip((dem - lo_e) / max(hi_e - lo_e, 1.0), 0, 1)
103
+ rgba = np.zeros((height, width, 4), dtype=np.uint8)
104
+ base = 0.35 + 0.65 * shade
105
+ rgba[..., 0] = np.clip((180 + 40 * tint) * base, 0, 255)
106
+ rgba[..., 1] = np.clip((190 + 25 * tint) * base, 0, 255)
107
+ rgba[..., 2] = np.clip((170 + 10 * tint) * base, 0, 255)
108
+ rgba[..., 3] = 255
109
+
110
+ step = max(1, math.ceil(max(width, height) / MESH_MAX_DIM))
111
+ mesh = dem[::step, ::step]
112
+ q = np.round((mesh - lo_e) / max(hi_e - lo_e, 1e-6) * 65535).astype("<u2") if hi_e > lo_e else np.zeros(mesh.shape, "<u2")
113
+ return {
114
+ "bounds": [[ts, tw], [tn, te]],
115
+ "relief": data_url(webp_bytes(rgba, quality=82), "image/webp"),
116
+ "w": int(mesh.shape[1]), "h": int(mesh.shape[0]),
117
+ "min": round(lo_e, 1), "max": round(hi_e, 1),
118
+ "z": base64.b64encode(q.tobytes()).decode("ascii"),
119
+ "attribution": DEM_ATTRIBUTION,
120
+ }
earthdiff/ui.py ADDED
@@ -0,0 +1,227 @@
1
+ """Tiny local web UI. Standard library only; binds to 127.0.0.1 and nothing else."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import mimetypes
6
+ import threading
7
+ import time
8
+ import uuid
9
+ import webbrowser
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from importlib import resources
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from . import __version__
16
+ from .aoi import MAX_AREA_KM2, AOIError, geometry_from_geojson
17
+ from .periods import parse_period
18
+ from .pipeline import InsufficientDataError, RunOptions, run_analysis
19
+ from .stac import STACError
20
+
21
+ HOST = "127.0.0.1"
22
+ MAX_BODY = 2_000_000
23
+ # Largest area one UI run may cover. Same as the MCP server's hard maximum; the pipeline picks a coarser grid
24
+ # (20/30/60 m) for large areas so the window stays under ~4 M pixels. The page reads this value, so both checks agree.
25
+ UI_MAX_AREA_KM2 = MAX_AREA_KM2
26
+ class Job:
27
+ def __init__(self, name: str):
28
+ self.id = uuid.uuid4().hex[:10]
29
+ self.name = name
30
+ self.status = "running" # running | done | error
31
+ self.log: list[str] = []
32
+ self.error: str | None = None
33
+ self.summary: dict[str, Any] | None = None
34
+ self.started = time.time()
35
+
36
+ def public(self) -> dict[str, Any]:
37
+ return {
38
+ "id": self.id, "name": self.name, "status": self.status, "log": self.log[-60:], "error": self.error,
39
+ "summary": self.summary, "report_url": f"/runs/{self.id}/report.html" if self.status == "done" else None,
40
+ "elapsed_s": round(time.time() - self.started),
41
+ }
42
+
43
+
44
+ class State:
45
+ def __init__(self, workdir: Path):
46
+ self.workdir = workdir
47
+ self.jobs: dict[str, Job] = {}
48
+ self.lock = threading.Lock()
49
+
50
+ def running(self) -> bool:
51
+ return any(j.status == "running" for j in self.jobs.values())
52
+
53
+
54
+ def _read_pkg(rel: str) -> bytes:
55
+ return resources.files("earthdiff").joinpath(rel).read_bytes()
56
+
57
+
58
+ def page() -> bytes:
59
+ """The UI page with the server's area limit filled in."""
60
+ return _read_pkg("static/ui.html").replace(b"__MAX_AREA_KM2__", str(int(UI_MAX_AREA_KM2)).encode())
61
+
62
+
63
+ # ---- drawing page: a relief basemap for the drawing map
64
+ # Relief tiles are shaded from the open AWS Terrain Tiles (Terrarium encoding); the server does it because those tiles
65
+ # carry no CORS header, so the page could not read their pixels itself.
66
+ RELIEF_SRC = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
67
+ RELIEF_MAX_ZOOM = 15
68
+ _relief_cache: dict[tuple[int, int, int], bytes] = {}
69
+
70
+
71
+ def relief_tile(z: int, x: int, y: int, fetch=None) -> bytes | None:
72
+ """A grey hillshade PNG for web-map tile z/x/y (256 px), or None when the elevation tile cannot be read."""
73
+ import io
74
+ import math
75
+ import urllib.request
76
+
77
+ import numpy as np
78
+ from PIL import Image
79
+
80
+ from .terrain import hillshade
81
+
82
+ if not (0 <= z <= RELIEF_MAX_ZOOM and 0 <= x < 2 ** z and 0 <= y < 2 ** z):
83
+ return None
84
+ key = (z, x, y)
85
+ if key in _relief_cache:
86
+ return _relief_cache[key]
87
+ url = RELIEF_SRC.format(z=z, x=x, y=y)
88
+ try:
89
+ if fetch:
90
+ raw = fetch(url)
91
+ else:
92
+ req = urllib.request.Request(url, headers={"User-Agent": f"earthdiff/{__version__}"})
93
+ with urllib.request.urlopen(req, timeout=20) as r:
94
+ raw = r.read()
95
+ rgb = np.asarray(Image.open(io.BytesIO(raw)).convert("RGB"), dtype=np.float64)
96
+ except Exception:
97
+ return None
98
+ dem = rgb[..., 0] * 256 + rgb[..., 1] + rgb[..., 2] / 256 - 32768
99
+ lat = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 0.5) / 2 ** z))))
100
+ px_m = 40075016.686 * math.cos(math.radians(lat)) / (256 * 2 ** z)
101
+ exag = 1 + max(0, 13 - z) * 0.6 # small-scale tiles need taller relief to show it at all
102
+ shade = hillshade(dem * exag, max(px_m, 1.0))
103
+ grey = (60 + shade * 195).astype(np.uint8)
104
+ grey[dem <= 0] = 205 # sea and flat lowland at sea level: plain light grey
105
+ buf = io.BytesIO()
106
+ Image.fromarray(grey, "L").save(buf, "PNG", optimize=True)
107
+ if len(_relief_cache) > 1024:
108
+ _relief_cache.clear()
109
+ _relief_cache[key] = buf.getvalue()
110
+ return _relief_cache[key]
111
+
112
+
113
+ def _execute(state: State, job: Job, payload: dict[str, Any]) -> None:
114
+ try:
115
+ geom = geometry_from_geojson(payload["geometry"])
116
+ after = parse_period(str(payload["after"]))
117
+ before = parse_period(str(payload["before"])) if payload.get("before") else after.shift_years(-1)
118
+ mmu = float(payload.get("mmu_ha") or 0.5)
119
+ if not 0.1 <= mmu <= 50:
120
+ raise ValueError("smallest change (mmu_ha) must be between 0.1 and 50 ha")
121
+ opts = RunOptions(name=job.name, sensitivity=payload.get("sensitivity", "default"), mmu_ha=mmu, max_area_km2=UI_MAX_AREA_KM2)
122
+ res = run_analysis(geom, before, after, state.workdir / job.id, opts, progress=job.log.append)
123
+ job.summary = res.summary
124
+ job.status = "done"
125
+ except (AOIError, InsufficientDataError, STACError, ValueError, KeyError) as exc:
126
+ job.error, job.status = str(exc) or exc.__class__.__name__, "error"
127
+ except Exception as exc: # keep the server alive whatever happens in a job
128
+ job.error, job.status = f"unexpected error: {exc!r}", "error"
129
+
130
+
131
+ def make_handler(state: State, port: int):
132
+ allowed_hosts = {f"127.0.0.1:{port}", f"localhost:{port}"}
133
+
134
+ class Handler(BaseHTTPRequestHandler):
135
+ server_version = f"earthdiff/{__version__}"
136
+
137
+ def log_message(self, fmt, *args): # quiet
138
+ pass
139
+
140
+ # -- helpers
141
+ def _host_ok(self) -> bool:
142
+ return self.headers.get("Host", "") in allowed_hosts # blocks DNS-rebinding
143
+
144
+ def _send(self, code: int, body: bytes, ctype: str = "application/json") -> None:
145
+ self.send_response(code)
146
+ self.send_header("Content-Type", ctype)
147
+ self.send_header("Content-Length", str(len(body)))
148
+ self.send_header("Cache-Control", "no-store")
149
+ self.send_header("X-Content-Type-Options", "nosniff")
150
+ self.end_headers()
151
+ self.wfile.write(body)
152
+
153
+ def _json(self, code: int, obj: Any) -> None:
154
+ self._send(code, json.dumps(obj).encode("utf-8"))
155
+
156
+ # -- routes
157
+ def do_GET(self):
158
+ if not self._host_ok():
159
+ return self._json(403, {"error": "bad host"})
160
+ path = self.path.split("?", 1)[0]
161
+ if path == "/":
162
+ return self._send(200, page(), "text/html; charset=utf-8")
163
+ if path in ("/vendor/leaflet.js", "/vendor/leaflet.css"):
164
+ ctype = "application/javascript" if path.endswith("js") else "text/css"
165
+ return self._send(200, _read_pkg(path.lstrip("/")), ctype)
166
+ if path.startswith("/tiles/relief/") and path.endswith(".png"):
167
+ try:
168
+ z, x, y = (int(v) for v in path[len("/tiles/relief/"):-4].split("/"))
169
+ except ValueError:
170
+ return self._json(400, {"error": "bad tile"})
171
+ png = relief_tile(z, x, y)
172
+ return self._send(200, png, "image/png") if png else self._json(404, {"error": "no elevation tile"})
173
+ if path.startswith("/api/jobs/"):
174
+ job = state.jobs.get(path.rsplit("/", 1)[1])
175
+ return self._json(200, job.public()) if job else self._json(404, {"error": "unknown job"})
176
+ if path.startswith("/runs/"):
177
+ parts = path.split("/")
178
+ if len(parts) == 4 and parts[2] in state.jobs and parts[3] and "/" not in parts[3] and ".." not in parts[3]:
179
+ f = (state.workdir / parts[2] / parts[3]).resolve()
180
+ if f.is_file() and state.workdir.resolve() in f.parents:
181
+ ctype = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
182
+ if f.suffix == ".html":
183
+ ctype = "text/html; charset=utf-8"
184
+ return self._send(200, f.read_bytes(), ctype)
185
+ self._json(404, {"error": "not found"})
186
+
187
+ def do_POST(self):
188
+ if not self._host_ok():
189
+ return self._json(403, {"error": "bad host"})
190
+ origin = self.headers.get("Origin")
191
+ if origin and origin.replace("http://", "") not in allowed_hosts:
192
+ return self._json(403, {"error": "bad origin"})
193
+ if self.path != "/api/run" or "application/json" not in self.headers.get("Content-Type", ""):
194
+ return self._json(400, {"error": "expected JSON POST /api/run"})
195
+ n = int(self.headers.get("Content-Length", "0") or 0)
196
+ if n <= 0 or n > MAX_BODY:
197
+ return self._json(400, {"error": "bad body size"})
198
+ try:
199
+ payload = json.loads(self.rfile.read(n))
200
+ assert isinstance(payload, dict) and isinstance(payload.get("geometry"), dict) and payload.get("after")
201
+ except Exception:
202
+ return self._json(400, {"error": "payload needs geometry (GeoJSON) and after (YYYY-MM)"})
203
+ with state.lock:
204
+ if state.running():
205
+ return self._json(409, {"error": "another run is in progress; wait for it to finish"})
206
+ job = Job(str(payload.get("name") or "Drawn area")[:80])
207
+ state.jobs[job.id] = job
208
+ threading.Thread(target=_execute, args=(state, job, payload), daemon=True).start()
209
+ self._json(202, {"id": job.id})
210
+
211
+ return Handler
212
+
213
+
214
+ def serve(port: int = 8765, workdir: Path = Path("earthdiff-runs"), open_browser: bool = True) -> None:
215
+ workdir.mkdir(parents=True, exist_ok=True)
216
+ state = State(workdir)
217
+ httpd = ThreadingHTTPServer((HOST, port), make_handler(state, port))
218
+ url = f"http://{HOST}:{port}/"
219
+ print(f"earthdiff UI on {url} (local only; runs stored in {workdir.resolve()}) Ctrl+C to stop")
220
+ if open_browser:
221
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
222
+ try:
223
+ httpd.serve_forever()
224
+ except KeyboardInterrupt:
225
+ pass
226
+ finally:
227
+ httpd.server_close()
@@ -0,0 +1,26 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2010-2023, Volodymyr Agafonkin
4
+ Copyright (c) 2010-2011, CloudMade
5
+ All rights reserved.
6
+
7
+ Redistribution and use in source and binary forms, with or without
8
+ modification, are permitted provided that the following conditions are met:
9
+
10
+ 1. Redistributions of source code must retain the above copyright notice, this
11
+ list of conditions and the following disclaimer.
12
+
13
+ 2. Redistributions in binary form must reproduce the above copyright notice,
14
+ this list of conditions and the following disclaimer in the documentation
15
+ and/or other materials provided with the distribution.
16
+
17
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
23
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
24
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
25
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.