seriffic Claude Opus 4.7 (1M context) commited on
Commit
599cc5c
·
1 Parent(s): 872b14a

Stones C3: add TerraMind-NYC adapter wrapper (lulc + buildings)

Browse files

Adds app/context/terramind_nyc.py wrapping the Apache-2.0 LoRA family
published at msradam/TerraMind-NYC-Adapters. Exposes two specialist
entry points consumable by the FSM:

lulc(s2l2a, s1rtc, dem) -> 5-class macro NYC land-cover
buildings(s2l2a, s1rtc, dem) -> binary NYC building-footprint

Per-call shape:
{ok, n_pixels, shape, class_fractions, dominant_class, dominant_pct,
adapter, repo, elapsed_s} (lulc)
{ok, n_pixels, shape, pct_buildings, n_building_components,
class_labels, adapter, repo, elapsed_s} (buildings)

Lazy load — adapter weights pull from HF Hub on first call and cache
to ~/.cache/huggingface/hub. Base TerraMind 1.0 weights are downloaded
by terratorch's EncoderDecoderFactory, not redistributed by us.

CHIP-SIZE TRAP. TerraMind's positional embeddings don't generalise off
224x224. Inference goes through terratorch.tasks.tiled_inference with
a 224x224 / 128 stride window so chip sizes != 224 are handled
correctly — calling task.model({...}) directly on a larger chip
returns silent garbage. The dict-of-modalities form is supported by
terratorch >= the version pinned in this repo.

Gated by RIPRAP_TERRAMIND_NYC_ENABLE (default 1). Deployments without
terratorch / peft / safetensors / huggingface_hub installed silently
no-op via the same skipped-result shape every other heavy specialist
emits — no noisy ModuleNotFoundError in the FSM trace.

C4 wires this into the FSM as step_terramind_lulc and
step_terramind_buildings; the chip-cache (so a single S2L2A+S1RTC+DEM
fetch per query feeds both calls) lives in C4.

tests/test_terramind_nyc.py exercises the gate paths, public API,
and result-dict shapes without downloading any weights.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

