Add Mol multi-zone operational validation

This commit is contained in:
Codex
2026-07-13 18:12:48 +02:00
parent 4539e92bcf
commit 2c16ff2bc0
16 changed files with 605 additions and 12 deletions
+8
View File
@@ -7,6 +7,14 @@
# Changelog
## Sprint 178 Mol multi-zone operational validation (2026-07-13)
- Added documented Mol center, Achterbos, Gompel, Donk and Postel operator zones with municipality and operational-zone provenance.
- Protected the four new positive zones as validation holdouts; they are not silently eligible for model training.
- Hardened real-data quality workflows to persist manifest-backed EPSG:4326 Areas and Mol project regions alongside datasets and analysis evidence.
- Added a single Mol operator runner that composes existing positive QA/QC and background detection-pressure workflows without fake metrics or model downloads.
- Added all-in-one image/readiness wiring and focused regression coverage; APIs, migrations, model activation and frontend behavior remain unchanged.
## Sprint 177 Mol-first operating context (2026-07-13)
- Made Mol the explicit primary operating focus while retaining the broader Kempen as the validation and interoperability region.
+13
View File
@@ -460,6 +460,13 @@ manifests generated for AI handoff include source CRS metadata so pixel-space
model outputs can be transformed to WGS84 GeoJSON coordinates. Current V1 upload
support is limited to GeoTIFF-style rasters and GeoJSON/JSON reference vectors.
When `REAL_AREA_BBOX=minx,miny,maxx,maxy` is supplied, the same workflow also
persists an EPSG:4326 project Area before uploading data. `REAL_AREA_NAME` and
`REAL_PROJECT_REGION` retain operator context. The multi-sample runner fills
these values from manifest `wgs84_bbox` and municipality metadata, so generated
projects are immediately usable in the map without an alternate persistence
path or API contract.
To prepare the documented operator sample corpus inside the all-in-one runtime
container, run:
@@ -477,6 +484,12 @@ empty GRB FeatureCollections for negative-tile training; normal reference AOIs
still fail when GRB returns no buildings. These are runtime artifacts only and
are not committed to Git.
Mol additionally has operational holdouts for Achterbos, Gompel, Donk and
Postel, with Mol center as the historical baseline and Postel-bos as a separate
background control. Prepare and execute that pack with the documented
`prepare_operator_real_data_samples.py` and
`run_mol_operational_validation.sh` commands in `scripts/README.md`.
The current recommended local building model is
`geointel-building-yolov8s-smallbld-minpx3-img640-ft30-pt` with tile size
`512`, overlap `64` and confidence threshold `0.15`. Its SHA256 is
@@ -75,6 +75,7 @@ def test_all_in_one_dockerfile_copies_operator_scripts_for_runtime_use() -> None
"verify_real_data_detection_qa_workflow.sh",
"run_detection_quality_matrix.sh",
"run_multi_sample_detection_quality_matrix.sh",
"run_mol_operational_validation.sh",
"export_detection_calibration_evidence.sh",
"assemble_detection_calibration_evidence_portfolio.sh",
"build_fixed_threshold_evidence_portfolio_inputs.py",
@@ -0,0 +1,114 @@
from __future__ import annotations
import importlib.util
import math
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[2]
def load_sample_preparer():
script_path = ROOT / "scripts" / "prepare_operator_real_data_samples.py"
spec = importlib.util.spec_from_file_location("mol_operational_sample_preparer", script_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def distance_m(left, right) -> float:
radius_m = 6_371_008.8
left_lat = math.radians(left.center_lat)
right_lat = math.radians(right.center_lat)
delta_lat = right_lat - left_lat
delta_lon = math.radians(right.center_lon - left.center_lon)
haversine = (
math.sin(delta_lat / 2) ** 2
+ math.cos(left_lat) * math.cos(right_lat) * math.sin(delta_lon / 2) ** 2
)
return 2 * radius_m * math.asin(math.sqrt(haversine))
def test_mol_operational_registry_has_distinct_real_world_zones_and_holdouts() -> None:
module = load_sample_preparer()
expected = {
"mol": "center",
"mol_achterbos": "residential",
"mol_gompel": "mixed_settlement",
"mol_donk": "canal_industrial",
"mol_postel": "rural_village",
}
assert tuple(expected) == module.MOL_OPERATIONAL_SAMPLE_SLUGS
assert module.MOL_BACKGROUND_CONTROL_SAMPLE_SLUGS == ("postel_bos",)
assert module.MOL_OPERATIONAL_VALIDATION_SAMPLE_SLUGS == frozenset(expected) - {"mol"}
samples = [module.SAMPLES[slug] for slug in expected]
assert all(sample.municipality == "Mol" for sample in samples)
assert {sample.operational_zone for sample in samples} == set(expected.values())
assert all(5.09 < sample.center_lon < 5.20 for sample in samples)
assert all(51.18 < sample.center_lat < 51.30 for sample in samples)
assert all(
module.recommended_split_for_sample(module.SAMPLES[slug]) == "val"
for slug in module.MOL_OPERATIONAL_VALIDATION_SAMPLE_SLUGS
)
new_holdouts = [module.SAMPLES[slug] for slug in module.MOL_OPERATIONAL_VALIDATION_SAMPLE_SLUGS]
assert min(
distance_m(left, right)
for index, left in enumerate(new_holdouts)
for right in new_holdouts[index + 1 :]
) >= 1_500
def test_mol_sample_metadata_is_persisted_in_operator_manifest_records(tmp_path: Path, monkeypatch) -> None:
module = load_sample_preparer()
sample = module.SAMPLES["mol_achterbos"]
monkeypatch.setattr(module, "sample_bounds", lambda _sample: ((1.0, 2.0, 3.0, 4.0), [5.0, 51.0, 5.1, 51.1]))
monkeypatch.setattr(module, "sample_artifact_paths", lambda _sample, _output: (tmp_path / "ortho.tif", tmp_path / "reference.geojson"))
monkeypatch.setattr(module, "fetch_orthophoto", lambda *_args: "https://example.test/ortho")
monkeypatch.setattr(module, "fetch_reference", lambda *_args, **_kwargs: ("https://example.test/grb", 42))
monkeypatch.setattr(module, "raster_summary", lambda _path: {"crs": "EPSG:31370"})
prepared = module.prepare_sample(sample, tmp_path, force=True)
assert prepared["municipality"] == "Mol"
assert prepared["operational_zone"] == "residential"
assert prepared["recommended_split"] == "val"
assert prepared["wgs84_bbox"] == [5.0, 51.0, 5.1, 51.1]
def test_real_data_matrix_propagates_project_region_and_persisted_area() -> None:
workflow = (ROOT / "scripts" / "verify_real_data_detection_qa_workflow.sh").read_text(encoding="utf-8")
matrix = (ROOT / "scripts" / "run_detection_quality_matrix.sh").read_text(encoding="utf-8")
multi = (ROOT / "scripts" / "run_multi_sample_detection_quality_matrix.sh").read_text(encoding="utf-8")
assert 'REAL_PROJECT_REGION="${REAL_PROJECT_REGION:-Kempen}"' in workflow
assert 'REAL_AREA_BBOX="${REAL_AREA_BBOX:-}"' in workflow
assert '/api/v1/projects/${project_id}/areas' in workflow
assert 'echo "Area: ${area_id}"' in workflow
assert 'REAL_PROJECT_REGION="${REAL_PROJECT_REGION}"' in matrix
assert 'REAL_AREA_BBOX="${REAL_AREA_BBOX}"' in matrix
assert 'REAL_AREA_BBOX="${wgs84_bbox}"' in multi
assert 'REAL_AREA_NAME="${sample_slug} AOI"' in multi
assert 'project_region="Mol, Kempen"' in multi
assert 'REAL_PROJECT_REGION="${project_region}"' in multi
def test_mol_operational_runner_uses_real_positive_qa_and_background_paths() -> None:
runner = (ROOT / "scripts" / "run_mol_operational_validation.sh").read_text(encoding="utf-8")
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
assert "mol_achterbos mol_gompel mol_donk mol_postel" in runner
assert "run_multi_sample_detection_quality_matrix.sh" in runner
assert "run_operator_hard_negative_detection_matrix.sh" in runner
assert "mol_operational_validation_summary.json" in runner
assert "fixture_mode" not in runner
assert "manual-fixture-detector" not in runner
assert "bash -n scripts/run_mol_operational_validation.sh" in readiness
assert "COPY scripts/run_mol_operational_validation.sh" in dockerfile
+2
View File
@@ -79,6 +79,7 @@ COPY scripts/train_operator_yolo_detector.sh /app/scripts/train_operator_yolo_de
COPY scripts/verify_real_data_detection_qa_workflow.sh /app/scripts/verify_real_data_detection_qa_workflow.sh
COPY scripts/run_detection_quality_matrix.sh /app/scripts/run_detection_quality_matrix.sh
COPY scripts/run_multi_sample_detection_quality_matrix.sh /app/scripts/run_multi_sample_detection_quality_matrix.sh
COPY scripts/run_mol_operational_validation.sh /app/scripts/run_mol_operational_validation.sh
COPY scripts/export_detection_calibration_evidence.sh /app/scripts/export_detection_calibration_evidence.sh
COPY scripts/assemble_detection_calibration_evidence_portfolio.sh /app/scripts/assemble_detection_calibration_evidence_portfolio.sh
COPY scripts/build_fixed_threshold_evidence_portfolio_inputs.py /app/scripts/build_fixed_threshold_evidence_portfolio_inputs.py
@@ -102,6 +103,7 @@ RUN chmod +x /usr/local/bin/geointel-all-in-one-start \
/app/scripts/verify_real_data_detection_qa_workflow.sh \
/app/scripts/run_detection_quality_matrix.sh \
/app/scripts/run_multi_sample_detection_quality_matrix.sh \
/app/scripts/run_mol_operational_validation.sh \
/app/scripts/export_detection_calibration_evidence.sh \
/app/scripts/assemble_detection_calibration_evidence_portfolio.sh \
/app/scripts/run_operator_hard_negative_detection_matrix.sh \
+15
View File
@@ -227,6 +227,21 @@ the first 1000-feature page. Generated reference GeoJSON records
`reference_max_features` and `source_urls` for auditability. The application
itself still does not perform live provider fetching.
### Mol operational validation pack
The operator registry includes a Mol-first validation pack: Mol center,
Achterbos residential, Gompel mixed settlement, Donk canal/industrial and
Postel rural village. The four new zones are marked as validation holdouts so
future training exports cannot silently consume the operational benchmark.
Postel-bos is evaluated separately as a background control.
`run_mol_operational_validation.sh` composes the existing positive multi-sample
quality matrix and background detection matrix. Positive runs use persisted GRB
`vector_features` and create real `QualityCheck`/`Metric` rows; background runs
only report persisted detection pressure and never synthesize QA metrics. AOI
bounds from the operator manifest are persisted as EPSG:4326 `Area` records so
every generated project opens as a complete map context.
For confidence-threshold calibration, use the sweep wrapper:
```bash
+20
View File
@@ -7267,3 +7267,23 @@ Open:
- Live Mol context contains a ready EPSG:31370 1024x1024 orthophoto and `1,993` ready EPSG:4326 GRB reference buildings.
- Persisted detection QA remains unchanged: precision `0.6011`, recall `0.5891`, F1 `0.5950`, mean IoU `0.4554`, `779` false positives and `819` false negatives.
- In-app browser verification selected the Mol project, Mol AOI and Mol reference dataset automatically. MapLibre rendered the existing `1,953`-feature detection layer over the road basemap with one canvas, zero browser warnings/errors and no horizontal document overflow at `1265x720`.
# Sprint 178 - Mol multi-zone operational validation
## Implementation
- Added Mol center, Achterbos, Gompel, Donk and Postel as explicit operator contexts with municipality and operational-zone provenance.
- Marked Achterbos, Gompel, Donk and Postel as validation holdouts to keep them outside future training exports unless the split policy is deliberately changed.
- Kept Postel-bos as a separate background control, so an empty/sparse context is never assigned fabricated precision, recall or F1.
- Extended the existing real-data workflow with optional project region and EPSG:4326 AOI bounds; manifest-backed positive and background projects now open map-ready with persisted Areas.
- Added `run_mol_operational_validation.sh` to compose the existing positive QA matrix and background detection-pressure matrix and emit one evidence summary.
- Included the new runner in the all-in-one runtime and release-readiness syntax gate. No API route, ORM model, migration, model activation or frontend behavior changed.
## Initial validation
- Focused Mol/operator/workflow/Docker regressions: `43 passed`.
- Changed Python operator preparer compiled successfully.
- All five affected shell workflows passed `bash -n`.
- `git diff --check`: clean apart from the existing Windows line-ending notice for the all-in-one Dockerfile.
- `bash scripts/run_readiness_check.sh`: passed with `485` backend tests, contract audit, single Alembic head, frontend typecheck and production build.
- Frontend bundle sizes are unchanged: app `217.69 kB`, React vendor `140.74 kB` and MapLibre `801.82 kB` before gzip.
+1
View File
@@ -19,6 +19,7 @@ This file now starts with the current implementation status. Older preparation/b
## Current implementation status
- [x] Make Mol the primary workbench, AOI and operator-sample context while preserving broader Kempen coverage.
- [x] Add a Mol multi-zone operational pack with independent positive holdouts, background control and persisted map-ready AOIs.
- [x] Backend FastAPI foundation, health endpoint and service structure.
- [x] React/TypeScript frontend foundation and MapLibre workbench.
- [x] Map layer visibility, opacity and feature property inspection.
+38
View File
@@ -203,6 +203,41 @@ GeoJSON files record `reference_pages_fetched`, `reference_truncated`,
`reference_page_limit`, `reference_max_features` and every fetched
`source_urls` page for auditability.
Mol has a dedicated operational pack with five positive contexts: center,
Achterbos residential, Gompel mixed settlement, Donk canal/industrial and
Postel rural village. The four new contexts are validation holdouts and are not
silently added to training. Postel-bos remains a separate background control.
Prepare the 1 km / 1024 px pack explicitly:
```bash
docker exec -it geointel python3 /app/scripts/prepare_operator_real_data_samples.py \
--output-dir /app/storage/operator-data/mol-operational-1024 \
--samples mol,mol_achterbos,mol_gompel,mol_donk,mol_postel,postel_bos \
--width 1024 \
--height 1024 \
--half-size-scale 2 \
--force
```
Then run the existing persisted positive QA and background-control paths as one
operator command:
```bash
docker exec -it \
-e OPERATOR_SAMPLE_MANIFEST_PATH=/app/storage/operator-data/mol-operational-1024/operator_samples_manifest.json \
-e MOL_VALIDATION_OUTPUT_DIR=/app/artifacts/mol-operational-validation/current \
geointel bash /app/scripts/run_mol_operational_validation.sh http://127.0.0.1
```
The runner defaults to the active local model at tile `512`, overlap `64`,
confidence `0.15` and QA IoU `0.25`. Every positive run persists Project, Area,
Dataset, Job, AnalysisRun, Detection, QualityCheck, Metric and Export records.
The background run persists its project, AOI, raster, job, analysis and
detections but intentionally does not invent QA metrics for an empty or sparse
reference context. The combined JSON/Markdown summary reports
`evidence_ready`; this records completed evidence and is not an automatic model
promotion decision.
For model-training candidates, prepare a larger operator-only sample manifest so
tile overlap can create meaningful context instead of one tile per source
raster:
@@ -301,6 +336,9 @@ plus a combined `multi_sample_quality_summary.json` with
`best_overall_by_precision` and `best_by_sample` rankings. It resolves
container-style `/app/storage/...` manifest paths to repo-relative
`storage/...` paths when run from the Tower host checkout.
Manifest-backed runs also persist the declared EPSG:4326 AOI and municipality
region, and retain municipality/operational-zone metadata in the combined
summary.
Export the same operator samples to a local YOLO detection dataset when the
public model candidates are not strong enough for the target imagery:
@@ -34,6 +34,15 @@ SMALL_BUILDING_TRAINING_SAMPLE_SLUGS = frozenset(
SMALL_BUILDING_VALIDATION_SAMPLE_SLUGS = frozenset(
{"vosselaar_center", "grobbendonk_center"}
)
MOL_OPERATIONAL_SAMPLE_SLUGS = (
"mol",
"mol_achterbos",
"mol_gompel",
"mol_donk",
"mol_postel",
)
MOL_OPERATIONAL_VALIDATION_SAMPLE_SLUGS = frozenset(MOL_OPERATIONAL_SAMPLE_SLUGS[1:])
MOL_BACKGROUND_CONTROL_SAMPLE_SLUGS = ("postel_bos",)
DEFAULT_VALIDATION_SAMPLE_SLUGS = frozenset(
{
"turnhout",
@@ -41,6 +50,7 @@ DEFAULT_VALIDATION_SAMPLE_SLUGS = frozenset(
"westerlo",
"arendonk_heide",
*SMALL_BUILDING_VALIDATION_SAMPLE_SLUGS,
*MOL_OPERATIONAL_VALIDATION_SAMPLE_SLUGS,
}
)
requests: Any = None
@@ -61,6 +71,8 @@ class OperatorSample:
height: int = 512
sample_role: str = "reference"
allow_empty_reference: bool = False
municipality: str | None = None
operational_zone: str = "regional_reference"
SAMPLES: dict[str, OperatorSample] = {
@@ -69,6 +81,40 @@ SAMPLES: dict[str, OperatorSample] = {
display_name="Mol center",
center_lon=5.1167,
center_lat=51.1919,
municipality="Mol",
operational_zone="center",
),
"mol_achterbos": OperatorSample(
slug="mol_achterbos",
display_name="Mol Achterbos residential",
center_lon=5.0979785,
center_lat=51.2008032,
municipality="Mol",
operational_zone="residential",
),
"mol_gompel": OperatorSample(
slug="mol_gompel",
display_name="Mol Gompel mixed settlement",
center_lon=5.1502009,
center_lat=51.1927937,
municipality="Mol",
operational_zone="mixed_settlement",
),
"mol_donk": OperatorSample(
slug="mol_donk",
display_name="Mol Donk canal and industrial context",
center_lon=5.1126881,
center_lat=51.2179802,
municipality="Mol",
operational_zone="canal_industrial",
),
"mol_postel": OperatorSample(
slug="mol_postel",
display_name="Mol Postel rural village",
center_lon=5.1897863,
center_lat=51.2874865,
municipality="Mol",
operational_zone="rural_village",
),
"geel": OperatorSample(
slug="geel",
@@ -178,6 +224,8 @@ SAMPLES: dict[str, OperatorSample] = {
half_size_m=260.0,
sample_role="background_candidate",
allow_empty_reference=True,
municipality="Mol",
operational_zone="forest_background",
),
"lommel_heide": OperatorSample(
slug="lommel_heide",
@@ -561,6 +609,8 @@ def fetch_reference(
reference["bbox"] = geo_bbox
reference["sample_slug"] = sample.slug
reference["sample_role"] = sample.sample_role
reference["municipality"] = sample.municipality
reference["operational_zone"] = sample.operational_zone
reference["allow_empty_reference"] = sample.allow_empty_reference
reference["background_category"] = background_category_for_sample(sample, len(features))
reference["recommended_split"] = recommended_split_for_sample(sample)
@@ -577,6 +627,8 @@ def fetch_reference(
props.setdefault("reference_layer_name", "buildings")
props.setdefault("sample_slug", sample.slug)
props.setdefault("sample_role", sample.sample_role)
props.setdefault("municipality", sample.municipality)
props.setdefault("operational_zone", sample.operational_zone)
props.setdefault("background_category", background_category_for_sample(sample, len(features)))
props.setdefault("recommended_split", recommended_split_for_sample(sample))
@@ -619,6 +671,8 @@ def prepare_sample(
"width": sample.width,
"height": sample.height,
"sample_role": sample.sample_role,
"municipality": sample.municipality,
"operational_zone": sample.operational_zone,
"allow_empty_reference": sample.allow_empty_reference,
"background_category": background_category,
"recommended_split": recommended_split_for_sample(sample),
@@ -657,6 +711,7 @@ def write_readme(output_dir: Path, samples: list[dict[str, Any]]) -> None:
f"- `{sample['sample_slug']}`: `{Path(sample['raster_path']).name}` and "
f"`{Path(sample['reference_path']).name}`, "
f"{sample['reference_feature_count']} reference features, role `{sample['sample_role']}`, "
f"municipality `{sample['municipality'] or 'regional'}`, zone `{sample['operational_zone']}`, "
f"background category `{sample['background_category']}`, "
f"recommended split `{sample['recommended_split']}`."
)
+14 -1
View File
@@ -27,6 +27,9 @@ Optional environment:
QUALITY_THRESHOLDS Space/comma separated confidence thresholds, default: 0.50 0.25 0.15.
QUALITY_OUTPUT_DIR Output directory, default: artifacts/detection-quality-matrix/<timestamp>.
QUALITY_SAMPLE_SLUG Optional AOI slug included in persisted project names.
REAL_PROJECT_REGION Persisted project region forwarded to the workflow.
REAL_AREA_NAME Persisted AOI name when REAL_AREA_BBOX is set.
REAL_AREA_BBOX Optional EPSG:4326 minx,miny,maxx,maxy AOI bounds.
REAL_IOU_THRESHOLD QA IoU threshold, default inherited by the underlying workflow.
This script never downloads models and never uses fixture detections. It repeats
@@ -45,6 +48,9 @@ QUALITY_TILE_SIZES="${QUALITY_TILE_SIZES:-640}"
QUALITY_TILE_OVERLAPS="${QUALITY_TILE_OVERLAPS:-64}"
QUALITY_THRESHOLDS="${QUALITY_THRESHOLDS:-0.50 0.25 0.15}"
QUALITY_SAMPLE_SLUG="${QUALITY_SAMPLE_SLUG:-}"
REAL_PROJECT_REGION="${REAL_PROJECT_REGION:-Kempen}"
REAL_AREA_NAME="${REAL_AREA_NAME:-${QUALITY_SAMPLE_SLUG:-Detection quality} AOI}"
REAL_AREA_BBOX="${REAL_AREA_BBOX:-}"
QUALITY_OUTPUT_DIR="${QUALITY_OUTPUT_DIR:-artifacts/detection-quality-matrix/$(date -u +%Y%m%dT%H%M%SZ)}"
if [ "${BASE_URL}" = "-h" ] || [ "${BASE_URL}" = "--help" ]; then
@@ -184,6 +190,9 @@ while IFS=$'\t' read -r model_request tile_size tile_overlap threshold run_label
echo "-- Matrix run ${run_index}: model=${model_request} tile=${tile_size} overlap=${tile_overlap} threshold=${threshold} --"
if ! REAL_PROJECT_NAME="GeoIntel Detection Quality Matrix${project_sample_label} ${model_request} tile ${tile_size} overlap ${tile_overlap} threshold ${threshold}" \
REAL_PROJECT_REGION="${REAL_PROJECT_REGION}" \
REAL_AREA_NAME="${REAL_AREA_NAME}" \
REAL_AREA_BBOX="${REAL_AREA_BBOX}" \
REAL_MODEL_ASSET_ID="${model_env}" \
REAL_TILE_SIZE="${tile_size}" \
REAL_TILE_OVERLAP="${tile_overlap}" \
@@ -196,6 +205,7 @@ while IFS=$'\t' read -r model_request tile_size tile_overlap threshold run_label
fi
project_id="$(sed -n 's/^Project: //p' "${run_log}" | tail -n 1)"
area_id="$(sed -n 's/^Area: //p' "${run_log}" | tail -n 1)"
raster_dataset_id="$(sed -n 's/^Raster dataset: //p' "${run_log}" | tail -n 1)"
reference_dataset_id="$(sed -n 's/^Reference dataset: //p' "${run_log}" | tail -n 1)"
selected_model_asset_id="$(sed -n 's/^Model asset: //p' "${run_log}" | tail -n 1)"
@@ -223,6 +233,7 @@ while IFS=$'\t' read -r model_request tile_size tile_overlap threshold run_label
"${tile_overlap}" \
"${threshold}" \
"${project_id}" \
"${area_id}" \
"${raster_dataset_id}" \
"${reference_dataset_id}" \
"${manifest_path}" \
@@ -244,6 +255,7 @@ import sys
tile_overlap,
threshold,
project_id,
area_id,
raster_dataset_id,
reference_dataset_id,
manifest_path,
@@ -252,7 +264,7 @@ import sys
detection_count,
export_id,
run_log,
) = sys.argv[1:18]
) = sys.argv[1:19]
with open(quality_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
@@ -281,6 +293,7 @@ summary = {
"tile_overlap": int(tile_overlap),
"threshold": float(threshold),
"project_id": project_id,
"area_id": area_id or None,
"raster_dataset_id": raster_dataset_id,
"reference_dataset_id": reference_dataset_id,
"manifest_path": manifest_path,
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <<'EOF'
Usage:
OPERATOR_SAMPLE_MANIFEST_PATH=storage/operator-data/mol-operational-1024/operator_samples_manifest.json \
bash scripts/run_mol_operational_validation.sh [base_url]
Optional environment:
MOL_POSITIVE_SAMPLE_SLUGS Positive Mol holdouts. Default: mol_achterbos mol_gompel mol_donk mol_postel.
MOL_BACKGROUND_SAMPLE_SLUGS Mol background controls. Default: postel_bos.
MOL_VALIDATION_OUTPUT_DIR Output directory, default: artifacts/mol-operational-validation/<timestamp>.
QUALITY_MODEL_ASSET_IDS Local model asset ID, default: active configured model.
QUALITY_TILE_SIZES Default: 512.
QUALITY_TILE_OVERLAPS Default: 64.
QUALITY_THRESHOLDS Default: 0.15.
REAL_IOU_THRESHOLD Default: 0.25.
The runner never downloads weights, fetches product providers or uses fixture
outputs. Prepare the documented real orthophoto/GRB files explicitly first.
EOF
}
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT}"
BASE_URL="${1:-${GE_INTEL_BASE_URL:-http://localhost:1202}}"
OPERATOR_SAMPLE_MANIFEST_PATH="${OPERATOR_SAMPLE_MANIFEST_PATH:-storage/operator-data/mol-operational-1024/operator_samples_manifest.json}"
MOL_POSITIVE_SAMPLE_SLUGS="${MOL_POSITIVE_SAMPLE_SLUGS:-mol_achterbos mol_gompel mol_donk mol_postel}"
MOL_BACKGROUND_SAMPLE_SLUGS="${MOL_BACKGROUND_SAMPLE_SLUGS:-postel_bos}"
MOL_VALIDATION_OUTPUT_DIR="${MOL_VALIDATION_OUTPUT_DIR:-artifacts/mol-operational-validation/$(date -u +%Y%m%dT%H%M%SZ)}"
QUALITY_MODEL_ASSET_IDS="${QUALITY_MODEL_ASSET_IDS:-${REAL_MODEL_ASSET_ID:-__active__}}"
QUALITY_TILE_SIZES="${QUALITY_TILE_SIZES:-512}"
QUALITY_TILE_OVERLAPS="${QUALITY_TILE_OVERLAPS:-64}"
QUALITY_THRESHOLDS="${QUALITY_THRESHOLDS:-0.15}"
REAL_IOU_THRESHOLD="${REAL_IOU_THRESHOLD:-0.25}"
if [ "${BASE_URL}" = "-h" ] || [ "${BASE_URL}" = "--help" ]; then
usage
exit 0
fi
if [ ! -f "${OPERATOR_SAMPLE_MANIFEST_PATH}" ]; then
echo "Mol operator manifest is not readable: ${OPERATOR_SAMPLE_MANIFEST_PATH}" >&2
exit 2
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 Mol operational validation" >&2
exit 1
fi
mkdir -p "${MOL_VALIDATION_OUTPUT_DIR}"
positive_output_dir="${MOL_VALIDATION_OUTPUT_DIR}/positive-qa"
background_output_dir="${MOL_VALIDATION_OUTPUT_DIR}/background-control"
"${PYTHON_BIN}" - \
"${OPERATOR_SAMPLE_MANIFEST_PATH}" \
"${MOL_POSITIVE_SAMPLE_SLUGS}" \
"${MOL_BACKGROUND_SAMPLE_SLUGS}" <<'PY'
import json
import sys
from pathlib import Path
manifest_path = Path(sys.argv[1])
positive_slugs = {value.strip().lower() for value in sys.argv[2].replace(",", " ").split() if value.strip()}
background_slugs = {value.strip().lower() for value in sys.argv[3].replace(",", " ").split() if value.strip()}
payload = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
samples = {str(sample.get("sample_slug") or "").lower(): sample for sample in payload.get("samples") or []}
required = positive_slugs | background_slugs
missing = sorted(required - samples.keys())
if missing:
raise SystemExit(f"Mol manifest is missing required samples: {', '.join(missing)}")
for slug in sorted(positive_slugs):
sample = samples[slug]
if sample.get("municipality") != "Mol":
raise SystemExit(f"Positive sample is not attributed to Mol: {slug}")
if sample.get("recommended_split") != "val":
raise SystemExit(f"Positive Mol operational sample is not a validation holdout: {slug}")
if int(sample.get("reference_feature_count") or 0) < 1:
raise SystemExit(f"Positive Mol operational sample has no GRB references: {slug}")
for slug in sorted(background_slugs):
sample = samples[slug]
if sample.get("municipality") != "Mol":
raise SystemExit(f"Background sample is not attributed to Mol: {slug}")
if sample.get("sample_role") != "background_candidate" and not sample.get("allow_empty_reference"):
raise SystemExit(f"Mol background sample is not explicitly marked as background: {slug}")
PY
echo "== GeoIntel Mol operational validation =="
echo "Base URL: ${BASE_URL}"
echo "Manifest: ${OPERATOR_SAMPLE_MANIFEST_PATH}"
echo "Positive holdouts: ${MOL_POSITIVE_SAMPLE_SLUGS}"
echo "Background controls: ${MOL_BACKGROUND_SAMPLE_SLUGS}"
echo "Output: ${MOL_VALIDATION_OUTPUT_DIR}"
OPERATOR_SAMPLE_MANIFEST_PATH="${OPERATOR_SAMPLE_MANIFEST_PATH}" \
OPERATOR_SAMPLE_SLUGS="${MOL_POSITIVE_SAMPLE_SLUGS}" \
MULTI_SAMPLE_OUTPUT_DIR="${positive_output_dir}" \
QUALITY_MODEL_ASSET_IDS="${QUALITY_MODEL_ASSET_IDS}" \
QUALITY_TILE_SIZES="${QUALITY_TILE_SIZES}" \
QUALITY_TILE_OVERLAPS="${QUALITY_TILE_OVERLAPS}" \
QUALITY_THRESHOLDS="${QUALITY_THRESHOLDS}" \
REAL_IOU_THRESHOLD="${REAL_IOU_THRESHOLD}" \
bash scripts/run_multi_sample_detection_quality_matrix.sh "${BASE_URL}"
OPERATOR_SAMPLE_MANIFEST_PATH="${OPERATOR_SAMPLE_MANIFEST_PATH}" \
OPERATOR_BACKGROUND_SAMPLE_SLUGS="${MOL_BACKGROUND_SAMPLE_SLUGS}" \
HARD_NEGATIVE_OUTPUT_DIR="${background_output_dir}" \
QUALITY_MODEL_ASSET_IDS="${QUALITY_MODEL_ASSET_IDS}" \
QUALITY_TILE_SIZES="${QUALITY_TILE_SIZES}" \
QUALITY_TILE_OVERLAPS="${QUALITY_TILE_OVERLAPS}" \
QUALITY_THRESHOLDS="${QUALITY_THRESHOLDS}" \
bash scripts/run_operator_hard_negative_detection_matrix.sh "${BASE_URL}"
"${PYTHON_BIN}" - \
"${positive_output_dir}/multi_sample_quality_summary.json" \
"${background_output_dir}/hard_negative_matrix_summary.json" \
"${MOL_VALIDATION_OUTPUT_DIR}" \
"${BASE_URL}" \
"${OPERATOR_SAMPLE_MANIFEST_PATH}" <<'PY'
import json
import statistics
import sys
from datetime import datetime, timezone
from pathlib import Path
positive_path = Path(sys.argv[1])
background_path = Path(sys.argv[2])
output_dir = Path(sys.argv[3])
base_url = sys.argv[4]
manifest_path = sys.argv[5]
positive = json.loads(positive_path.read_text(encoding="utf-8"))
background = json.loads(background_path.read_text(encoding="utf-8"))
positive_items = positive.get("items") or []
background_items = background.get("items") or []
if not positive_items or not background_items:
raise SystemExit("Mol operational validation did not produce both positive and background evidence")
def metric_values(key: str) -> list[float]:
return [float(item[key]) for item in positive_items if item.get(key) is not None]
f1_values = metric_values("f1_score")
precision_values = metric_values("precision")
recall_values = metric_values("recall")
summary = {
"schema_version": 1,
"status": "evidence_ready",
"generated_at": datetime.now(timezone.utc).isoformat(),
"base_url": base_url,
"operator_sample_manifest_path": manifest_path,
"positive_summary_path": str(positive_path),
"background_summary_path": str(background_path),
"positive_sample_count": int(positive.get("sample_count") or 0),
"positive_run_count": len(positive_items),
"background_sample_count": int(background.get("sample_count") or 0),
"background_run_count": len(background_items),
"mean_precision": statistics.fmean(precision_values) if precision_values else None,
"mean_recall": statistics.fmean(recall_values) if recall_values else None,
"mean_f1": statistics.fmean(f1_values) if f1_values else None,
"minimum_f1": min(f1_values) if f1_values else None,
"total_matches": sum(int(item.get("matches") or 0) for item in positive_items),
"total_false_positives": sum(int(item.get("false_positives") or 0) for item in positive_items),
"total_false_negatives": sum(int(item.get("false_negatives") or 0) for item in positive_items),
"total_background_detections": sum(int(item.get("detection_count") or 0) for item in background_items),
"zero_detection_background_runs": sum(1 for item in background_items if int(item.get("detection_count") or 0) == 0),
"project_ids": [item.get("project_id") for item in positive_items + background_items],
"area_ids": [item.get("area_id") for item in positive_items + background_items],
"analysis_run_ids": [item.get("analysis_run_id") for item in positive_items + background_items],
"quality_check_ids": [item.get("quality_check_id") for item in positive_items],
"positive_items": positive_items,
"background_items": background_items,
}
summary_path = output_dir / "mol_operational_validation_summary.json"
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8")
def fmt(value):
return "n/a" if value is None else f"{value:.4f}"
lines = [
"# Mol operational validation",
"",
f"- Status: `{summary['status']}`",
f"- Positive samples/runs: `{summary['positive_sample_count']}` / `{summary['positive_run_count']}`",
f"- Background samples/runs: `{summary['background_sample_count']}` / `{summary['background_run_count']}`",
f"- Mean precision: `{fmt(summary['mean_precision'])}`",
f"- Mean recall: `{fmt(summary['mean_recall'])}`",
f"- Mean F1: `{fmt(summary['mean_f1'])}`",
f"- Minimum F1: `{fmt(summary['minimum_f1'])}`",
f"- Total matches / FP / FN: `{summary['total_matches']}` / `{summary['total_false_positives']}` / `{summary['total_false_negatives']}`",
f"- Background detections: `{summary['total_background_detections']}`",
"",
"`evidence_ready` records completed persisted workflows; it is not an automatic model-promotion decision.",
]
(output_dir / "mol_operational_validation_summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(json.dumps({"status": summary["status"], "summary_path": str(summary_path)}, indent=2))
PY
@@ -118,7 +118,15 @@ with output_path.open("w", encoding="utf-8") as handle:
reference_count = int(sample.get("reference_feature_count") or 0)
if reference_count < 1:
raise SystemExit(f"Operator sample has no reference features: {sample_slug}")
handle.write(f"{sample_slug}\t{raster_path}\t{reference_path}\t{reference_count}\n")
bbox = sample.get("wgs84_bbox") or []
if len(bbox) != 4:
raise SystemExit(f"Operator sample has no valid wgs84_bbox: {sample_slug}")
bbox_csv = ",".join(str(float(value)) for value in bbox)
municipality = str(sample.get("municipality") or "")
handle.write(
f"{sample_slug}\t{raster_path}\t{reference_path}\t{reference_count}\t"
f"{bbox_csv}\t{municipality}\n"
)
selected += 1
if selected == 0:
@@ -131,13 +139,20 @@ echo "Manifest: ${OPERATOR_SAMPLE_MANIFEST_PATH}"
echo "Sample filter: ${OPERATOR_SAMPLE_SLUGS:-all}"
echo "Output: ${MULTI_SAMPLE_OUTPUT_DIR}"
while IFS=$'\t' read -r sample_slug raster_path reference_path reference_feature_count; do
while IFS=$'\t' read -r sample_slug raster_path reference_path reference_feature_count wgs84_bbox municipality; do
sample_output_dir="${MULTI_SAMPLE_OUTPUT_DIR}/${sample_slug}"
mkdir -p "${sample_output_dir}"
echo "-- Sample ${sample_slug}: reference_features=${reference_feature_count} --"
project_region="Kempen"
if [ "${municipality,,}" = "mol" ]; then
project_region="Mol, Kempen"
fi
REAL_RASTER_PATH="${raster_path}" \
REAL_REFERENCE_VECTOR_PATH="${reference_path}" \
QUALITY_SAMPLE_SLUG="${sample_slug}" \
REAL_PROJECT_REGION="${project_region}" \
REAL_AREA_NAME="${sample_slug} AOI" \
REAL_AREA_BBOX="${wgs84_bbox}" \
QUALITY_OUTPUT_DIR="${sample_output_dir}" \
bash scripts/run_detection_quality_matrix.sh "${BASE_URL}"
done < "${sample_manifest_tsv}"
@@ -153,11 +168,17 @@ from pathlib import Path
output_dir = Path(sys.argv[1])
base_url = sys.argv[2]
manifest_path = sys.argv[3]
manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
manifest_samples = {
str(sample.get("sample_slug") or "").lower(): sample
for sample in manifest_payload.get("samples") or []
}
sample_summaries = []
flat_items = []
for summary_path in sorted(glob.glob(str(output_dir / "*" / "quality_matrix_summary.json"))):
sample_slug = Path(summary_path).parent.name
sample_metadata = manifest_samples.get(sample_slug, {})
summary = json.loads(Path(summary_path).read_text(encoding="utf-8"))
items = summary.get("items") or []
for item in items:
@@ -167,6 +188,11 @@ for summary_path in sorted(glob.glob(str(output_dir / "*" / "quality_matrix_summ
sample_summaries.append(
{
"sample_slug": sample_slug,
"display_name": sample_metadata.get("display_name"),
"municipality": sample_metadata.get("municipality"),
"operational_zone": sample_metadata.get("operational_zone"),
"wgs84_bbox": sample_metadata.get("wgs84_bbox"),
"recommended_split": sample_metadata.get("recommended_split"),
"summary_path": summary_path,
"run_count": len(items),
"best_by_score": summary.get("best_by_score"),
@@ -146,9 +146,14 @@ with output_path.open("w", encoding="utf-8") as handle:
background_category = "reference_aoi"
if requested_categories and background_category not in requested_categories:
continue
bbox = sample.get("wgs84_bbox") or []
if len(bbox) != 4:
raise SystemExit(f"Background sample has no valid wgs84_bbox: {sample_slug}")
bbox_csv = ",".join(str(float(value)) for value in bbox)
municipality = str(sample.get("municipality") or "")
handle.write(
f"{sample_slug}\t{raster_path}\t{sample_role}\t{allow_empty_reference}\t"
f"{reference_count}\t{background_category}\n"
f"{reference_count}\t{background_category}\t{bbox_csv}\t{municipality}\n"
)
selected += 1
@@ -263,8 +268,12 @@ echo "Tile overlaps: ${tile_overlaps_normalized}"
echo "Thresholds: ${thresholds_normalized}"
echo "Output: ${HARD_NEGATIVE_OUTPUT_DIR}"
while IFS=$'\t' read -r sample_slug raster_path sample_role allow_empty_reference reference_feature_count background_category; do
while IFS=$'\t' read -r sample_slug raster_path sample_role allow_empty_reference reference_feature_count background_category wgs84_bbox municipality; do
echo "-- Background sample ${sample_slug}: role=${sample_role} category=${background_category} allow_empty_reference=${allow_empty_reference} reference_features=${reference_feature_count} --"
project_region="Kempen"
if [ "${municipality,,}" = "mol" ]; then
project_region="Mol, Kempen"
fi
while IFS=$'\t' read -r model_request tile_size tile_overlap threshold run_label; do
sample_output_dir="${HARD_NEGATIVE_OUTPUT_DIR}/${sample_slug}"
mkdir -p "${sample_output_dir}"
@@ -278,17 +287,17 @@ while IFS=$'\t' read -r sample_slug raster_path sample_role allow_empty_referenc
{
echo "sample=${sample_slug} model=${model_request} tile=${tile_size} overlap=${tile_overlap} threshold=${threshold}"
"${PYTHON_BIN}" - "${tmp_dir}/project_request.json" "${sample_slug}" "${model_request}" "${threshold}" <<'PY'
"${PYTHON_BIN}" - "${tmp_dir}/project_request.json" "${sample_slug}" "${model_request}" "${threshold}" "${project_region}" <<'PY'
import json
import sys
from datetime import datetime, timezone
path, sample_slug, model_request, threshold = sys.argv[1:5]
path, sample_slug, model_request, threshold, project_region = sys.argv[1:6]
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
payload = {
"name": f"GeoIntel hard-negative {sample_slug} {model_request} {threshold} {stamp}",
"description": "Operator hard-negative validation: raster upload, tiling, configured YOLO detection count, no QA reference.",
"region": "Kempen",
"region": project_region,
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
@@ -300,6 +309,29 @@ PY
require_json_data "${tmp_dir}/project.json"
project_id="$(json_field "${tmp_dir}/project.json" "data.id")"
"${PYTHON_BIN}" - "${tmp_dir}/area_request.json" "${sample_slug} background AOI" "${wgs84_bbox}" <<'PY'
import json
import sys
path, area_name, bbox_raw = sys.argv[1:4]
minx, miny, maxx, maxy = [float(value.strip()) for value in bbox_raw.split(",")]
if minx >= maxx or miny >= maxy:
raise SystemExit("Background AOI bbox minimum values must be smaller than maximum values")
ring = [[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy], [minx, miny]]
payload = {
"name": area_name,
"crs": "EPSG:4326",
"geometry": {"type": "MultiPolygon", "coordinates": [[ring]]},
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
PY
curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/areas" \
-H "Content-Type: application/json" \
--data-binary "@${tmp_dir}/area_request.json" > "${tmp_dir}/area.json"
require_json_data "${tmp_dir}/area.json"
area_id="$(json_field "${tmp_dir}/area.json" "data.id")"
curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/upload" \
-F "file=@${raster_path}" \
-F "dataset_type=raster" \
@@ -407,6 +439,7 @@ PY
"${tile_overlap}" \
"${threshold}" \
"${project_id}" \
"${area_id}" \
"${raster_dataset_id}" \
"${manifest_path}" \
"${analysis_run_id}" \
@@ -430,6 +463,7 @@ import sys
tile_overlap,
threshold,
project_id,
area_id,
raster_dataset_id,
manifest_path,
analysis_run_id,
@@ -437,7 +471,7 @@ import sys
detections_list_count,
tile_count,
run_log,
) = sys.argv[1:20]
) = sys.argv[1:21]
detections = int(detection_count)
listed = int(detections_list_count)
@@ -456,6 +490,7 @@ summary = {
"tile_overlap": int(tile_overlap),
"threshold": float(threshold),
"project_id": project_id,
"area_id": area_id,
"raster_dataset_id": raster_dataset_id,
"manifest_path": manifest_path,
"analysis_run_id": analysis_run_id,
+1
View File
@@ -73,6 +73,7 @@ bash -n scripts/smoke_detection_calibration_evidence_bundle.sh
bash -n scripts/assemble_detection_calibration_evidence_portfolio.sh
bash -n scripts/run_detection_quality_matrix.sh
bash -n scripts/run_multi_sample_detection_quality_matrix.sh
bash -n scripts/run_mol_operational_validation.sh
bash -n scripts/run_operator_hard_negative_detection_matrix.sh
bash -n scripts/run_background_corpus_split_matrix.sh
bash -n scripts/run_split_background_promotion_workflow.sh
@@ -17,6 +17,9 @@ Required inputs:
Optional environment:
REAL_PROJECT_NAME Project name for the validation run.
REAL_PROJECT_REGION Persisted project region, default: Kempen.
REAL_AREA_NAME Persisted AOI name when REAL_AREA_BBOX is set.
REAL_AREA_BBOX Optional EPSG:4326 minx,miny,maxx,maxy AOI bounds.
REAL_MODEL_ASSET_ID Specific /api/v1/detection/model-assets id to use.
REAL_TILE_SIZE Raster tile size, default 640.
REAL_TILE_OVERLAP Raster tile overlap, default 64.
@@ -29,6 +32,9 @@ BASE_URL="${1:-${GE_INTEL_BASE_URL:-http://localhost:1202}}"
REAL_RASTER_PATH="${2:-${REAL_RASTER_PATH:-}}"
REAL_REFERENCE_VECTOR_PATH="${3:-${REAL_REFERENCE_VECTOR_PATH:-}}"
REAL_PROJECT_NAME="${REAL_PROJECT_NAME:-GeoIntel Real Data Validation}"
REAL_PROJECT_REGION="${REAL_PROJECT_REGION:-Kempen}"
REAL_AREA_NAME="${REAL_AREA_NAME:-${REAL_PROJECT_NAME} AOI}"
REAL_AREA_BBOX="${REAL_AREA_BBOX:-}"
REAL_TILE_SIZE="${REAL_TILE_SIZE:-640}"
REAL_TILE_OVERLAP="${REAL_TILE_OVERLAP:-64}"
REAL_CONFIDENCE_THRESHOLD="${REAL_CONFIDENCE_THRESHOLD:-0.5}"
@@ -130,17 +136,17 @@ echo "Base URL: ${BASE_URL}"
echo "Raster: ${REAL_RASTER_PATH}"
echo "Reference vector: ${REAL_REFERENCE_VECTOR_PATH}"
"${PYTHON_BIN}" - "${TMP_DIR}/project_request.json" "${REAL_PROJECT_NAME}" <<'PY'
"${PYTHON_BIN}" - "${TMP_DIR}/project_request.json" "${REAL_PROJECT_NAME}" "${REAL_PROJECT_REGION}" <<'PY'
import json
import sys
from datetime import datetime, timezone
path, project_name = sys.argv[1], sys.argv[2]
path, project_name, project_region = sys.argv[1:4]
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
payload = {
"name": f"{project_name} {stamp}",
"description": "Operator-provided real data validation: raster upload, reference vector upload, configured YOLO detection, QA/QC and GeoJSON export.",
"region": "Kempen",
"region": project_region,
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
@@ -156,6 +162,41 @@ if [ -z "${project_id}" ] || [ "${project_id}" = "None" ] || [ "${project_id}" =
exit 1
fi
area_id=""
if [ -n "${REAL_AREA_BBOX}" ]; then
"${PYTHON_BIN}" - "${TMP_DIR}/area_request.json" "${REAL_AREA_NAME}" "${REAL_AREA_BBOX}" <<'PY'
import json
import sys
path, area_name, bbox_raw = sys.argv[1:4]
try:
minx, miny, maxx, maxy = [float(value.strip()) for value in bbox_raw.split(",")]
except (TypeError, ValueError) as exc:
raise SystemExit("REAL_AREA_BBOX must contain four numeric EPSG:4326 values: minx,miny,maxx,maxy") from exc
if minx >= maxx or miny >= maxy:
raise SystemExit("REAL_AREA_BBOX minimum values must be smaller than maximum values")
if not (-180 <= minx <= 180 and -180 <= maxx <= 180 and -90 <= miny <= 90 and -90 <= maxy <= 90):
raise SystemExit("REAL_AREA_BBOX is outside EPSG:4326 longitude/latitude bounds")
ring = [[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy], [minx, miny]]
payload = {
"name": area_name,
"crs": "EPSG:4326",
"geometry": {"type": "MultiPolygon", "coordinates": [[ring]]},
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
PY
curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/areas" \
-H "Content-Type: application/json" \
--data-binary "@${TMP_DIR}/area_request.json" > "${TMP_DIR}/area.json"
require_json_data "${TMP_DIR}/area.json"
area_id="$(json_field "${TMP_DIR}/area.json" "data.id")"
if [ -z "${area_id}" ] || [ "${area_id}" = "None" ] || [ "${area_id}" = "null" ]; then
echo "Area creation did not return an area id" >&2
exit 1
fi
fi
curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/upload" \
-F "file=@${REAL_RASTER_PATH}" \
-F "dataset_type=raster" \
@@ -494,6 +535,7 @@ PY
echo "Real data detection + QA workflow verification passed"
echo "Project: ${project_id}"
echo "Area: ${area_id}"
echo "Raster dataset: ${raster_dataset_id}"
echo "Reference dataset: ${reference_dataset_id}"
echo "Model asset: ${model_asset_id}"