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/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.
|