app/context/terramind_nyc.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TerraMind-NYC adapters — LULC and Buildings inference for NYC chips.
2
+
3
+ Wraps the Apache-2.0 [`msradam/TerraMind-NYC-Adapters`](https://huggingface.co/msradam/TerraMind-NYC-Adapters)
4
+ LoRA family fine-tuned on NYC EO chips (Sentinel-2 L2A + Sentinel-1 RTC
5
+ + Copernicus DEM, temporal stack of 4) on AMD MI300X via AMD Developer
6
+ Cloud. Exposes two specialist entry points:
7
+
8
+ lulc(s2l2a, s1rtc, dem) -> 5-class macro NYC LULC mask
9
+ buildings(s2l2a, s1rtc, dem) -> binary NYC building footprint mask
10
+
11
+ The base TerraMind 1.0 weights are downloaded by terratorch on first
12
+ call; the LoRA adapter + UNet decoder weights come from the HF repo and
13
+ are cached to `~/.cache/huggingface/hub`.
14
+
15
+ CHIP-SIZE TRAP. TerraMind's positional embeddings don't generalise off
16
+ its training resolution (224×224). Calling `task.model({...})` on a
17
+ chip ≠ 224×224 produces silent garbage. We therefore wrap inference
18
+ with `terratorch.tasks.tiled_inference.tiled_inference`, which slides
19
+ a 224×224 crop window across the chip and stitches per-window logits.
20
+ This matches the patch in
21
+ `experiments/18_terramind_nyc_lora/shared/inference_ensemble.py` that
22
+ the plan flags as required for production.
23
+
24
+ Gated by RIPRAP_TERRAMIND_NYC_ENABLE — deployments without the deps
25
+ installed (HF Spaces' Py3.10 cone, plain Ollama dev VMs) silently no-op
26
+ through the same skipped-result shape every other heavy specialist
27
+ emits.
28
+
29
+ This module does NOT fetch its own S2/S1/DEM chips. C4 wires it into
30
+ the FSM with a shared chip cache so the LULC and Buildings calls
31
+ don't each refetch ~150 MB of imagery.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ import os
37
+ import threading
38
+ import time
39
+ from typing import Any
40
+
41
+ log = logging.getLogger("riprap.terramind_nyc")
42
+
43
+ ENABLE = os.environ.get("RIPRAP_TERRAMIND_NYC_ENABLE", "1").lower() in ("1", "true", "yes")
44
+ DEVICE = os.environ.get("RIPRAP_TERRAMIND_NYC_DEVICE", "cpu")
45
+ ADAPTERS_REPO = "msradam/TerraMind-NYC-Adapters"
46
+
47
+ # Per-task config knobs the HF README's quick-start fixes for these
48
+ # adapters. Mirrored from experiments/18_terramind_nyc_lora/adapters/*/
49
+ # config.yaml so a single source of truth lives next to the inference
50
+ # code rather than being scraped from YAML at runtime.
51
+ ADAPTER_SPECS: dict[str, dict[str, Any]] = {
52
+ "lulc": {
53
+ "subdir": "lulc_nyc",
54
+ "num_classes": 5,
55
+ "class_labels": [
56
+ "Trees / vegetation",
57
+ "Cropland",
58
+ "Built / impervious",
59
+ "Bare ground",
60
+ "Water",
61
+ ],
62
+ },
63
+ "buildings": {
64
+ "subdir": "buildings_nyc",
65
+ "num_classes": 2,
66
+ # The decoder emits class 0 = background, class 1 = building.
67
+ "class_labels": ["Background", "Building footprint"],
68
+ },
69
+ }
70
+
71
+ # Tile-window size — TerraMind's training resolution. Stride < window
72
+ # yields overlap (smooths seams from window-boundary classification
73
+ # noise); 96 px overlap matches the experiments/18 ensemble.
74
+ TILE_SIZE = 224
75
+ TILE_STRIDE = 128
76
+
77
+ # One-shot lazy-init guards. The base TerraMind weights are heavy
78
+ # (~1.6 GB) and we want to load them once across LULC and Buildings.
79
+ _INIT_LOCK = threading.Lock()
80
+ _BASE_LOADED = False
81
+ _ADAPTERS: dict[str, Any] = {} # name -> built terratorch task on DEVICE
82
+
83
+
84
+ def _has_required_deps() -> tuple[bool, str | None]:
85
+ """Probe the heavy-EO deps. Same shape as prithvi_live's check —
86
+ a missing dep (terratorch / peft / safetensors / hf_hub) returns a
87
+ clean `skipped: deps_unavailable` outcome instead of a noisy
88
+ ModuleNotFoundError in the trace."""
89
+ missing: list[str] = []
90
+ for name in ("terratorch", "peft", "safetensors", "huggingface_hub",
91
+ "torch", "yaml"):
92
+ try:
93
+ __import__(name)
94
+ except ImportError:
95
+ missing.append(name)
96
+ if missing:
97
+ return False, ", ".join(missing)
98
+ return True, None
99
+
100
+
101
+ _DEPS_OK, _DEPS_MISSING = _has_required_deps()
102
+
103
+
104
+ def _ensure_adapter(adapter_name: str):
105
+ """Build the terratorch SemanticSegmentationTask, inject the LoRA
106
+ scaffold, load the published Δ + decoder weights, return the task.
107
+
108
+ Per-task tasks share the TerraMind base inside terratorch's model
109
+ factory — calling SemanticSegmentationTask twice loads the base
110
+ twice in fp32 (~3.3 GB resident on CPU). For a two-task family this
111
+ is acceptable; we don't need the cross-task weight sharing the
112
+ experiments/18 ensemble does. If memory becomes a problem, swap
113
+ this for a single-task / hot-swap-adapter implementation.
114
+ """
115
+ if adapter_name not in ADAPTER_SPECS:
116
+ raise KeyError(f"unknown adapter {adapter_name!r}; "
117
+ f"expected one of {list(ADAPTER_SPECS)}")
118
+ if adapter_name in _ADAPTERS:
119
+ return _ADAPTERS[adapter_name]
120
+
121
+ with _INIT_LOCK:
122
+ if adapter_name in _ADAPTERS:
123
+ return _ADAPTERS[adapter_name]
124
+
125
+ spec = ADAPTER_SPECS[adapter_name]
126
+ log.info("terramind_nyc: building task for %s", adapter_name)
127
+
128
+ from huggingface_hub import snapshot_download
129
+ from peft import LoraConfig, inject_adapter_in_model
130
+ from safetensors.torch import load_file
131
+ from terratorch.tasks import SemanticSegmentationTask
132
+
133
+ # 1. Pull the requested adapter subtree from the HF repo.
134
+ adapter_root = snapshot_download(
135
+ ADAPTERS_REPO,
136
+ allow_patterns=[f"{spec['subdir']}/*"],
137
+ )
138
+
139
+ # 2. Build the standard terratorch task with the same model_args
140
+ # the published HF_README quick-start uses.
141
+ task = SemanticSegmentationTask(
142
+ model_factory="EncoderDecoderFactory",
143
+ model_args=dict(
144
+ backbone="terramind_v1_base",
145
+ backbone_pretrained=True,
146
+ backbone_modalities=["S2L2A", "S1RTC", "DEM"],
147
+ backbone_use_temporal=True,
148
+ backbone_temporal_pooling="concat",
149
+ backbone_temporal_n_timestamps=4,
150
+ necks=[
151
+ {"name": "SelectIndices", "indices": [2, 5, 8, 11]},
152
+ {"name": "ReshapeTokensToImage", "remove_cls_token": False},
153
+ {"name": "LearnedInterpolateToPyramidal"},
154
+ ],
155
+ decoder="UNetDecoder",
156
+ decoder_channels=[512, 256, 128, 64],
157
+ head_dropout=0.1,
158
+ num_classes=spec["num_classes"],
159
+ ),
160
+ loss="ce", lr=1e-4, freeze_backbone=False, freeze_decoder=False,
161
+ )
162
+
163
+ # 3. Inject the LoRA scaffold the adapter weights were trained
164
+ # against. Same hyperparameters every adapter in this family
165
+ # used (see experiments/18 adapters/_template/config.yaml).
166
+ inject_adapter_in_model(LoraConfig(
167
+ r=16, lora_alpha=32, lora_dropout=0.05,
168
+ target_modules=["attn.qkv", "attn.proj"], bias="none",
169
+ ), task.model.encoder)
170
+
171
+ # 4. Restore Δ matrices (encoder LoRA) and the decoder/neck/head
172
+ # weights from the safetensors bundle. The encoder.* prefix
173
+ # is stripped because the encoder state-dict is rooted at
174
+ # the encoder module, not the task.
175
+ adapter_dir = f"{adapter_root}/{spec['subdir']}"
176
+ lora_state = load_file(f"{adapter_dir}/adapter_model.safetensors")
177
+ head_state = load_file(f"{adapter_dir}/decoder_head.safetensors")
178
+ encoder_state = {
179
+ k.removeprefix("encoder."): v
180
+ for k, v in lora_state.items() if k.startswith("encoder.")
181
+ }
182
+ task.model.encoder.load_state_dict(encoder_state, strict=False)
183
+ for sub in ("decoder", "neck", "head", "aux_heads"):
184
+ sub_state = {
185
+ k[len(sub) + 1:]: v
186
+ for k, v in head_state.items() if k.startswith(sub + ".")
187
+ }
188
+ if sub_state and hasattr(task.model, sub):
189
+ getattr(task.model, sub).load_state_dict(sub_state,
190
+ strict=False)
191
+
192
+ # 5. Move to the configured device. CUDA only if the caller
193
+ # asked AND a CUDA device is actually available — silently
194
+ # fall back to CPU otherwise.
195
+ target_device = DEVICE
196
+ if target_device == "cuda":
197
+ import torch
198
+ if not torch.cuda.is_available():
199
+ log.warning("terramind_nyc: CUDA unavailable, falling back to CPU")
200
+ target_device = "cpu"
201
+ task = task.to(target_device).eval()
202
+
203
+ _ADAPTERS[adapter_name] = task
204
+ log.info("terramind_nyc: %s ready on %s", adapter_name, target_device)
205
+ return task
206
+
207
+
208
+ def _tiled_predict(task, modality_chips: dict, num_classes: int):
209
+ """Run the task's encoder-decoder forward in 224×224 tiles, returning
210
+ a (1, num_classes, H, W) logits tensor stitched from the windows.
211
+
212
+ TerraMind's positional embeddings are tied to the 224×224 training
213
+ resolution. terratorch's tiled_inference helper slides a window
214
+ across the input modalities (it accepts a dict of per-modality
215
+ tensors as long as all modalities share H×W), runs the model on
216
+ each crop, and averages overlapping logits. Without it, larger
217
+ chips return silent garbage; smaller chips error on the encoder
218
+ ViT.
219
+ """
220
+ import torch
221
+ from terratorch.tasks.tiled_inference import tiled_inference
222
+
223
+ # tiled_inference invokes `model_forward(patch)` per tile. The task
224
+ # model returns a ModelOutput-like with .output OR a plain tensor;
225
+ # coerce to tensor either way.
226
+ def _forward(x, **_extra):
227
+ out = task.model(x)
228
+ return out.output if hasattr(out, "output") else out
229
+
230
+ with torch.no_grad():
231
+ logits = tiled_inference(
232
+ _forward,
233
+ modality_chips,
234
+ out_channels=num_classes,
235
+ h_crop=TILE_SIZE,
236
+ w_crop=TILE_SIZE,
237
+ h_stride=TILE_STRIDE,
238
+ w_stride=TILE_STRIDE,
239
+ average_patches=True,
240
+ blend_overlaps=True,
241
+ padding="reflect",
242
+ )
243
+ return logits
244
+
245
+
246
+ def _summarize_lulc(pred, class_labels: list[str]) -> dict[str, Any]:
247
+ """Per-class pixel fraction + dominant class from an integer mask."""
248
+ import numpy as np
249
+ pred_np = pred.detach().cpu().numpy() if hasattr(pred, "detach") else np.asarray(pred)
250
+ flat = pred_np.reshape(-1)
251
+ n = max(int(flat.size), 1)
252
+ fractions: dict[str, float] = {}
253
+ for idx, label in enumerate(class_labels):
254
+ pct = 100.0 * float((flat == idx).sum()) / n
255
+ if pct > 0:
256
+ fractions[label] = round(pct, 2)
257
+ dominant_idx = int(max(range(len(class_labels)),
258
+ key=lambda i: int((flat == i).sum())))
259
+ return {
260
+ "ok": True,
261
+ "n_pixels": int(flat.size),
262
+ "shape": list(pred_np.shape),
263
+ "class_fractions": fractions,
264
+ "dominant_class": class_labels[dominant_idx],
265
+ "dominant_pct": fractions.get(class_labels[dominant_idx], 0.0),
266
+ }
267
+
268
+
269
+ def _summarize_buildings(pred, class_labels: list[str]) -> dict[str, Any]:
270
+ """Building-pixel coverage + simple connected-component count."""
271
+ import numpy as np
272
+ pred_np = pred.detach().cpu().numpy() if hasattr(pred, "detach") else np.asarray(pred)
273
+ mask = (pred_np == 1).astype("uint8")
274
+ n_total = max(int(mask.size), 1)
275
+ pct_built = 100.0 * float(mask.sum()) / n_total
276
+ # Connected-component count is a cheap signal of "how many distinct
277
+ # buildings does this chip cover" — useful for the briefing without
278
+ # paying for full polygonisation.
279
+ n_components: int | None = None
280
+ try:
281
+ from scipy.ndimage import label
282
+ _, n_components = label(mask)
283
+ except Exception: # scipy is optional in some HF Spaces build cones
284
+ log.debug("terramind_nyc: scipy.ndimage unavailable; "
285
+ "skipping component count")
286
+ return {
287
+ "ok": True,
288
+ "n_pixels": int(mask.size),
289
+ "shape": list(mask.shape),
290
+ "pct_buildings": round(pct_built, 2),
291
+ "n_building_components": n_components,
292
+ "class_labels": class_labels,
293
+ }
294
+
295
+
296
+ def _run(adapter_name: str, modality_chips: dict, summarizer):
297
+ """Common boilerplate: gate, time, load, tiled predict, summarize."""
298
+ if not ENABLE:
299
+ return {"ok": False,
300
+ "skipped": "RIPRAP_TERRAMIND_NYC_ENABLE=0"}
301
+ if not _DEPS_OK:
302
+ return {"ok": False,
303
+ "skipped": f"deps unavailable on this deployment: "
304
+ f"{_DEPS_MISSING}"}
305
+ if not modality_chips:
306
+ return {"ok": False, "err": "no modality chips supplied"}
307
+ t0 = time.time()
308
+ try:
309
+ task = _ensure_adapter(adapter_name)
310
+ spec = ADAPTER_SPECS[adapter_name]
311
+ logits = _tiled_predict(task, modality_chips, spec["num_classes"])
312
+ # logits: (B, C, H, W). Argmax to per-pixel class id.
313
+ pred = logits.argmax(dim=1).squeeze(0)
314
+ result = summarizer(pred, spec["class_labels"])
315
+ result["elapsed_s"] = round(time.time() - t0, 2)
316
+ result["adapter"] = adapter_name
317
+ result["repo"] = ADAPTERS_REPO
318
+ return result
319
+ except Exception as e:
320
+ log.exception("terramind_nyc.%s failed", adapter_name)
321
+ return {"ok": False, "err": f"{type(e).__name__}: {e}",
322
+ "elapsed_s": round(time.time() - t0, 2)}
323
+
324
+
325
+ def lulc(s2l2a, s1rtc=None, dem=None) -> dict[str, Any]:
326
+ """5-class NYC macro land-cover.
327
+
328
+ Inputs are torch tensors. The temporal models we trained expect
329
+ [C, T, H, W] (preferred) or [C, H, W] (will be expanded to T=1).
330
+ Pass S1 and DEM if you have them — the published adapter was
331
+ trained on the full triplet and accuracy degrades when modalities
332
+ are dropped.
333
+ """
334
+ chips = {"S2L2A": s2l2a}
335
+ if s1rtc is not None:
336
+ chips["S1RTC"] = s1rtc
337
+ if dem is not None:
338
+ chips["DEM"] = dem
339
+ return _run("lulc", chips, _summarize_lulc)
340
+
341
+
342
+ def buildings(s2l2a, s1rtc=None, dem=None) -> dict[str, Any]:
343
+ """Binary NYC building-footprint mask. Same input contract as lulc()."""
344
+ chips = {"S2L2A": s2l2a}
345
+ if s1rtc is not None:
346
+ chips["S1RTC"] = s1rtc
347
+ if dem is not None:
348
+ chips["DEM"] = dem
349
+ return _run("buildings", chips, _summarize_buildings)
350
+
351
+
352
+ def warm():
353
+ """Optional pre-load — amortizes the first-query model build cost."""
354
+ if not ENABLE or not _DEPS_OK:
355
+ return
356
+ try:
357
+ for name in ADAPTER_SPECS:
358
+ _ensure_adapter(name)
359
+ except Exception:
360
+ log.exception("terramind_nyc: warm() failed; specialists will no-op")
tests/test_terramind_nyc.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the TerraMind-NYC adapter wrapper.
2
+
3
+ These tests don't actually load weights or run inference — they verify
4
+ the gate paths (ENABLE=0, missing deps), the public API surface, and
5
+ the result-dict shape so the FSM specialist can consume the output
6
+ without surprises. Real end-to-end smoke testing happens in commit 4
7
+ once the FSM action wires this in and a chip cache is available.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ import os
13
+
14
+ import pytest
15
+
16
+
17
+ def _reload_with_env(**env):
18
+ """Reimport the module with mutated environment so module-level
19
+ constants (ENABLE, DEVICE) re-evaluate."""
20
+ for k, v in env.items():
21
+ if v is None:
22
+ os.environ.pop(k, None)
23
+ else:
24
+ os.environ[k] = v
25
+ import app.context.terramind_nyc as m
26
+ return importlib.reload(m)
27
+
28
+
29
+ def test_module_imports_without_loading_weights():
30
+ """Importing the module must not download or build the base model."""
31
+ m = _reload_with_env()
32
+ # Adapter cache empty by default.
33
+ assert m._ADAPTERS == {}
34
+ assert {"lulc", "buildings"} <= set(m.ADAPTER_SPECS)
35
+ assert m.ADAPTERS_REPO == "msradam/TerraMind-NYC-Adapters"
36
+
37
+
38
+ def test_disabled_returns_skipped_outcome():
39
+ m = _reload_with_env(RIPRAP_TERRAMIND_NYC_ENABLE="0")
40
+ assert m.ENABLE is False
41
+ out = m.lulc(None)
42
+ assert out == {"ok": False, "skipped": "RIPRAP_TERRAMIND_NYC_ENABLE=0"}
43
+ out = m.buildings(None, s1rtc=None, dem=None)
44
+ assert out == {"ok": False, "skipped": "RIPRAP_TERRAMIND_NYC_ENABLE=0"}
45
+ # Restore default for the rest of the suite.
46
+ _reload_with_env(RIPRAP_TERRAMIND_NYC_ENABLE="1")
47
+
48
+
49
+ def test_unknown_adapter_raises_keyerror():
50
+ m = _reload_with_env(RIPRAP_TERRAMIND_NYC_ENABLE="1")
51
+ with pytest.raises(KeyError):
52
+ m._ensure_adapter("nonsense")
53
+
54
+
55
+ def test_summarize_lulc_shape():
56
+ """_summarize_lulc emits the dict shape the FSM doc-builder will
57
+ consume — class fractions, dominant class, dominant pct, n_pixels."""
58
+ import numpy as np
59
+ m = _reload_with_env()
60
+ pred = np.array([[0, 0, 0],
61
+ [2, 2, 2],
62
+ [4, 4, 4]])
63
+ labels = ["Trees", "Cropland", "Built", "Bare", "Water"]
64
+ out = m._summarize_lulc(pred, labels)
65
+ assert out["ok"] is True
66
+ assert out["n_pixels"] == 9
67
+ assert out["shape"] == [3, 3]
68
+ # Three classes appeared, equally; dominant is the FIRST in argmax tie.
69
+ assert set(out["class_fractions"]) == {"Trees", "Built", "Water"}
70
+ for v in out["class_fractions"].values():
71
+ assert v == pytest.approx(33.33, abs=0.1)
72
+ assert out["dominant_class"] in {"Trees", "Built", "Water"}
73
+ assert out["dominant_pct"] > 0
74
+
75
+
76
+ def test_summarize_buildings_shape():
77
+ import numpy as np
78
+ m = _reload_with_env()
79
+ pred = np.array([[0, 0, 1],
80
+ [0, 1, 1],
81
+ [0, 0, 0]])
82
+ labels = ["Background", "Building footprint"]
83
+ out = m._summarize_buildings(pred, labels)
84
+ assert out["ok"] is True
85
+ assert out["n_pixels"] == 9
86
+ assert out["pct_buildings"] == pytest.approx(33.33, abs=0.1)
87
+ assert out["class_labels"] == labels
88
+ # scipy.ndimage may or may not be installed; the helper degrades
89
+ # rather than raising. If it's installed, two diagonal/adjacent
90
+ # building pixels should land in one connected component.
91
+ assert out["n_building_components"] in {None, 1}
92
+
93
+
94
+ def test_public_api_signatures():
95
+ m = _reload_with_env()
96
+ import inspect
97
+ for fn in (m.lulc, m.buildings):
98
+ sig = inspect.signature(fn)
99
+ params = list(sig.parameters)
100
+ # Caller may pass S2 only OR S2+S1+DEM.
101
+ assert params[0] == "s2l2a"
102
+ assert "s1rtc" in params
103
+ assert "dem" in params
104
+
105
+
106
+ def test_warm_is_no_op_when_disabled():
107
+ """warm() must not download anything when ENABLE=0 or deps missing."""
108
+ m = _reload_with_env(RIPRAP_TERRAMIND_NYC_ENABLE="0")
109
+ # No exceptions, no side effects.
110
+ m.warm()
111
+ assert m._ADAPTERS == {}
112
+ _reload_with_env(RIPRAP_TERRAMIND_NYC_ENABLE="1")