Add test/acceptance/ so the customer's reviewers can re-run what the Stage 8 documents claim on an isolated stand built from this branch: - compose.acceptance.yml + stand.sh: compose project gis-acceptance (app 18080, postgres 15432, minio 19000/19001, rabbitmq mgmt 15672), POSTGIS_PLATFORM / MINIO_IMAGE knobs for arm64 and amd64 hosts. - functional.sh: the 89 test-stand checks of Приложение 7 section 5 (F01-F38, N01-N38, L02-L05, L07-L15), own prefixed test data, PostGIS/MinIO confirmation via the stand containers, full cleanup. - lp_gen.py / lp_run.sh / lp_verify.py: synthetic ЛП inputs, a run from stage 0, and an independent numpy re-implementation of every node that compares all 15 outputs pixel by pixel; read-only (GET) mode works against any stand, in row/column windows for 22219x14977 rasters. - load.sh + seed.sh: scenarios S1-S6 of Приложение 3 with hey 0.1.5 running inside the stand network; CSV/JSON summaries, criteria K1-K4. - scan.sh: govulncheck (source and binary), gosec, trivy fs/image, golangci-lint; fails on any High/Critical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
437 lines
18 KiB
Python
Executable File
437 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Independent pixel-by-pixel check of a Landscape Program (ЛП) run.
|
||
|
||
Re-implements every node of the ЛП graph in numpy — 00 z_integrated,
|
||
01 u_integrated, 02 z_max, 03 u_max, 10 e_ecological_constraint,
|
||
20 c_conflicts, 21 p_max, 30 func_zoning (V1–V3 with the run's thresholds and
|
||
tie_priority), 31 c_max — from the 13 leaf input rasters the run actually used,
|
||
and compares the result with all 15 output rasters the run published.
|
||
|
||
The program is READ-ONLY: it issues only HTTP GET requests, so it can be
|
||
pointed at any stand, production included:
|
||
|
||
python lp_verify.py --base-url http://localhost:18080 --run <run-id>
|
||
python lp_verify.py --base-url https://dssgis.dwh.kz --run <run-id>
|
||
|
||
Data flow:
|
||
GET /landscape/runs/{id} run params, per-node inputs and outputs
|
||
GET /datasets/{id}.cog every input (13) and output (15) raster
|
||
(or /datasets/{id}/download for inputs with --inputs-from download)
|
||
|
||
The leaf inputs are taken from the run's node `inputs` (group = slot code), the
|
||
outputs from the node `outputs`. Expected values are computed through the whole
|
||
chain from the leaf inputs only (E from the re-computed Z/U, C and V from the
|
||
re-computed E, ...), so a wrong intermediate output cannot hide a later error.
|
||
The run's own chain references (node 10 reading node 00's output, ...) are
|
||
checked for consistency as well.
|
||
|
||
Large rasters (production: 22219 x 14977) are processed in windows of
|
||
--block-rows x --block-cols pixels (default 512 x 4096, aligned with the
|
||
512-pixel COG tiles), so memory stays bounded (~0.5 GB).
|
||
|
||
Pixel rules (source of truth: internal/landscape/kernels.go):
|
||
valid(v) 1 <= v <= 5 (NoData and anything else is invalid); class
|
||
values are used as int16(v) (truncation)
|
||
classify(x) float32 x: < 1 -> NoData, <= 1.8 -> 1, <= 2.6 -> 2,
|
||
<= 3.4 -> 3, <= 4.2 -> 4, <= 5.0 -> 5, else NoData
|
||
z_integrated all Z valid: classify(0.25*Z1 + 0.25*Z2 + 0.25*Z3 + 0.25*Z4)
|
||
(float32, summed left to right)
|
||
u_integrated water & soil & (crop | veg): classify(num/den),
|
||
num = 0.25*water + 0.25*soil [+ 0.25*crop] [+ 0.25*veg],
|
||
den = 0.5 [+ 0.25] [+ 0.25] (float32)
|
||
z_max all Z valid: max; u_max: U domain as above, max of valid
|
||
e Z_int & U_int valid: max(Z, U)
|
||
c_conflicts P_j & E valid: min(P_j, E)
|
||
p_max, c_max max over valid inputs; NoData when none is valid
|
||
func_zoning E valid & any P_k valid; candidate k: P_k valid & rule(E, P_k)
|
||
V1: E<=2 & S>=v1_min_s_e12 | E==3 & S>=v1_min_s_e3
|
||
V2: E<=2 | E==3 & S>=v2_min_s_e3 | E==4 & S>=v2_min_s_e4
|
||
V3: E<=4
|
||
no candidate -> 1; else sector with max S (ties by
|
||
tie_priority) + 1
|
||
|
||
Exit code: 0 when every output matches pixel for pixel, 1 on any mismatch,
|
||
2 on a usage / data error.
|
||
"""
|
||
import argparse
|
||
import json
|
||
import os
|
||
import shutil
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
|
||
import numpy as np
|
||
|
||
try:
|
||
import rasterio
|
||
from rasterio.windows import Window
|
||
except ImportError: # pragma: no cover
|
||
sys.exit("lp_verify.py needs numpy and rasterio: pip install numpy rasterio")
|
||
|
||
NODATA = np.int16(-9999)
|
||
SLOTS = [
|
||
"lp-z-biodiversity", "lp-z-lcr", "lp-z-soil-protection", "lp-z-wr",
|
||
"lp-u-surf-water", "lp-u-soil", "lp-u-crop", "lp-u-veg",
|
||
"lp-p-irrigation", "lp-p-snow", "lp-p-crop", "lp-p-pasture", "lp-p-tourism",
|
||
]
|
||
Z = SLOTS[0:4]
|
||
U = SLOTS[4:8] # water, soil, crop, veg
|
||
P = SLOTS[8:13] # sector order 1..5
|
||
C = ["lp-c-irrigation", "lp-c-snow", "lp-c-crop", "lp-c-pasture", "lp-c-tourism"]
|
||
OUTPUTS = [
|
||
"lp-z-integrated", "lp-u-integrated", "lp-z-max", "lp-u-max", "lp-e",
|
||
*C, "lp-p-max", "lp-v1-conservative", "lp-v2-balanced", "lp-v3-development", "lp-c-max",
|
||
]
|
||
OUTPUT_NODE = {
|
||
"lp-z-integrated": "00", "lp-u-integrated": "01", "lp-z-max": "02", "lp-u-max": "03", "lp-e": "10",
|
||
**{c: "20" for c in C}, "lp-p-max": "21",
|
||
"lp-v1-conservative": "30", "lp-v2-balanced": "30", "lp-v3-development": "30", "lp-c-max": "31",
|
||
}
|
||
DEFAULT_PARAMS = {"tie_priority": [1, 2, 3, 4, 5], "v1_min_s_e12": 3, "v1_min_s_e3": 4,
|
||
"v2_min_s_e3": 3, "v2_min_s_e4": 5}
|
||
|
||
F32 = np.float32
|
||
|
||
|
||
# ----------------------------------------------------------------- pixel rules
|
||
def valid(v):
|
||
return (v >= 1) & (v <= 5)
|
||
|
||
|
||
def cls(v, ok):
|
||
"""int16(v) for valid pixels (Go truncation), 0 elsewhere."""
|
||
return np.where(ok, np.trunc(np.where(ok, v, 0)), 0).astype(np.int16)
|
||
|
||
|
||
def classify(x):
|
||
x = x.astype(np.float32, copy=False)
|
||
return np.select(
|
||
[x < F32(1.0), x <= F32(1.8), x <= F32(2.6), x <= F32(3.4), x <= F32(4.2), x <= F32(5.0)],
|
||
[NODATA, 1, 2, 3, 4, 5], default=NODATA).astype(np.int16)
|
||
|
||
|
||
def quarter(v):
|
||
# float32(float32(0.25) * float32(v)), as in the Go kernels
|
||
return F32(0.25) * v.astype(np.float32)
|
||
|
||
|
||
def z_integrated(z):
|
||
ok = valid(z[0]) & valid(z[1]) & valid(z[2]) & valid(z[3])
|
||
score = ((quarter(z[0]) + quarter(z[1])) + quarter(z[2])) + quarter(z[3])
|
||
return np.where(ok, classify(score), NODATA).astype(np.int16)
|
||
|
||
|
||
def u_domain(u):
|
||
water, soil, crop, veg = u
|
||
crop_ok, veg_ok = valid(crop), valid(veg)
|
||
return valid(water) & valid(soil) & (crop_ok | veg_ok), crop_ok, veg_ok
|
||
|
||
|
||
def u_integrated(u):
|
||
water, soil, crop, veg = u
|
||
ok, crop_ok, veg_ok = u_domain(u)
|
||
num = quarter(water) + quarter(soil)
|
||
den = np.full(num.shape, F32(0.5), dtype=np.float32)
|
||
num = np.where(crop_ok, num + quarter(crop), num).astype(np.float32)
|
||
den = np.where(crop_ok, den + F32(0.25), den).astype(np.float32)
|
||
num = np.where(veg_ok, num + quarter(veg), num).astype(np.float32)
|
||
den = np.where(veg_ok, den + F32(0.25), den).astype(np.float32)
|
||
with np.errstate(invalid="ignore", divide="ignore"):
|
||
score = (num / den).astype(np.float32)
|
||
return np.where(ok, classify(score), NODATA).astype(np.int16)
|
||
|
||
|
||
def z_max(z):
|
||
ok = valid(z[0]) & valid(z[1]) & valid(z[2]) & valid(z[3])
|
||
m = np.maximum.reduce([cls(v, ok) for v in z])
|
||
return np.where(ok, m, NODATA).astype(np.int16)
|
||
|
||
|
||
def u_max(u):
|
||
water, soil, crop, veg = u
|
||
ok, crop_ok, veg_ok = u_domain(u)
|
||
m = np.maximum(cls(water, ok), cls(soil, ok))
|
||
m = np.where(crop_ok, np.maximum(m, cls(crop, crop_ok)), m)
|
||
m = np.where(veg_ok, np.maximum(m, cls(veg, veg_ok)), m)
|
||
return np.where(ok, m, NODATA).astype(np.int16)
|
||
|
||
|
||
def e_constraint(zi, ui):
|
||
ok = valid(zi) & valid(ui)
|
||
return np.where(ok, np.maximum(zi, ui), NODATA).astype(np.int16)
|
||
|
||
|
||
def c_conflict(p, e):
|
||
ok = valid(p) & valid(e)
|
||
return np.where(ok, np.minimum(cls(p, ok), e), NODATA).astype(np.int16)
|
||
|
||
|
||
def max_any_valid(layers):
|
||
oks = [valid(v) for v in layers]
|
||
m = np.maximum.reduce([cls(v, ok) for v, ok in zip(layers, oks)])
|
||
anyok = np.logical_or.reduce(oks)
|
||
return np.where(anyok, m, NODATA).astype(np.int16)
|
||
|
||
|
||
def func_zoning(e, p, params):
|
||
eok = valid(e)
|
||
ev = np.where(eok, e, 0).astype(np.int16)
|
||
base = [valid(v) for v in p]
|
||
s = [cls(v, ok) for v, ok in zip(p, base)]
|
||
any_base = np.logical_or.reduce(base)
|
||
t = params
|
||
rules = [
|
||
lambda e_, s_: ((e_ <= 2) & (s_ >= t["v1_min_s_e12"])) | ((e_ == 3) & (s_ >= t["v1_min_s_e3"])),
|
||
lambda e_, s_: (e_ <= 2) | ((e_ == 3) & (s_ >= t["v2_min_s_e3"])) | ((e_ == 4) & (s_ >= t["v2_min_s_e4"])),
|
||
lambda e_, s_: e_ <= 4,
|
||
]
|
||
outs = []
|
||
for rule in rules:
|
||
cand = [b & rule(ev, sk) for b, sk in zip(base, s)]
|
||
any_cand = np.logical_or.reduce(cand)
|
||
max_s = np.maximum.reduce([np.where(ck, sk, 0) for ck, sk in zip(cand, s)]).astype(np.int16)
|
||
sector = np.zeros(ev.shape, dtype=np.int16)
|
||
done = np.zeros(ev.shape, dtype=bool)
|
||
for code in t["tie_priority"]:
|
||
k = code - 1
|
||
hit = cand[k] & (s[k] == max_s) & ~done
|
||
sector[hit] = code
|
||
done |= hit
|
||
v = np.where(any_cand, sector + 1, 1).astype(np.int16)
|
||
outs.append(np.where(eok & any_base, v, NODATA).astype(np.int16))
|
||
return outs
|
||
|
||
|
||
def expected_block(inp, params):
|
||
"""inp: dict slot -> float64 array. Returns dict output group -> int16."""
|
||
z = [inp[g] for g in Z]
|
||
u = [inp[g] for g in U]
|
||
p = [inp[g] for g in P]
|
||
out = {
|
||
"lp-z-integrated": z_integrated(z),
|
||
"lp-u-integrated": u_integrated(u),
|
||
"lp-z-max": z_max(z),
|
||
"lp-u-max": u_max(u),
|
||
}
|
||
e = e_constraint(out["lp-z-integrated"], out["lp-u-integrated"])
|
||
out["lp-e"] = e
|
||
for cg, pk in zip(C, p):
|
||
out[cg] = c_conflict(pk, e)
|
||
out["lp-p-max"] = max_any_valid(p)
|
||
v1, v2, v3 = func_zoning(e, p, params)
|
||
out["lp-v1-conservative"], out["lp-v2-balanced"], out["lp-v3-development"] = v1, v2, v3
|
||
out["lp-c-max"] = max_any_valid([out[c] for c in C])
|
||
return out
|
||
|
||
|
||
# ------------------------------------------------------------------- HTTP (GET)
|
||
def http_get(url, timeout=120, retries=3):
|
||
req = urllib.request.Request(url, method="GET", headers={"User-Agent": "lp_verify/1.0"})
|
||
last = None
|
||
for attempt in range(retries):
|
||
try:
|
||
return urllib.request.urlopen(req, timeout=timeout)
|
||
except urllib.error.HTTPError as e:
|
||
if e.code < 500:
|
||
raise
|
||
last = e
|
||
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
||
last = e
|
||
time.sleep(2 * (attempt + 1))
|
||
raise last
|
||
|
||
|
||
def get_json(url):
|
||
with http_get(url) as r:
|
||
return json.loads(r.read().decode("utf-8"))
|
||
|
||
|
||
def download(url, path, wait=0):
|
||
"""GET url into path (reused when already cached). A 409 means the COG is
|
||
still being produced (conversion is asynchronous): retry for up to `wait`
|
||
seconds."""
|
||
if os.path.exists(path) and os.path.getsize(path) > 0:
|
||
return "cached"
|
||
tmp = path + ".part"
|
||
deadline = time.time() + wait
|
||
while True:
|
||
try:
|
||
r = http_get(url, timeout=600)
|
||
break
|
||
except urllib.error.HTTPError as e:
|
||
if e.code != 409 or time.time() >= deadline:
|
||
raise
|
||
time.sleep(2)
|
||
with r, open(tmp, "wb") as f:
|
||
shutil.copyfileobj(r, f, length=4 << 20)
|
||
os.replace(tmp, path)
|
||
return "%.1f MB" % (os.path.getsize(path) / 1e6)
|
||
|
||
|
||
# ----------------------------------------------------------------------- main
|
||
def parse_points(s):
|
||
pts = []
|
||
for part in filter(None, (x.strip() for x in s.split(";"))):
|
||
c, r = part.split(",")
|
||
pts.append((int(c), int(r)))
|
||
return pts
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
ap.add_argument("--base-url", default=os.environ.get("BASE_URL", "http://localhost:18080"))
|
||
ap.add_argument("--run", required=True, help="landscape run id")
|
||
ap.add_argument("--cache", help="directory for downloaded rasters (kept; existing files are reused)")
|
||
ap.add_argument("--inputs-from", choices=["cog", "download"], default="cog",
|
||
help="read inputs from their COG (default) or the original upload (what the node read)")
|
||
ap.add_argument("--block-rows", type=int, default=512)
|
||
ap.add_argument("--block-cols", type=int, default=4096)
|
||
ap.add_argument("--wait", type=int, default=180,
|
||
help="seconds to keep retrying a COG that is not ready yet (HTTP 409), default 180")
|
||
ap.add_argument("--points", help='spot-check pixels "col,row;col,row;..." and print their values')
|
||
args = ap.parse_args()
|
||
base = args.base_url.rstrip("/")
|
||
|
||
run = get_json("%s/landscape/runs/%s" % (base, args.run))
|
||
params = dict(DEFAULT_PARAMS)
|
||
raw_params = run.get("params") or {}
|
||
for k in DEFAULT_PARAMS:
|
||
if raw_params.get(k):
|
||
params[k] = raw_params[k]
|
||
print("run %s status=%s from_stage=%s" % (run["id"], run["status"], run.get("from_stage")))
|
||
print("params %s" % json.dumps(params))
|
||
nodes = sorted(run.get("nodes") or [], key=lambda n: n["node_code"])
|
||
print("nodes %s" % " ".join("%s:%s" % (n["node_code"], n["status"]) for n in nodes))
|
||
if run["status"] != "succeeded":
|
||
print("ERROR: run is not succeeded; nothing to verify")
|
||
return 2
|
||
|
||
errors = []
|
||
slot_ids, out_ids = {}, {}
|
||
for n in nodes:
|
||
for ref in n.get("inputs") or []:
|
||
g, did = ref["group"], ref["dataset_id"]
|
||
if g in SLOTS:
|
||
if slot_ids.setdefault(g, did) != did:
|
||
errors.append("slot %s resolved to two datasets (%s, %s)" % (g, slot_ids[g], did))
|
||
for ref in n.get("outputs") or []:
|
||
out_ids[ref["group"]] = ref["dataset_id"]
|
||
if OUTPUT_NODE.get(ref["group"]) != n["node_code"]:
|
||
errors.append("output %s reported by node %s" % (ref["group"], n["node_code"]))
|
||
# chain consistency: an intermediate input must be what this run produced
|
||
for n in nodes:
|
||
for ref in n.get("inputs") or []:
|
||
g = ref["group"]
|
||
if g in OUTPUTS and out_ids.get(g) != ref["dataset_id"]:
|
||
errors.append("node %s read %s=%s but the run's output is %s" % (n["node_code"], g, ref["dataset_id"], out_ids.get(g)))
|
||
missing = [g for g in SLOTS if g not in slot_ids] + [g for g in OUTPUTS if g not in out_ids]
|
||
if missing:
|
||
errors.append("run does not reference: %s" % ", ".join(missing))
|
||
if errors:
|
||
for e in errors:
|
||
print("ERROR:", e)
|
||
return 2
|
||
|
||
cache = args.cache or tempfile.mkdtemp(prefix="lp_verify_")
|
||
os.makedirs(cache, exist_ok=True)
|
||
files = {}
|
||
t0 = time.time()
|
||
for g in SLOTS + OUTPUTS:
|
||
did = slot_ids.get(g) or out_ids[g]
|
||
if g in SLOTS and args.inputs_from == "download":
|
||
url, path = "%s/datasets/%s/download" % (base, did), os.path.join(cache, did + ".orig.tif")
|
||
else:
|
||
url, path = "%s/datasets/%s.cog" % (base, did), os.path.join(cache, did + ".cog.tif")
|
||
try:
|
||
info = download(url, path, args.wait)
|
||
except urllib.error.HTTPError as e:
|
||
print("ERROR: GET %s -> HTTP %d (output not ready or missing?)" % (url, e.code))
|
||
return 2
|
||
files[g] = path
|
||
print("fetch %-22s %s %s" % (g, did, info))
|
||
print("downloaded in %.1f s to %s" % (time.time() - t0, cache))
|
||
|
||
with rasterio.Env(GDAL_CACHEMAX=512):
|
||
ds = {g: rasterio.open(files[g]) for g in SLOTS + OUTPUTS}
|
||
ref = ds[SLOTS[0]]
|
||
for g, d in ds.items():
|
||
if (d.width, d.height) != (ref.width, ref.height) or not d.transform.almost_equals(ref.transform) or d.crs != ref.crs:
|
||
errors.append("%s grid %dx%d %s differs from %s %dx%d %s" % (
|
||
g, d.width, d.height, d.crs, SLOTS[0], ref.width, ref.height, ref.crs))
|
||
for g in OUTPUTS:
|
||
if ds[g].dtypes[0] != "int16" or ds[g].nodata != -9999:
|
||
errors.append("output %s: dtype %s nodata %s (want int16, -9999)" % (g, ds[g].dtypes[0], ds[g].nodata))
|
||
if errors:
|
||
for e in errors:
|
||
print("ERROR:", e)
|
||
return 2
|
||
W, H = ref.width, ref.height
|
||
print("grid %dx%d %s pixel %.3f" % (W, H, ref.crs, ref.transform.a))
|
||
|
||
if args.points:
|
||
return spot_check(ds, parse_points(args.points), params)
|
||
|
||
mism = {g: 0 for g in OUTPUTS}
|
||
validn = {g: 0 for g in OUTPUTS}
|
||
first = {}
|
||
t0 = time.time()
|
||
for r0 in range(0, H, args.block_rows):
|
||
h = min(args.block_rows, H - r0)
|
||
for c0 in range(0, W, args.block_cols):
|
||
w = min(args.block_cols, W - c0)
|
||
win = Window(c0, r0, w, h)
|
||
inp = {g: ds[g].read(1, window=win, masked=False).astype(np.float64) for g in SLOTS}
|
||
exp = expected_block(inp, params)
|
||
for g in OUTPUTS:
|
||
act = ds[g].read(1, window=win, masked=False)
|
||
bad = exp[g] != act
|
||
nbad = int(bad.sum())
|
||
validn[g] += int((exp[g] != NODATA).sum())
|
||
if nbad:
|
||
mism[g] += nbad
|
||
if g not in first:
|
||
rr, cc = np.argwhere(bad)[0]
|
||
first[g] = (c0 + int(cc), r0 + int(rr), int(exp[g][rr, cc]), int(act[rr, cc]))
|
||
print("compared in %.1f s" % (time.time() - t0))
|
||
for d in ds.values():
|
||
d.close()
|
||
|
||
print()
|
||
print("%-20s %-4s %-38s %12s %12s %10s" % ("output", "node", "dataset_id", "pixels", "valid(exp)", "mismatch"))
|
||
total = 0
|
||
for g in OUTPUTS:
|
||
total += mism[g]
|
||
line = "%-20s %-4s %-38s %12d %12d %10d" % (g, OUTPUT_NODE[g], out_ids[g], W * H, validn[g], mism[g])
|
||
if g in first:
|
||
line += " first at col=%d row=%d expected=%d actual=%d" % first[g]
|
||
print(line)
|
||
print()
|
||
print("TOTAL mismatching pixels: %d -> %s" % (total, "PASS" if total == 0 else "FAIL"))
|
||
return 0 if total == 0 else 1
|
||
|
||
|
||
def spot_check(ds, points, params):
|
||
bad = 0
|
||
shown = ["lp-z-integrated", "lp-u-integrated", "lp-e", *C, "lp-v1-conservative", "lp-v2-balanced", "lp-v3-development"]
|
||
for col, row in points:
|
||
win = Window(col, row, 1, 1)
|
||
inp = {g: ds[g].read(1, window=win, masked=False).astype(np.float64) for g in SLOTS}
|
||
exp = expected_block(inp, params)
|
||
vals = " ".join("%s=%d" % (g.replace("lp-", ""), int(inp[g][0, 0])) for g in SLOTS)
|
||
print("point (%d,%d) inputs: %s" % (col, row, vals))
|
||
for g in shown:
|
||
act = int(ds[g].read(1, window=win, masked=False)[0, 0])
|
||
e = int(exp[g][0, 0])
|
||
ok = act == e
|
||
bad += 0 if ok else 1
|
||
print(" %-20s expected=%6d actual=%6d %s" % (g, e, act, "OK" if ok else "MISMATCH"))
|
||
print("SPOT CHECK: %d points, %d mismatches -> %s" % (len(points), bad, "PASS" if bad == 0 else "FAIL"))
|
||
return 0 if bad == 0 else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|