saterys 0.2.7__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.
saterys/app.py ADDED
@@ -0,0 +1,225 @@
1
+ # saterys/app.py (Python 3.7 compatible)
2
+
3
+ from fastapi import FastAPI, HTTPException
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.responses import Response, FileResponse
6
+ from fastapi.staticfiles import StaticFiles
7
+ from pydantic import BaseModel
8
+ from typing import Any, Dict # IMPORTANT: Dict[...] for Py3.7
9
+ import importlib.util
10
+ import os
11
+ import sys
12
+
13
+ # ------------------------------------------------------------------------------
14
+ # FastAPI app + CORS
15
+ # ------------------------------------------------------------------------------
16
+ app = FastAPI()
17
+
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=["*"], # or list specific origins if you prefer
21
+ allow_credentials=True,
22
+ allow_methods=["*"],
23
+ allow_headers=["*"],
24
+ )
25
+
26
+
27
+ # ------------------------------------------------------------------------------
28
+ # Plugin discovery — load BOTH package nodes AND ./nodes
29
+ # ------------------------------------------------------------------------------
30
+ PKG_NODE_DIR = os.path.join(os.path.dirname(__file__), "nodes") # built-ins
31
+ CWD_NODE_DIR = os.path.join(os.getcwd(), "nodes") # user workspace
32
+
33
+ PLUGINS: Dict[str, Any] = {} # name -> module
34
+
35
+ def _load_dir(d: str):
36
+ if not os.path.isdir(d):
37
+ return
38
+ for fn in os.listdir(d):
39
+ if not fn.endswith(".py") or fn.startswith("__"):
40
+ continue
41
+ path = os.path.join(d, fn)
42
+ spec = importlib.util.spec_from_file_location(fn[:-3], path)
43
+ if not spec or not spec.loader:
44
+ continue
45
+ mod = importlib.util.module_from_spec(spec)
46
+ sys.modules[fn[:-3]] = mod
47
+ spec.loader.exec_module(mod)
48
+ name = getattr(mod, "NAME", fn[:-3])
49
+ PLUGINS[name] = mod
50
+ print(f"Loaded plugin: {name} from {path}")
51
+
52
+ def discover_plugins():
53
+ PLUGINS.clear()
54
+ _load_dir(PKG_NODE_DIR) # package built-ins
55
+ _load_dir(CWD_NODE_DIR) # user ./nodes
56
+
57
+ discover_plugins()
58
+
59
+ # ------------------------------------------------------------------------------
60
+ # API models + endpoints
61
+ # ------------------------------------------------------------------------------
62
+ class RunPayload(BaseModel):
63
+ nodeId: str
64
+ type: str # plugin name
65
+ args: Dict[str, Any] = {}
66
+ inputs: Dict[str, Any] = {} # optional upstream data
67
+
68
+ @app.get("/node_types")
69
+ def node_types():
70
+ out = []
71
+ for name, mod in PLUGINS.items():
72
+ out.append({
73
+ "name": name,
74
+ "default_args": getattr(mod, "DEFAULT_ARGS", {}),
75
+ })
76
+ return {"types": out}
77
+
78
+ @app.post("/run_node")
79
+ def run_node(p: RunPayload):
80
+ mod = PLUGINS.get(p.type)
81
+ if not mod:
82
+ return {"ok": False, "error": "unknown node type '%s'" % p.type}
83
+ try:
84
+ res = mod.run(p.args or {}, p.inputs or {}, {"nodeId": p.nodeId})
85
+ return {"ok": True, "output": res}
86
+ except Exception as e:
87
+ return {"ok": False, "error": str(e)}
88
+
89
+ # ------------------------------------------------------------------------------
90
+ # Raster preview endpoints (Leaflet tiles) — Py3.7 compatible
91
+ # Requires: pip install "rio-tiler<6" numpy
92
+ # ------------------------------------------------------------------------------
93
+ from typing import Dict as _Dict # avoid confusion with above
94
+ import numpy as np
95
+
96
+ # rio-tiler compatibility: use Reader if available (v6+), else COGReader (v<6)
97
+ try:
98
+ from rio_tiler.io import Reader as _RTReader # rio-tiler >= 6 (needs Python >= 3.8)
99
+ except Exception:
100
+ try:
101
+ from rio_tiler.io import COGReader as _RTReader # rio-tiler < 6 (Py3.7 OK)
102
+ except Exception as _e:
103
+ raise ImportError(
104
+ "rio-tiler not available. Install a Py3.7-compatible version:\n"
105
+ " pip install 'rio-tiler<6' numpy"
106
+ ) from _e
107
+
108
+ # Simple registry: preview id -> absolute path
109
+ PREVIEWS: _Dict[str, str] = {}
110
+
111
+ @app.post("/preview/register")
112
+ def preview_register(payload: Dict[str, str]):
113
+ """
114
+ Body: { "id": "myRaster1", "path": "/abs/path/to/file.tif" }
115
+ """
116
+ rid = str(payload.get("id", "")).strip()
117
+ pth = str(payload.get("path", "")).strip()
118
+ if not rid or not pth:
119
+ raise HTTPException(400, "id and path are required")
120
+ ap = os.path.abspath(pth)
121
+ if not os.path.exists(ap):
122
+ raise HTTPException(404, "file not found: %s" % ap)
123
+ PREVIEWS[rid] = ap
124
+ return {"ok": True, "id": rid, "path": ap}
125
+
126
+ @app.get("/preview/bounds/{rid}")
127
+ def preview_bounds(rid: str):
128
+ path = PREVIEWS.get(rid)
129
+ if not path:
130
+ raise HTTPException(404, "unknown preview id")
131
+ with _RTReader(path) as r:
132
+ west, south, east, north = r.geographic_bounds # lon/lat
133
+ return {"bounds": [west, south, east, north], "crs": "EPSG:4326"}
134
+
135
+ @app.get("/preview/tile/{rid}/{z}/{x}/{y}.png")
136
+ def preview_tile(rid: str, z: int, x: int, y: int, indexes: str = ""):
137
+ """
138
+ Return a PNG tile for the registered raster.
139
+ - ?indexes=4,3,2 chooses 1-based band indexes. If omitted: RGB if >=3 bands else band 1 grayscale.
140
+ - Per-tile p2/p98 stretch to 0..255 for a decent look without dataset stats.
141
+ """
142
+ path = PREVIEWS.get(rid)
143
+ if not path:
144
+ raise HTTPException(404, "unknown preview id")
145
+
146
+ # parse ?indexes
147
+ idx = None
148
+ if indexes:
149
+ try:
150
+ idx = tuple(int(i) for i in indexes.split(",") if i.strip())
151
+ except Exception:
152
+ raise HTTPException(400, "bad indexes param; expected comma-separated integers")
153
+
154
+ with _RTReader(path) as r:
155
+ # detect band count (compat across versions)
156
+ band_count = getattr(getattr(r, "dataset", None), "count", None)
157
+ if band_count is None:
158
+ try:
159
+ info = r.info()
160
+ band_count = len(info.get("band_metadata", []))
161
+ except Exception:
162
+ band_count = 1
163
+ if idx is None:
164
+ idx = (1, 2, 3) if (band_count and band_count >= 3) else (1,)
165
+
166
+ data, mask = r.tile(x, y, z, indexes=idx) # data: (bands, H, W), mask: HxW
167
+
168
+ # Per-band percentile stretch
169
+ out_bands = []
170
+ for b in range(data.shape[0]):
171
+ arr = data[b].astype("float32")
172
+ if mask is not None:
173
+ valid = (mask != 0) # treat 0 as nodata
174
+ arr = np.where(valid, arr, np.nan)
175
+ finite = np.isfinite(arr)
176
+ if not np.any(finite):
177
+ scaled = np.zeros_like(arr, dtype="uint8")
178
+ else:
179
+ vals = arr[finite]
180
+ p2, p98 = np.percentile(vals, (2, 98))
181
+ if not np.isfinite(p2) or not np.isfinite(p98) or (p98 <= p2):
182
+ scaled = np.zeros_like(arr, dtype="uint8")
183
+ else:
184
+ scaledf = (arr - p2) / (p98 - p2) * 255.0
185
+ scaled = np.clip(scaledf, 0, 255)
186
+ scaled = np.where(finite, scaled, 0).astype("uint8")
187
+ out_bands.append(scaled)
188
+
189
+ data8 = np.stack(out_bands, axis=0)
190
+
191
+ # Compose RGB
192
+ if data8.shape[0] == 1:
193
+ rgb = np.vstack([data8, data8, data8])
194
+ elif data8.shape[0] >= 3:
195
+ rgb = data8[:3]
196
+ else: # 2 bands -> duplicate last
197
+ rgb = np.vstack([data8, data8[-1:]])
198
+
199
+ # Encode PNG
200
+ from rio_tiler.utils import render
201
+ img = render(rgb, mask=mask, img_format="PNG")
202
+ return Response(content=img, media_type="image/png")
203
+
204
+ # ------------------------------------------------------------------------------
205
+ # Serve built frontend (compiled Svelte) from saterys/static at "/"
206
+ # ------------------------------------------------------------------------------
207
+
208
+ _here = os.path.dirname(__file__)
209
+ static_dir = os.path.join(_here, "static")
210
+
211
+ if os.path.isdir(static_dir):
212
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
213
+
214
+ # 👇 extra mount so /assets/* works when index.html uses absolute /assets URLs
215
+ assets_dir = os.path.join(static_dir, "assets")
216
+ if os.path.isdir(assets_dir):
217
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
218
+
219
+ @app.get("/")
220
+ def root():
221
+ index_path = os.path.join(static_dir, "index.html")
222
+ if not os.path.exists(index_path):
223
+ raise HTTPException(404, "Frontend not built. Run: (cd saterys/web && npm install && npm run build)")
224
+ return FileResponse(index_path)
225
+
saterys/cli.py ADDED
@@ -0,0 +1,26 @@
1
+ import argparse, os, subprocess, sys, time, webbrowser
2
+ from pathlib import Path
3
+ import uvicorn
4
+
5
+ def main():
6
+ ap = argparse.ArgumentParser(prog="saterys", description="Saterys runner")
7
+ ap.add_argument("--host", default=os.environ.get("SATERYS_HOST", "127.0.0.1"))
8
+ ap.add_argument("--port", type=int, default=int(os.environ.get("SATERYS_PORT", "8000")))
9
+ ap.add_argument("--dev", action="store_true", help="Run Vite (frontend) + Uvicorn (backend)")
10
+ args = ap.parse_args()
11
+
12
+ if args.dev:
13
+ # Frontend path is inside package: saterys/web
14
+ web_dir = Path(__file__).resolve().parent / "web"
15
+ if (web_dir / "package.json").exists():
16
+ os.environ.setdefault("SATERYS_DEV_ORIGIN", "http://localhost:5173")
17
+ print("Starting Vite (frontend) on :5173")
18
+ vite = subprocess.Popen(["npm", "run", "dev", "--", "--port=5173"], cwd=str(web_dir))
19
+ time.sleep(1.0)
20
+ try: webbrowser.open("http://localhost:5173")
21
+ except Exception: pass
22
+ else:
23
+ print("No frontend found at saterys/web. Serving built assets only.")
24
+
25
+ print(f"Starting Saterys API → http://{args.host}:{args.port}")
26
+ uvicorn.run("saterys.app:app", host=args.host, port=args.port, reload=bool(args.dev))
saterys/nodes/NDVI.py ADDED
@@ -0,0 +1,201 @@
1
+ # nodes/ndvi.py
2
+ """
3
+ NDVI node.
4
+ - If there are TWO upstream raster inputs: uses them as RED and NIR (you can hint which one via args.prefer_upstream_*).
5
+ - If there is ONE upstream raster input: reads specified band indices for red/nir.
6
+ - Saves NDVI as a GeoTIFF and returns a raster payload (path + metadata).
7
+
8
+ Requirements: rasterio, numpy
9
+ """
10
+
11
+ from __future__ import annotations
12
+ import os, hashlib
13
+ from typing import Any, Dict, Tuple
14
+
15
+ NAME = "raster.ndvi"
16
+ DEFAULT_ARGS = {
17
+ # When only one upstream raster is connected, read these band indices (1-based!)
18
+ "red_band": 4, # e.g. Landsat 8: B4 is red
19
+ "nir_band": 5, # Landsat 8: B5 is NIR
20
+ # When TWO upstream rasters are connected, you can optionally hint which node id is which:
21
+ "prefer_upstream_red_id": "",
22
+ "prefer_upstream_nir_id": "",
23
+ # Output
24
+ "output_path": "", # if empty, auto-cache to ./data/cache/ndvi-<hash>.tif
25
+ "dtype": "float32",
26
+ "nodata": -9999.0
27
+ }
28
+
29
+ def _cache_dir() -> str:
30
+ return os.path.abspath(os.getenv("RASTER_CACHE", "./data/cache"))
31
+
32
+ def _ensure_dir(p: str):
33
+ os.makedirs(p, exist_ok=True)
34
+
35
+ def _auto_name(paths: Tuple[str, ...], bands: Tuple[int, int]) -> str:
36
+ h = hashlib.sha1(("|".join(paths) + f"|{bands}").encode("utf-8")).hexdigest()[:16]
37
+ return f"ndvi-{h}.tif"
38
+
39
+ def _first_two_rasters(inputs: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
40
+ """Return a dict of {up_id: raster_payload} for the first two raster inputs found."""
41
+ out = {}
42
+ for up_id, v in inputs.items():
43
+ if isinstance(v, dict) and v.get("type") == "raster" and v.get("path"):
44
+ out[up_id] = v
45
+ if len(out) == 2:
46
+ break
47
+ return out
48
+
49
+ def _assert_same_grid(r1, r2):
50
+ if r1["crs"] != r2["crs"]:
51
+ raise ValueError(f"NDVI: CRS mismatch: {r1['crs']} vs {r2['crs']}")
52
+ if r1["transform"] != r2["transform"]:
53
+ raise ValueError("NDVI: transform (georeferencing) mismatch between inputs")
54
+ if r1["width"] != r2["width"] or r1["height"] != r2["height"]:
55
+ raise ValueError("NDVI: raster shapes differ; please resample beforehand")
56
+
57
+ def run(args: Dict[str, Any], inputs: Dict[str, Any], context: Dict[str, Any]):
58
+ import numpy as np
59
+ import rasterio
60
+ from rasterio.enums import Resampling
61
+
62
+ # 1) Gather upstream rasters
63
+ rasters = _first_two_rasters(inputs)
64
+
65
+ # 2) Resolve operating mode
66
+ prefer_red = (args.get("prefer_upstream_red_id") or "").strip() or None
67
+ prefer_nir = (args.get("prefer_upstream_nir_id") or "").strip() or None
68
+
69
+ red_arr = None
70
+ nir_arr = None
71
+ meta_source = None # template for writing output
72
+
73
+ if len(rasters) >= 2:
74
+ # --- Two-raster mode ---
75
+ # Determine which is red and which is nir
76
+ red_payload = None
77
+ nir_payload = None
78
+
79
+ # If preferences provided and present, honor them
80
+ if prefer_red and prefer_red in rasters:
81
+ red_payload = rasters[prefer_red]
82
+ if prefer_nir and prefer_nir in rasters:
83
+ nir_payload = rasters[prefer_nir]
84
+
85
+ # If not both resolved, assign remaining by order
86
+ remaining = [v for k, v in rasters.items() if v not in (red_payload, nir_payload)]
87
+ if red_payload is None and remaining:
88
+ red_payload = remaining.pop(0)
89
+ if nir_payload is None and remaining:
90
+ nir_payload = remaining.pop(0)
91
+
92
+ if red_payload is None or nir_payload is None:
93
+ raise ValueError("NDVI: need two upstream rasters or band indices")
94
+
95
+ _assert_same_grid(red_payload, nir_payload)
96
+
97
+ # Read first band from each file by default
98
+ with rasterio.open(red_payload["path"]) as ds_red, rasterio.open(nir_payload["path"]) as ds_nir:
99
+ red = ds_red.read(1, masked=True).astype("float32")
100
+ nir = ds_nir.read(1, masked=True).astype("float32")
101
+ meta_source = ds_red # use RED for profile
102
+
103
+ # Prefer nodata masks from both
104
+ mask = red.mask | nir.mask
105
+ red_arr = np.ma.array(red, mask=mask)
106
+ nir_arr = np.ma.array(nir, mask=mask)
107
+
108
+ out_name_paths = (red_payload["path"], nir_payload["path"])
109
+ bands_used = (1, 1)
110
+
111
+ else:
112
+ # --- Single multiband mode ---
113
+ # Find a single upstream raster
114
+ raster = next((v for v in inputs.values() if isinstance(v, dict) and v.get("type") == "raster"), None)
115
+ if raster is None:
116
+ raise ValueError("NDVI: no upstream raster found")
117
+
118
+ red_band = int(args.get("red_band", 4))
119
+ nir_band = int(args.get("nir_band", 5))
120
+ if red_band < 1 or nir_band < 1:
121
+ raise ValueError("NDVI: band indices are 1-based and must be >= 1")
122
+
123
+ with rasterio.open(raster["path"]) as ds:
124
+ if red_band > ds.count or nir_band > ds.count:
125
+ raise ValueError(f"NDVI: band index out of range (count={ds.count})")
126
+ red = ds.read(red_band, masked=True).astype("float32")
127
+ nir = ds.read(nir_band, masked=True).astype("float32")
128
+ meta_source = ds
129
+ mask = red.mask | nir.mask
130
+ red_arr = np.ma.array(red, mask=mask)
131
+ nir_arr = np.ma.array(nir, mask=mask)
132
+
133
+ out_name_paths = (raster["path"],)
134
+ bands_used = (red_band, nir_band)
135
+
136
+ # 3) Compute NDVI = (NIR - RED) / (NIR + RED)
137
+ # Handle divide-by-zero and propagate masks.
138
+ num = (nir_arr - red_arr)
139
+ den = (nir_arr + red_arr)
140
+ # Avoid runtime warnings; where den == 0, set masked
141
+ with np.errstate(divide="ignore", invalid="ignore"):
142
+ ndvi = np.ma.divide(num, den)
143
+ ndvi.mask = np.ma.getmaskarray(ndvi) | (den == 0)
144
+
145
+ out_dtype = str(args.get("dtype", "float32")).lower()
146
+ if out_dtype not in ("float32", "float64"):
147
+ out_dtype = "float32"
148
+ nodata_val = float(args.get("nodata", -9999.0))
149
+
150
+ # 4) Prepare output path
151
+ out_path = (args.get("output_path") or "").strip()
152
+ if not out_path:
153
+ cache_root = _cache_dir()
154
+ _ensure_dir(cache_root)
155
+ out_path = os.path.join(cache_root, _auto_name(out_name_paths, bands_used))
156
+ else:
157
+ _ensure_dir(os.path.dirname(os.path.abspath(out_path)))
158
+
159
+ # 5) Write GeoTIFF
160
+ with rasterio.open(out_path, "w",
161
+ driver="GTiff",
162
+ height=ndvi.shape[0],
163
+ width=ndvi.shape[1],
164
+ count=1,
165
+ dtype=out_dtype,
166
+ crs=meta_source.crs,
167
+ transform=meta_source.transform,
168
+ nodata=nodata_val) as dst:
169
+ # Fill masked pixels with nodata
170
+ out = ndvi.filled(nodata_val).astype(out_dtype)
171
+ dst.write(out, 1)
172
+ # Optional description
173
+ dst.set_band_description(1, "NDVI")
174
+
175
+ # 6) Return a raster payload for downstream nodes
176
+ from rasterio.coords import BoundingBox
177
+ with rasterio.open(out_path) as ds:
178
+ bb = ds.bounds
179
+ payload = {
180
+ "type": "raster",
181
+ "driver": ds.driver,
182
+ "path": os.path.abspath(out_path),
183
+ "width": ds.width,
184
+ "height": ds.height,
185
+ "count": ds.count,
186
+ "dtype": ds.dtypes[0],
187
+ "crs": str(ds.crs) if ds.crs else None,
188
+ "transform": [ds.transform.a, ds.transform.b, ds.transform.c,
189
+ ds.transform.d, ds.transform.e, ds.transform.f],
190
+ "bounds": [bb.left, bb.bottom, bb.right, bb.top],
191
+ "nodata": ds.nodata,
192
+ "band_names": ["NDVI"],
193
+ "meta": {
194
+ "source": "ndvi",
195
+ "mode": "two_rasters" if len(rasters) >= 2 else "multiband",
196
+ "inputs": list(out_name_paths),
197
+ "bands_used": {"red": bands_used[0], "nir": bands_used[1]},
198
+ },
199
+ }
200
+
201
+ return payload
saterys/nodes/NDWI.py ADDED
@@ -0,0 +1,195 @@
1
+ # nodes/ndwi.py
2
+ """
3
+ NDWI node (McFeeters 1996): NDWI = (Green - NIR) / (Green + NIR)
4
+
5
+ - Modes:
6
+ 1) Two upstream single-band rasters (same grid): use them as GREEN and NIR
7
+ (you can hint which is which via args.prefer_upstream_*).
8
+ 2) One upstream multiband raster: read GREEN/NIR band indices.
9
+
10
+ - Output: writes NDWI GeoTIFF and returns a raster payload (path + metadata)
11
+ compatible with your NDVI node’s structure.
12
+
13
+ Requirements: rasterio, numpy
14
+ """
15
+
16
+ from __future__ import annotations
17
+ import os, hashlib
18
+ from typing import Any, Dict, Tuple
19
+
20
+ NAME = "raster.ndwi"
21
+ DEFAULT_ARGS = {
22
+ # When only one upstream raster is connected, read these band indices (1-based!)
23
+ "green_band": 3, # Landsat 8/9: B3 is green
24
+ "nir_band": 5, # Landsat 8/9: B5 is NIR
25
+
26
+ # When TWO upstream rasters are connected, you can hint which node id is which:
27
+ "prefer_upstream_green_id": "",
28
+ "prefer_upstream_nir_id": "",
29
+
30
+ # Output
31
+ "output_path": "", # if empty, auto-cache to ./data/cache/ndwi-<hash>.tif
32
+ "dtype": "float32",
33
+ "nodata": -9999.0
34
+ }
35
+
36
+ def _cache_dir() -> str:
37
+ return os.path.abspath(os.getenv("RASTER_CACHE", "./data/cache"))
38
+
39
+ def _ensure_dir(p: str):
40
+ os.makedirs(p, exist_ok=True)
41
+
42
+ def _auto_name(paths: Tuple[str, ...], bands: Tuple[int, int]) -> str:
43
+ h = hashlib.sha1(("|".join(paths) + f"|{bands}").encode("utf-8")).hexdigest()[:16]
44
+ return f"ndwi-{h}.tif"
45
+
46
+ def _first_two_rasters(inputs: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
47
+ """Return {up_id: raster_payload} for the first two raster inputs found."""
48
+ out = {}
49
+ for up_id, v in inputs.items():
50
+ if isinstance(v, dict) and v.get("type") == "raster" and v.get("path"):
51
+ out[up_id] = v
52
+ if len(out) == 2:
53
+ break
54
+ return out
55
+
56
+ def _assert_same_grid(r1, r2):
57
+ if r1["crs"] != r2["crs"]:
58
+ raise ValueError(f"NDWI: CRS mismatch: {r1['crs']} vs {r2['crs']}")
59
+ if r1["transform"] != r2["transform"]:
60
+ raise ValueError("NDWI: transform (georeferencing) mismatch between inputs")
61
+ if r1["width"] != r2["width"] or r1["height"] != r2["height"]:
62
+ raise ValueError("NDWI: raster shapes differ; please resample beforehand")
63
+
64
+ def run(args: Dict[str, Any], inputs: Dict[str, Any], context: Dict[str, Any]):
65
+ import numpy as np
66
+ import rasterio
67
+
68
+ # 1) Gather upstream rasters
69
+ rasters = _first_two_rasters(inputs)
70
+
71
+ # 2) Resolve operating mode
72
+ prefer_g = (args.get("prefer_upstream_green_id") or "").strip() or None
73
+ prefer_n = (args.get("prefer_upstream_nir_id") or "").strip() or None
74
+
75
+ green_arr = None
76
+ nir_arr = None
77
+ meta_source = None # template for writing output
78
+
79
+ if len(rasters) >= 2:
80
+ # --- Two-raster mode ---
81
+ # Determine which is green and which is nir
82
+ green_payload = None
83
+ nir_payload = None
84
+
85
+ if prefer_g and prefer_g in rasters:
86
+ green_payload = rasters[prefer_g]
87
+ if prefer_n and prefer_n in rasters:
88
+ nir_payload = rasters[prefer_n]
89
+
90
+ remaining = [v for k, v in rasters.items() if v not in (green_payload, nir_payload)]
91
+ if green_payload is None and remaining:
92
+ green_payload = remaining.pop(0)
93
+ if nir_payload is None and remaining:
94
+ nir_payload = remaining.pop(0)
95
+
96
+ if green_payload is None or nir_payload is None:
97
+ raise ValueError("NDWI: need two upstream rasters or band indices")
98
+
99
+ _assert_same_grid(green_payload, nir_payload)
100
+
101
+ with rasterio.open(green_payload["path"]) as dsg, rasterio.open(nir_payload["path"]) as dsn:
102
+ g = dsg.read(1, masked=True).astype("float32")
103
+ n = dsn.read(1, masked=True).astype("float32")
104
+ meta_source = dsg
105
+ mask = g.mask | n.mask
106
+ green_arr = np.ma.array(g, mask=mask)
107
+ nir_arr = np.ma.array(n, mask=mask)
108
+
109
+ bands_used = (1, 1)
110
+ name_inputs = (green_payload["path"], nir_payload["path"])
111
+
112
+ else:
113
+ # --- Single multiband mode ---
114
+ raster = next((v for v in inputs.values() if isinstance(v, dict) and v.get("type") == "raster"), None)
115
+ if raster is None:
116
+ raise ValueError("NDWI: no upstream raster found")
117
+
118
+ g_idx = int(args.get("green_band", 3))
119
+ n_idx = int(args.get("nir_band", 5))
120
+ if g_idx < 1 or n_idx < 1:
121
+ raise ValueError("NDWI: band indices are 1-based and must be >= 1")
122
+
123
+ with rasterio.open(raster["path"]) as ds:
124
+ if g_idx > ds.count or n_idx > ds.count:
125
+ raise ValueError(f"NDWI: band index out of range (count={ds.count})")
126
+ g = ds.read(g_idx, masked=True).astype("float32")
127
+ n = ds.read(n_idx, masked=True).astype("float32")
128
+ meta_source = ds
129
+ mask = g.mask | n.mask
130
+ green_arr = np.ma.array(g, mask=mask)
131
+ nir_arr = np.ma.array(n, mask=mask)
132
+
133
+ bands_used = (g_idx, n_idx)
134
+ name_inputs = (raster["path"],)
135
+
136
+ # 3) Compute NDWI = (G - NIR) / (G + NIR), mask div-by-zero
137
+ with np.errstate(divide="ignore", invalid="ignore"):
138
+ num = (green_arr - nir_arr)
139
+ den = (green_arr + nir_arr)
140
+ ndwi = np.ma.divide(num, den)
141
+ ndwi.mask = np.ma.getmaskarray(ndwi) | (den == 0)
142
+
143
+ out_dtype = str(args.get("dtype", "float32")).lower()
144
+ if out_dtype not in ("float32", "float64"):
145
+ out_dtype = "float32"
146
+ nodata_val = float(args.get("nodata", -9999.0))
147
+
148
+ # 4) Prepare output path
149
+ out_path = (args.get("output_path") or "").strip()
150
+ if not out_path:
151
+ cache_root = _cache_dir()
152
+ _ensure_dir(cache_root)
153
+ out_path = os.path.join(cache_root, _auto_name(name_inputs, bands_used))
154
+ else:
155
+ _ensure_dir(os.path.dirname(os.path.abspath(out_path)))
156
+
157
+ # 5) Write GeoTIFF
158
+ with rasterio.open(out_path, "w",
159
+ driver="GTiff",
160
+ height=ndwi.shape[0],
161
+ width=ndwi.shape[1],
162
+ count=1,
163
+ dtype=out_dtype,
164
+ crs=meta_source.crs,
165
+ transform=meta_source.transform,
166
+ nodata=nodata_val) as dst:
167
+ dst.write(ndwi.filled(nodata_val).astype(out_dtype), 1)
168
+ dst.set_band_description(1, "NDWI")
169
+
170
+ # 6) Return a raster payload (mirrors NDVI node)
171
+ with rasterio.open(out_path) as ds:
172
+ bb = ds.bounds
173
+ payload = {
174
+ "type": "raster",
175
+ "driver": ds.driver,
176
+ "path": os.path.abspath(out_path),
177
+ "width": ds.width,
178
+ "height": ds.height,
179
+ "count": ds.count,
180
+ "dtype": ds.dtypes[0],
181
+ "crs": str(ds.crs) if ds.crs else None,
182
+ "transform": [ds.transform.a, ds.transform.b, ds.transform.c,
183
+ ds.transform.d, ds.transform.e, ds.transform.f],
184
+ "bounds": [bb.left, bb.bottom, bb.right, bb.top],
185
+ "nodata": ds.nodata,
186
+ "band_names": ["NDWI"],
187
+ "meta": {
188
+ "source": "ndwi",
189
+ "mode": "two_rasters" if len(rasters) >= 2 else "multiband",
190
+ "inputs": list(name_inputs),
191
+ "bands_used": {"green": bands_used[0], "nir": bands_used[1]},
192
+ },
193
+ }
194
+
195
+ return payload