saterys 0.2.7__tar.gz

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.
Files changed (33) hide show
  1. saterys-0.2.7/LICENSE +21 -0
  2. saterys-0.2.7/MANIFEST.in +4 -0
  3. saterys-0.2.7/PKG-INFO +50 -0
  4. saterys-0.2.7/README.md +28 -0
  5. saterys-0.2.7/pyproject.toml +58 -0
  6. saterys-0.2.7/saterys/app.py +225 -0
  7. saterys-0.2.7/saterys/cli.py +26 -0
  8. saterys-0.2.7/saterys/nodes/NDVI.py +201 -0
  9. saterys-0.2.7/saterys/nodes/NDWI.py +195 -0
  10. saterys-0.2.7/saterys/nodes/PCA.py +370 -0
  11. saterys-0.2.7/saterys/nodes/hello.py +6 -0
  12. saterys-0.2.7/saterys/nodes/input.py +39 -0
  13. saterys-0.2.7/saterys/nodes/script.py +13 -0
  14. saterys-0.2.7/saterys/nodes/sum.py +7 -0
  15. saterys-0.2.7/saterys/static/assets/ContrastTheme-8vl10reL.js +1 -0
  16. saterys-0.2.7/saterys/static/assets/Controls-BuZt2rJO.js +1 -0
  17. saterys-0.2.7/saterys/static/assets/DrawerController-CunOJ5Gd.js +1 -0
  18. saterys-0.2.7/saterys/static/assets/DrawerController-ZXfKNTdC.css +1 -0
  19. saterys-0.2.7/saterys/static/assets/Icon-BZoXfbBu.js +1 -0
  20. saterys-0.2.7/saterys/static/assets/Minimap-6vF3teR8.js +5 -0
  21. saterys-0.2.7/saterys/static/assets/ThemeToggle-CmShuKPJ.js +1 -0
  22. saterys-0.2.7/saterys/static/assets/index-CjC_xNd3.css +1 -0
  23. saterys-0.2.7/saterys/static/assets/index-CwCTxci7.js +13 -0
  24. saterys-0.2.7/saterys/static/assets/vite-C4kziZTZ.svg +12 -0
  25. saterys-0.2.7/saterys/static/index.html +14 -0
  26. saterys-0.2.7/saterys.egg-info/PKG-INFO +50 -0
  27. saterys-0.2.7/saterys.egg-info/SOURCES.txt +31 -0
  28. saterys-0.2.7/saterys.egg-info/dependency_links.txt +1 -0
  29. saterys-0.2.7/saterys.egg-info/entry_points.txt +2 -0
  30. saterys-0.2.7/saterys.egg-info/requires.txt +6 -0
  31. saterys-0.2.7/saterys.egg-info/top_level.txt +1 -0
  32. saterys-0.2.7/setup.cfg +4 -0
  33. saterys-0.2.7/setup.py +37 -0
