Add demo raster workflow smoke
This commit is contained in:
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Sprint 98 demo raster workflow smoke (2026-06-23)
|
||||||
|
|
||||||
|
- Added `scripts/verify_demo_raster_workflow.sh` to validate the browser-facing demo raster happy path: inspect, preview, stats and tile manifest generation.
|
||||||
|
- Added readiness syntax coverage for the raster workflow smoke.
|
||||||
|
- Fixed raster tiling manifest generation for Rasterio versions that return window bounds as tuples instead of bound objects.
|
||||||
|
- Added regression coverage for tuple-based raster window bounds and the new raster smoke contract.
|
||||||
|
- No AI inference, external provider fetching, migrations or API route changes were introduced.
|
||||||
|
|
||||||
## Sprint 97 demo raster fixture workflow (2026-06-23)
|
## Sprint 97 demo raster fixture workflow (2026-06-23)
|
||||||
|
|
||||||
- Added a deterministic local GeoTIFF raster fixture to the offline demo workflow so raster controls and AI Lab dataset prerequisites have usable V1 context.
|
- Added a deterministic local GeoTIFF raster fixture to the offline demo workflow so raster controls and AI Lab dataset prerequisites have usable V1 context.
|
||||||
|
|||||||
@@ -199,6 +199,13 @@ class RasterOperationsService:
|
|||||||
preview_width = 1
|
preview_width = 1
|
||||||
return preview_width, preview_height
|
return preview_width, preview_height
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _window_bounds_to_list(bounds: Any) -> list[float]:
|
||||||
|
if isinstance(bounds, (list, tuple)) and len(bounds) == 4:
|
||||||
|
left, bottom, right, top = bounds
|
||||||
|
return [float(left), float(bottom), float(right), float(top)]
|
||||||
|
return [float(bounds.left), float(bounds.bottom), float(bounds.right), float(bounds.top)]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_preview_data(data: Any) -> Any:
|
def _normalize_preview_data(data: Any) -> Any:
|
||||||
try:
|
try:
|
||||||
@@ -972,7 +979,7 @@ class RasterOperationsService:
|
|||||||
{
|
{
|
||||||
"path": str(tile_path),
|
"path": str(tile_path),
|
||||||
"pixel_window": [int(xoff), int(yoff), int(tile_width), int(tile_height)],
|
"pixel_window": [int(xoff), int(yoff), int(tile_width), int(tile_height)],
|
||||||
"bounds": [float(bounds.left), float(bounds.bottom), float(bounds.right), float(bounds.top)],
|
"bounds": RasterOperationsService._window_bounds_to_list(bounds),
|
||||||
"transform": [float(item) for item in transform.to_gdal()],
|
"transform": [float(item) for item in transform.to_gdal()],
|
||||||
"index": tile_index,
|
"index": tile_index,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -599,7 +599,12 @@ def test_raster_tile_returns_manifest_payload(monkeypatch, tmp_path) -> None:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def bounds(window: FakeWindow, _source_transform):
|
def bounds(window: FakeWindow, _source_transform):
|
||||||
return FakeWindowBounds(window.xoff, window.yoff, window.width, window.height)
|
return (
|
||||||
|
float(window.xoff),
|
||||||
|
float(window.yoff),
|
||||||
|
float(window.xoff + window.width),
|
||||||
|
float(window.yoff + window.height),
|
||||||
|
)
|
||||||
|
|
||||||
class FakeSource:
|
class FakeSource:
|
||||||
width = 10
|
width = 10
|
||||||
@@ -666,6 +671,7 @@ def test_raster_tile_returns_manifest_payload(monkeypatch, tmp_path) -> None:
|
|||||||
assert payload["manifest"]["source_dataset_id"] == str(dataset_id)
|
assert payload["manifest"]["source_dataset_id"] == str(dataset_id)
|
||||||
assert payload["manifest"]["source_raster_id"] == str(dataset_id)
|
assert payload["manifest"]["source_raster_id"] == str(dataset_id)
|
||||||
assert payload["manifest"]["count"] == payload["count"]
|
assert payload["manifest"]["count"] == payload["count"]
|
||||||
|
assert payload["manifest"]["tiles"][0]["bounds"] == [0.0, 0.0, 4.0, 4.0]
|
||||||
assert payload["manifest"]["ai_inference"] is False
|
assert payload["manifest"]["ai_inference"] is False
|
||||||
assert payload["manifest"]["tile_server"] is None
|
assert payload["manifest"]["tile_server"] is None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_raster_workflow_smoke_is_registered_and_checks_core_raster_path() -> None:
|
||||||
|
script_path = ROOT / "scripts" / "verify_demo_raster_workflow.sh"
|
||||||
|
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert script_path.exists()
|
||||||
|
script = script_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "bash -n scripts/verify_demo_raster_workflow.sh" in readiness
|
||||||
|
assert "/api/v1/demo/workflow" in script
|
||||||
|
assert "data.raster_dataset_id" in script
|
||||||
|
assert "/raster/inspect" in script
|
||||||
|
assert "/raster/preview" in script
|
||||||
|
assert "/raster/stats" in script
|
||||||
|
assert "/raster/tile" in script
|
||||||
|
assert "demo_context_raster.tif" in script
|
||||||
|
assert "EPSG:4326" in script
|
||||||
|
assert "manifest_path" in script
|
||||||
|
assert "tile_paths" in script
|
||||||
|
assert "raster.tile" in script
|
||||||
|
assert "Response is not a canonical GeoIntel data envelope" in script
|
||||||
@@ -1,3 +1,37 @@
|
|||||||
|
## Sprint 98 demo raster workflow smoke (2026-06-23)
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
- Added `scripts/verify_demo_raster_workflow.sh`, a browser-facing runtime smoke for the seeded `demo_context_raster.tif` fixture.
|
||||||
|
- Added the new smoke to readiness syntax checks.
|
||||||
|
- Hardened `RasterOperationsService.tile` so tile manifest bounds accept both Rasterio tuple bounds and object bounds.
|
||||||
|
- Updated `backend/tests/test_raster_operations_service.py` to cover tuple-based window bounds.
|
||||||
|
- Added `backend/tests/test_sprint98_raster_workflow_smoke.py`.
|
||||||
|
- Updated `scripts/README.md` and `CHANGELOG.md`.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- RED: `python -m pytest backend\tests\test_sprint98_raster_workflow_smoke.py -q` failed before implementation because `scripts/verify_demo_raster_workflow.sh` did not exist.
|
||||||
|
- `python -m pytest backend\tests\test_sprint98_raster_workflow_smoke.py backend\tests\test_readiness_gate.py -q` passed: 14 tests.
|
||||||
|
- `bash -n scripts/verify_demo_raster_workflow.sh` passed.
|
||||||
|
- Live diagnostic run of `bash scripts/verify_demo_raster_workflow.sh http://192.168.10.150:1202` showed inspect, preview and stats passed, then raster tile failed with HTTP 500.
|
||||||
|
- Tower backend logs identified the root cause: `rasterio.windows.bounds(...)` returned a tuple, while tile manifest generation expected `.left/.bottom/.right/.top` attributes.
|
||||||
|
- RED: `python -m pytest backend\tests\test_raster_operations_service.py::test_raster_tile_returns_manifest_payload -q` reproduced the live `AttributeError` after updating the fixture to tuple bounds.
|
||||||
|
- `python -m pytest backend\tests\test_raster_operations_service.py::test_raster_tile_returns_manifest_payload backend\tests\test_sprint98_raster_workflow_smoke.py -q` passed: 2 tests.
|
||||||
|
- `python -m compileall backend/app` passed.
|
||||||
|
- `python -m pytest -q` from `backend/` passed: 321 tests.
|
||||||
|
- `cd frontend && npm run typecheck` passed.
|
||||||
|
- `cd frontend && npm run build` passed.
|
||||||
|
- `bash scripts/run_readiness_check.sh` passed.
|
||||||
|
- `cd backend && python -m alembic heads` passed: `202606120900 (head)`.
|
||||||
|
- `cd backend && python -m alembic upgrade head --sql` passed.
|
||||||
|
- `bash -n scripts/live_migration_smoke.sh` passed.
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
- The raster smoke creates a small tile set each run. It is appropriate for local/runtime validation, not high-volume benchmarking.
|
||||||
|
- The smoke intentionally does not run AI inference or external imagery/provider fetching.
|
||||||
|
|
||||||
|
Next recommended pass:
|
||||||
|
- Run full readiness, redeploy, then verify the raster smoke live against `http://192.168.10.150:1202`.
|
||||||
|
|
||||||
## Sprint 97 demo raster fixture workflow (2026-06-23)
|
## Sprint 97 demo raster fixture workflow (2026-06-23)
|
||||||
|
|
||||||
Changed:
|
Changed:
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ persisted QA/QC result is compared against `fixtures/golden/expected_qa_metrics.
|
|||||||
so runtime demo precision, recall, F1, mean IoU and false-positive/negative
|
so runtime demo precision, recall, F1, mean IoU and false-positive/negative
|
||||||
counts cannot drift silently.
|
counts cannot drift silently.
|
||||||
|
|
||||||
|
Verify the explicit demo raster workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/verify_demo_raster_workflow.sh http://192.168.10.150:1202
|
||||||
|
```
|
||||||
|
|
||||||
|
The raster smoke is intentionally mutating and idempotent enough for local
|
||||||
|
runtime checks: it seeds the offline demo workflow, validates the
|
||||||
|
`demo_context_raster.tif` fixture dataset, then exercises raster inspect,
|
||||||
|
preview, stats and one small tile/manifest generation through canonical
|
||||||
|
`data` envelopes. It does not run AI inference or fetch external imagery.
|
||||||
|
|
||||||
Verify that the browser-facing workbench can populate the default demo start
|
Verify that the browser-facing workbench can populate the default demo start
|
||||||
state through the frontend proxy:
|
state through the frontend proxy:
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ ${PYTHON_BIN} -m compileall backend/app
|
|||||||
bash -n scripts/live_migration_smoke.sh
|
bash -n scripts/live_migration_smoke.sh
|
||||||
bash -n scripts/verify_browser_runtime.sh
|
bash -n scripts/verify_browser_runtime.sh
|
||||||
bash -n scripts/verify_demo_export_workflow.sh
|
bash -n scripts/verify_demo_export_workflow.sh
|
||||||
|
bash -n scripts/verify_demo_raster_workflow.sh
|
||||||
bash -n scripts/verify_workbench_default_state.sh
|
bash -n scripts/verify_workbench_default_state.sh
|
||||||
bash -n scripts/verify_workbench_interactions.sh
|
bash -n scripts/verify_workbench_interactions.sh
|
||||||
bash -n scripts/verify_gis_runtime.sh
|
bash -n scripts/verify_gis_runtime.sh
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${1:-http://localhost:1202}"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${TMP_DIR}"' EXIT
|
||||||
|
|
||||||
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
|
echo "curl is required for demo raster workflow verification" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${PYTHON_BIN:-}" ]; then
|
||||||
|
PYTHON_BIN="${PYTHON_BIN}"
|
||||||
|
else
|
||||||
|
PYTHON_BIN=""
|
||||||
|
for candidate in python3 python.exe python; do
|
||||||
|
if command -v "${candidate}" >/dev/null 2>&1 && "${candidate}" -c "import json, sys" >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="${candidate}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${PYTHON_BIN}" ]; then
|
||||||
|
echo "A Python interpreter is required for JSON parsing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
json_field() {
|
||||||
|
local file_path="$1"
|
||||||
|
local expression="$2"
|
||||||
|
"${PYTHON_BIN}" - "$file_path" "$expression" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path, expression = sys.argv[1], sys.argv[2]
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
value = payload
|
||||||
|
for part in expression.split("."):
|
||||||
|
if part:
|
||||||
|
value = value[part]
|
||||||
|
print(value)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
require_json_data() {
|
||||||
|
local file_path="$1"
|
||||||
|
"${PYTHON_BIN}" - "$file_path" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
if "data" not in payload:
|
||||||
|
raise SystemExit("Response is not a canonical GeoIntel data envelope")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== GeoIntel demo raster workflow verification =="
|
||||||
|
echo "Base URL: ${BASE_URL}"
|
||||||
|
|
||||||
|
curl -fsS -X POST "${BASE_URL%/}/api/v1/demo/workflow" > "${TMP_DIR}/demo.json"
|
||||||
|
require_json_data "${TMP_DIR}/demo.json"
|
||||||
|
project_id="$(json_field "${TMP_DIR}/demo.json" "data.project_id")"
|
||||||
|
raster_dataset_id="$(json_field "${TMP_DIR}/demo.json" "data.raster_dataset_id")"
|
||||||
|
|
||||||
|
if [ -z "${raster_dataset_id}" ] || [ "${raster_dataset_id}" = "None" ] || [ "${raster_dataset_id}" = "null" ]; then
|
||||||
|
echo "Demo workflow did not return a raster_dataset_id" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets" > "${TMP_DIR}/datasets.json"
|
||||||
|
require_json_data "${TMP_DIR}/datasets.json"
|
||||||
|
"${PYTHON_BIN}" - "${TMP_DIR}/datasets.json" "${raster_dataset_id}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path, raster_id = sys.argv[1], sys.argv[2]
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
items = json.load(handle)["data"]["items"]
|
||||||
|
raster = next((item for item in items if item["id"] == raster_id), None)
|
||||||
|
if not raster:
|
||||||
|
raise SystemExit("Demo raster dataset is missing from dataset list")
|
||||||
|
if raster.get("name") != "demo_context_raster.tif":
|
||||||
|
raise SystemExit(f"Unexpected demo raster name: {raster.get('name')}")
|
||||||
|
if raster.get("dataset_type") != "raster":
|
||||||
|
raise SystemExit(f"Unexpected demo raster dataset_type: {raster.get('dataset_type')}")
|
||||||
|
if raster.get("source_name") != "fixture":
|
||||||
|
raise SystemExit(f"Unexpected demo raster source_name: {raster.get('source_name')}")
|
||||||
|
if raster.get("status") != "ready":
|
||||||
|
raise SystemExit(f"Unexpected demo raster status: {raster.get('status')}")
|
||||||
|
if raster.get("crs") != "EPSG:4326":
|
||||||
|
raise SystemExit(f"Unexpected demo raster CRS: {raster.get('crs')}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${raster_dataset_id}/raster/inspect" > "${TMP_DIR}/inspect.json"
|
||||||
|
require_json_data "${TMP_DIR}/inspect.json"
|
||||||
|
"${PYTHON_BIN}" - "${TMP_DIR}/inspect.json" "${raster_dataset_id}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path, raster_id = sys.argv[1], sys.argv[2]
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)["data"]
|
||||||
|
if data.get("dataset_id") != raster_id:
|
||||||
|
raise SystemExit("Raster inspect returned the wrong dataset_id")
|
||||||
|
if data.get("ready") is not True:
|
||||||
|
raise SystemExit("Raster inspect did not report ready=true")
|
||||||
|
metadata = data.get("metadata") or {}
|
||||||
|
if metadata.get("crs") != "EPSG:4326":
|
||||||
|
raise SystemExit(f"Raster inspect metadata CRS drifted: {metadata.get('crs')}")
|
||||||
|
if int(metadata.get("width") or 0) < 1 or int(metadata.get("height") or 0) < 1:
|
||||||
|
raise SystemExit("Raster inspect metadata does not expose width/height")
|
||||||
|
PY
|
||||||
|
|
||||||
|
curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${raster_dataset_id}/raster/preview" > "${TMP_DIR}/preview.json"
|
||||||
|
require_json_data "${TMP_DIR}/preview.json"
|
||||||
|
"${PYTHON_BIN}" - "${TMP_DIR}/preview.json" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], "r", encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)["data"]
|
||||||
|
if data.get("ready") is not True:
|
||||||
|
raise SystemExit("Raster preview did not report ready=true")
|
||||||
|
preview = data.get("preview") or {}
|
||||||
|
if str(preview.get("format") or "").lower() != "png":
|
||||||
|
raise SystemExit(f"Unexpected preview format: {preview.get('format')}")
|
||||||
|
if int(preview.get("width") or 0) < 1 or int(preview.get("height") or 0) < 1:
|
||||||
|
raise SystemExit("Raster preview did not expose image dimensions")
|
||||||
|
PY
|
||||||
|
|
||||||
|
curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${raster_dataset_id}/raster/stats" > "${TMP_DIR}/stats.json"
|
||||||
|
require_json_data "${TMP_DIR}/stats.json"
|
||||||
|
"${PYTHON_BIN}" - "${TMP_DIR}/stats.json" "${raster_dataset_id}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path, raster_id = sys.argv[1], sys.argv[2]
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)["data"]
|
||||||
|
if data.get("dataset_id") != raster_id:
|
||||||
|
raise SystemExit("Raster stats returned the wrong dataset_id")
|
||||||
|
bands = data.get("bands") or []
|
||||||
|
if not bands:
|
||||||
|
raise SystemExit("Raster stats did not report band statistics")
|
||||||
|
band = bands[0]
|
||||||
|
if band.get("valid_pixel_count", 0) < 1:
|
||||||
|
raise SystemExit("Raster stats valid_pixel_count is empty")
|
||||||
|
if band.get("min") is None or band.get("max") is None:
|
||||||
|
raise SystemExit("Raster stats min/max are missing")
|
||||||
|
PY
|
||||||
|
|
||||||
|
curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${raster_dataset_id}/raster/tile" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tile_size":64,"overlap":0,"output_name":"demo_raster_smoke_tiles"}' > "${TMP_DIR}/tile.json"
|
||||||
|
require_json_data "${TMP_DIR}/tile.json"
|
||||||
|
"${PYTHON_BIN}" - "${TMP_DIR}/tile.json" "${raster_dataset_id}" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path, raster_id = sys.argv[1], sys.argv[2]
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
job = json.load(handle)["data"]
|
||||||
|
if job.get("job_type") != "raster.tile":
|
||||||
|
raise SystemExit(f"Unexpected tile job_type: {job.get('job_type')}")
|
||||||
|
if job.get("status") != "completed":
|
||||||
|
raise SystemExit(f"Raster tile job did not complete: {job.get('status')}")
|
||||||
|
result = job.get("result_json") or {}
|
||||||
|
if result.get("dataset_id") != raster_id:
|
||||||
|
raise SystemExit("Raster tile result returned the wrong dataset_id")
|
||||||
|
if result.get("ready") is not True:
|
||||||
|
raise SystemExit("Raster tile result did not report ready=true")
|
||||||
|
if result.get("operation") != "raster.tile":
|
||||||
|
raise SystemExit(f"Unexpected raster tile operation: {result.get('operation')}")
|
||||||
|
if not result.get("manifest_path"):
|
||||||
|
raise SystemExit("Raster tile result is missing manifest_path")
|
||||||
|
manifest = result.get("manifest") or {}
|
||||||
|
tile_paths = manifest.get("tile_paths") or []
|
||||||
|
tiles = manifest.get("tiles") or []
|
||||||
|
if result.get("count") != len(tile_paths) or len(tile_paths) != len(tiles):
|
||||||
|
raise SystemExit("Raster tile manifest count does not match tile_paths/tiles")
|
||||||
|
if not tile_paths:
|
||||||
|
raise SystemExit("Raster tile manifest did not include any tile paths")
|
||||||
|
if manifest.get("source_dataset_id") != raster_id:
|
||||||
|
raise SystemExit("Raster tile manifest source_dataset_id drifted")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "Demo raster workflow verification passed"
|
||||||
|
echo "Project: ${project_id}"
|
||||||
|
echo "Raster dataset: ${raster_dataset_id}"
|
||||||
|
echo "Operations: inspect, preview, stats and raster.tile"
|
||||||
Reference in New Issue
Block a user