saterys-0.2.7/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Your Name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ include README.md
2
+ include LICENSE
3
+ recursive-include saterys/static *
4
+ recursive-include saterys/nodes *.py
saterys-0.2.7/PKG-INFO ADDED
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.1
2
+ Name: saterys
3
+ Version: 0.2.7
4
+ Summary: Saterys — Scalable Analysis Toolkit for Earth Remote sYStemS
5
+ Author-email: Sebastian Sanchez Bernal <bastian@example.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/yourusername/saterys
8
+ Project-URL: Repository, https://github.com/yourusername/saterys
9
+ Project-URL: Issues, https://github.com/yourusername/saterys/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Topic :: Scientific/Engineering :: GIS
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+
23
+ # SATERYS
24
+
25
+ [![PyPI version](https://img.shields.io/pypi/v/saterys.svg?style=flat-square)](https://pypi.org/project/saterys/)
26
+ [![Python versions](https://img.shields.io/pypi/pyversions/saterys.svg?style=flat-square)](https://pypi.org/project/saterys/)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE)
28
+ [![Build](https://img.shields.io/github/actions/workflow/status/bastian6666/saterys/ci.yml?style=flat-square&label=build)](https://github.com/bastian6666/saterys/actions)
29
+
30
+ **SATERYS** is a geospatial pipeline builder combining a **Svelte** frontend with a **FastAPI** backend.
31
+ It provides an interactive node-based canvas for connecting operations, executing Python plugins, and visualizing raster data directly on a Leaflet map.
32
+
33
+ ---
34
+
35
+ ## ✨ Features
36
+
37
+ - 🎨 **Interactive Node Editor** using [Svelvet](https://svelvet.io/).
38
+ - ⚡ **FastAPI Backend** for plugin execution and REST endpoints.
39
+ - 🛰 **Geospatial Preview** with Leaflet, serving raster tiles via [rio-tiler](https://github.com/cogeotiff/rio-tiler).
40
+ - 🔌 **Extensible Plugin System**: add your own nodes by dropping Python files into a `nodes/` folder.
41
+ - 🌙 **Dark/Light Theme** toggle.
42
+ - 📦 Fully **pip-installable** with built frontend assets included.
43
+
44
+ ---
45
+
46
+ ## 📦 Installation
47
+
48
+ ```bash
49
+ pip install saterys
50
+ # SATERYS
@@ -0,0 +1,28 @@
1
+ # SATERYS
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/saterys.svg?style=flat-square)](https://pypi.org/project/saterys/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/saterys.svg?style=flat-square)](https://pypi.org/project/saterys/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE)
6
+ [![Build](https://img.shields.io/github/actions/workflow/status/bastian6666/saterys/ci.yml?style=flat-square&label=build)](https://github.com/bastian6666/saterys/actions)
7
+
8
+ **SATERYS** is a geospatial pipeline builder combining a **Svelte** frontend with a **FastAPI** backend.
9
+ It provides an interactive node-based canvas for connecting operations, executing Python plugins, and visualizing raster data directly on a Leaflet map.
10
+
11
+ ---
12
+
13
+ ## ✨ Features
14
+
15
+ - 🎨 **Interactive Node Editor** using [Svelvet](https://svelvet.io/).
16
+ - ⚡ **FastAPI Backend** for plugin execution and REST endpoints.
17
+ - 🛰 **Geospatial Preview** with Leaflet, serving raster tiles via [rio-tiler](https://github.com/cogeotiff/rio-tiler).
18
+ - 🔌 **Extensible Plugin System**: add your own nodes by dropping Python files into a `nodes/` folder.
19
+ - 🌙 **Dark/Light Theme** toggle.
20
+ - 📦 Fully **pip-installable** with built frontend assets included.
21
+
22
+ ---
23
+
24
+ ## 📦 Installation
25
+
26
+ ```bash
27
+ pip install saterys
28
+ # SATERYS
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel", "build"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "saterys"
7
+ version = "0.2.7" # bump each release
8
+ description = "Saterys — Scalable Analysis Toolkit for Earth Remote sYStemS"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+
12
+ authors = [
13
+ { name = "Sebastian Sanchez Bernal", email = "bastian@example.com" }
14
+ ]
15
+
16
+ requires-python = ">=3.9"
17
+
18
+ dependencies = [
19
+ "fastapi>=0.110", # modern FastAPI (with Pydantic v2 support)
20
+ "uvicorn[standard]>=0.23", # ASGI server
21
+ "pydantic>=2", # data validation
22
+ "numpy>=1.26", # wheels available for Python 3.12
23
+ "rio-tiler>=6", # raster tiling & preview
24
+ "rasterio>=1.3", # geospatial raster I/O
25
+ ]
26
+
27
+ classifiers = [
28
+ "Development Status :: 3 - Alpha",
29
+ "Intended Audience :: Science/Research",
30
+ "Topic :: Scientific/Engineering :: GIS",
31
+ "License :: OSI Approved :: MIT License",
32
+ "Programming Language :: Python :: 3",
33
+ "Programming Language :: Python :: 3.9",
34
+ "Programming Language :: Python :: 3.10",
35
+ "Programming Language :: Python :: 3.11",
36
+ "Programming Language :: Python :: 3.12",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/yourusername/saterys"
41
+ Repository = "https://github.com/yourusername/saterys"
42
+ Issues = "https://github.com/yourusername/saterys/issues"
43
+
44
+ [project.scripts]
45
+ saterys = "saterys.cli:main"
46
+
47
+ [tool.setuptools]
48
+ include-package-data = true
49
+
50
+ [tool.setuptools.packages.find]
51
+ include = ["saterys*"]
52
+
53
+ [tool.setuptools.package-data]
54
+ saterys = [
55
+ "nodes/*.py",
56
+ "static/*",
57
+ "static/**/*",
58
+ ]
@@ -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
+
@@ -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))
@@ -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