feat(landscape): computation graph, params and per-pixel kernels
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
153
internal/landscape/graph.go
Normal file
153
internal/landscape/graph.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Package landscape defines the Landscape Program (ЛП) computation graph — the
|
||||
// per-pixel formulas that turn ecological (Z), vulnerability (U) and
|
||||
// suitability (P) class rasters into constraints (E), conflicts (C) and
|
||||
// functional zoning (V1–V3) — together with a window-based executor.
|
||||
package landscape
|
||||
|
||||
import "sort"
|
||||
|
||||
const (
|
||||
// NoData is the output nodata value of every LP raster.
|
||||
NoData int16 = -9999
|
||||
// CategoryCode is the dataset category holding every LP dataset.
|
||||
CategoryCode = "landscape"
|
||||
)
|
||||
|
||||
// Group codes. Slots are leaf inputs users upload; the rest are node outputs.
|
||||
const (
|
||||
SlotZBiodiversity = "lp-z-biodiversity"
|
||||
SlotZLCR = "lp-z-lcr"
|
||||
SlotZSoilProtection = "lp-z-soil-protection"
|
||||
SlotZWR = "lp-z-wr"
|
||||
SlotUSurfWater = "lp-u-surf-water"
|
||||
SlotUSoil = "lp-u-soil"
|
||||
SlotUCrop = "lp-u-crop"
|
||||
SlotUVeg = "lp-u-veg"
|
||||
SlotPIrrigation = "lp-p-irrigation"
|
||||
SlotPSnow = "lp-p-snow"
|
||||
SlotPCrop = "lp-p-crop"
|
||||
SlotPPasture = "lp-p-pasture"
|
||||
SlotPTourism = "lp-p-tourism"
|
||||
|
||||
OutZIntegrated = "lp-z-integrated"
|
||||
OutUIntegrated = "lp-u-integrated"
|
||||
OutZMax = "lp-z-max"
|
||||
OutUMax = "lp-u-max"
|
||||
OutE = "lp-e"
|
||||
OutCIrrigation = "lp-c-irrigation"
|
||||
OutCSnow = "lp-c-snow"
|
||||
OutCCrop = "lp-c-crop"
|
||||
OutCPasture = "lp-c-pasture"
|
||||
OutCTourism = "lp-c-tourism"
|
||||
OutPMax = "lp-p-max"
|
||||
OutV1 = "lp-v1-conservative"
|
||||
OutV2 = "lp-v2-balanced"
|
||||
OutV3 = "lp-v3-development"
|
||||
OutCMax = "lp-c-max"
|
||||
)
|
||||
|
||||
type Slot struct{ Code, Title string }
|
||||
type Output struct{ Code, Title string }
|
||||
|
||||
// Kernel computes one window. in[i] follows Node.Inputs order and out[j]
|
||||
// follows Node.Outputs order; every slice has exactly window-pixel length.
|
||||
type Kernel func(in [][]float64, out [][]int16, p Params)
|
||||
|
||||
// Node is one function of the graph. Stage = first digit of Code.
|
||||
type Node struct {
|
||||
Code, Name string
|
||||
Stage int
|
||||
Inputs []string
|
||||
Outputs []string
|
||||
Kernel Kernel
|
||||
}
|
||||
|
||||
var Slots = []Slot{
|
||||
{SlotZBiodiversity, "Z Biodiversity"}, {SlotZLCR, "Z LCR"}, {SlotZSoilProtection, "Z Soil Protection"}, {SlotZWR, "Z Water Resources"},
|
||||
{SlotUSurfWater, "U Surface Water"}, {SlotUSoil, "U Soil"}, {SlotUCrop, "U Crop"}, {SlotUVeg, "U Vegetation"},
|
||||
{SlotPIrrigation, "P Irrigation"}, {SlotPSnow, "P Snow"}, {SlotPCrop, "P Crop"}, {SlotPPasture, "P Pasture"}, {SlotPTourism, "P Tourism"},
|
||||
}
|
||||
|
||||
var Outputs = []Output{
|
||||
{OutZIntegrated, "Z Integrated"}, {OutUIntegrated, "U Integrated"}, {OutZMax, "Z Max"}, {OutUMax, "U Max"},
|
||||
{OutE, "E Ecological Constraint"},
|
||||
{OutCIrrigation, "C Irrigation"}, {OutCSnow, "C Snow"}, {OutCCrop, "C Crop"}, {OutCPasture, "C Pasture"}, {OutCTourism, "C Tourism"},
|
||||
{OutPMax, "P Max"},
|
||||
{OutV1, "V1 Conservative"}, {OutV2, "V2 Balanced"}, {OutV3, "V3 Development"}, {OutCMax, "C Max"},
|
||||
}
|
||||
|
||||
var zInputs = []string{SlotZBiodiversity, SlotZLCR, SlotZSoilProtection, SlotZWR}
|
||||
var uInputs = []string{SlotUSurfWater, SlotUSoil, SlotUCrop, SlotUVeg} // order matters: water, soil, crop, veg
|
||||
var pInputs = []string{SlotPIrrigation, SlotPSnow, SlotPCrop, SlotPPasture, SlotPTourism} // sector order 1..5
|
||||
var cOutputs = []string{OutCIrrigation, OutCSnow, OutCCrop, OutCPasture, OutCTourism}
|
||||
|
||||
var Nodes = []Node{
|
||||
{Code: "00", Name: "z_integrated", Stage: 0, Inputs: zInputs, Outputs: []string{OutZIntegrated}, Kernel: kernelZIntegrated},
|
||||
{Code: "01", Name: "u_integrated", Stage: 0, Inputs: uInputs, Outputs: []string{OutUIntegrated}, Kernel: kernelUIntegrated},
|
||||
{Code: "02", Name: "z_max", Stage: 0, Inputs: zInputs, Outputs: []string{OutZMax}, Kernel: kernelZMax},
|
||||
{Code: "03", Name: "u_max", Stage: 0, Inputs: uInputs, Outputs: []string{OutUMax}, Kernel: kernelUMax},
|
||||
{Code: "10", Name: "e_ecological_constraint", Stage: 1, Inputs: []string{OutZIntegrated, OutUIntegrated}, Outputs: []string{OutE}, Kernel: kernelE},
|
||||
{Code: "20", Name: "c_conflicts", Stage: 2, Inputs: append(append([]string{}, pInputs...), OutE), Outputs: cOutputs, Kernel: kernelCConflicts},
|
||||
{Code: "21", Name: "p_max", Stage: 2, Inputs: pInputs, Outputs: []string{OutPMax}, Kernel: kernelMaxAnyValid},
|
||||
{Code: "30", Name: "func_zoning", Stage: 3, Inputs: append([]string{OutE}, cOutputs...), Outputs: []string{OutV1, OutV2, OutV3}, Kernel: kernelFuncZoning},
|
||||
{Code: "31", Name: "c_max", Stage: 3, Inputs: cOutputs, Outputs: []string{OutCMax}, Kernel: kernelMaxAnyValid},
|
||||
}
|
||||
|
||||
func NodeByCode(code string) (Node, bool) {
|
||||
for _, n := range Nodes {
|
||||
if n.Code == code {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return Node{}, false
|
||||
}
|
||||
|
||||
func SlotByCode(code string) (Slot, bool) {
|
||||
for _, s := range Slots {
|
||||
if s.Code == code {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return Slot{}, false
|
||||
}
|
||||
|
||||
func OutputByCode(code string) (Output, bool) {
|
||||
for _, o := range Outputs {
|
||||
if o.Code == code {
|
||||
return o, true
|
||||
}
|
||||
}
|
||||
return Output{}, false
|
||||
}
|
||||
|
||||
// NodeForOutput returns the node producing the given output group.
|
||||
func NodeForOutput(group string) (Node, bool) {
|
||||
for _, n := range Nodes {
|
||||
for _, o := range n.Outputs {
|
||||
if o == group {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return Node{}, false
|
||||
}
|
||||
|
||||
// Stages groups nodes by stage, ascending; within a stage nodes are sorted by code.
|
||||
func Stages() [][]Node {
|
||||
byStage := map[int][]Node{}
|
||||
for _, n := range Nodes {
|
||||
byStage[n.Stage] = append(byStage[n.Stage], n)
|
||||
}
|
||||
keys := make([]int, 0, len(byStage))
|
||||
for k := range byStage {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
out := make([][]Node, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
ns := byStage[k]
|
||||
sort.Slice(ns, func(i, j int) bool { return ns[i].Code < ns[j].Code })
|
||||
out = append(out, ns)
|
||||
}
|
||||
return out
|
||||
}
|
||||
134
internal/landscape/graph_test.go
Normal file
134
internal/landscape/graph_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package landscape
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var slugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
// isSlot reports whether code names one of the leaf input slots.
|
||||
func isSlot(code string) bool {
|
||||
_, ok := SlotByCode(code)
|
||||
return ok
|
||||
}
|
||||
|
||||
func TestNodeInputsComeFromSlotsOrLowerStage(t *testing.T) {
|
||||
for _, n := range Nodes {
|
||||
for _, in := range n.Inputs {
|
||||
if isSlot(in) {
|
||||
continue
|
||||
}
|
||||
producer, ok := NodeForOutput(in)
|
||||
if !ok {
|
||||
t.Errorf("node %s: input %q is neither a slot nor a known node output", n.Code, in)
|
||||
continue
|
||||
}
|
||||
if producer.Stage >= n.Stage {
|
||||
t.Errorf("node %s (stage %d): input %q is produced by node %s at stage %d, want stage < %d",
|
||||
n.Code, n.Stage, in, producer.Code, producer.Stage, n.Stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryOutputHasExactlyOneProducer(t *testing.T) {
|
||||
count := map[string]int{}
|
||||
for _, n := range Nodes {
|
||||
for _, o := range n.Outputs {
|
||||
count[o]++
|
||||
}
|
||||
}
|
||||
for _, o := range Outputs {
|
||||
if c := count[o.Code]; c != 1 {
|
||||
t.Errorf("output %q has %d producers, want exactly 1", o.Code, c)
|
||||
}
|
||||
}
|
||||
// No node should produce something that isn't in the Outputs list.
|
||||
for code, c := range count {
|
||||
if _, ok := OutputByCode(code); !ok {
|
||||
t.Errorf("node output %q (%d producers) is not declared in Outputs", code, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStagesGroupingAndOrder(t *testing.T) {
|
||||
stages := Stages()
|
||||
if len(stages) != 4 {
|
||||
t.Fatalf("got %d stages, want 4", len(stages))
|
||||
}
|
||||
want := [][]string{
|
||||
{"00", "01", "02", "03"},
|
||||
{"10"},
|
||||
{"20", "21"},
|
||||
{"30", "31"},
|
||||
}
|
||||
for i, group := range stages {
|
||||
var codes []string
|
||||
for _, n := range group {
|
||||
codes = append(codes, n.Code)
|
||||
}
|
||||
if len(codes) != len(want[i]) {
|
||||
t.Fatalf("stage group %d: got codes %v, want %v", i, codes, want[i])
|
||||
}
|
||||
for j, c := range codes {
|
||||
if c != want[i][j] {
|
||||
t.Errorf("stage group %d: got codes %v, want %v", i, codes, want[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllGroupCodesAreValidSlugs(t *testing.T) {
|
||||
for _, s := range Slots {
|
||||
if !slugRe.MatchString(s.Code) {
|
||||
t.Errorf("slot code %q is not a valid slug", s.Code)
|
||||
}
|
||||
}
|
||||
for _, o := range Outputs {
|
||||
if !slugRe.MatchString(o.Code) {
|
||||
t.Errorf("output code %q is not a valid slug", o.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeByCode(t *testing.T) {
|
||||
n, ok := NodeByCode("00")
|
||||
if !ok || n.Name != "z_integrated" {
|
||||
t.Fatalf("NodeByCode(00) = %+v, %v", n, ok)
|
||||
}
|
||||
if _, ok := NodeByCode("99"); ok {
|
||||
t.Fatal("NodeByCode(99) should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotByCode(t *testing.T) {
|
||||
s, ok := SlotByCode(SlotZBiodiversity)
|
||||
if !ok || s.Title != "Z Biodiversity" {
|
||||
t.Fatalf("SlotByCode(%s) = %+v, %v", SlotZBiodiversity, s, ok)
|
||||
}
|
||||
if _, ok := SlotByCode("nope"); ok {
|
||||
t.Fatal("SlotByCode(nope) should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputByCode(t *testing.T) {
|
||||
o, ok := OutputByCode(OutE)
|
||||
if !ok || o.Title != "E Ecological Constraint" {
|
||||
t.Fatalf("OutputByCode(%s) = %+v, %v", OutE, o, ok)
|
||||
}
|
||||
if _, ok := OutputByCode("nope"); ok {
|
||||
t.Fatal("OutputByCode(nope) should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeForOutput(t *testing.T) {
|
||||
n, ok := NodeForOutput(OutV1)
|
||||
if !ok || n.Code != "30" {
|
||||
t.Fatalf("NodeForOutput(%s) = %+v, %v", OutV1, n, ok)
|
||||
}
|
||||
if _, ok := NodeForOutput("nope"); ok {
|
||||
t.Fatal("NodeForOutput(nope) should not be found")
|
||||
}
|
||||
}
|
||||
212
internal/landscape/kernels.go
Normal file
212
internal/landscape/kernels.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package landscape
|
||||
|
||||
// valid reports whether a class value is in the analysis domain (1..5). Any
|
||||
// other value, including nodata and NaN, is invalid.
|
||||
func valid(v float64) bool { return v >= 1 && v <= 5 }
|
||||
|
||||
// classify maps a float32 score onto classes 1..5 with the fixed intervals
|
||||
// used by the reference notebook (comparisons in float32, like numpy on a
|
||||
// float32 array). Scores outside [1, 5] yield NoData.
|
||||
func classify(x float32) int16 {
|
||||
switch {
|
||||
case x < 1.0:
|
||||
return NoData
|
||||
case x <= 1.8:
|
||||
return 1
|
||||
case x <= 2.6:
|
||||
return 2
|
||||
case x <= 3.4:
|
||||
return 3
|
||||
case x <= 4.2:
|
||||
return 4
|
||||
case x <= 5.0:
|
||||
return 5
|
||||
default:
|
||||
return NoData
|
||||
}
|
||||
}
|
||||
|
||||
// kernelZIntegrated: all four Z valid -> classify(0.25*Z1 + 0.25*Z2 + 0.25*Z3 + 0.25*Z4).
|
||||
func kernelZIntegrated(in [][]float64, out [][]int16, _ Params) {
|
||||
for i := range out[0] {
|
||||
z1, z2, z3, z4 := in[0][i], in[1][i], in[2][i], in[3][i]
|
||||
if !(valid(z1) && valid(z2) && valid(z3) && valid(z4)) {
|
||||
out[0][i] = NoData
|
||||
continue
|
||||
}
|
||||
score := float32(0.25)*float32(z1) + float32(0.25)*float32(z2) + float32(0.25)*float32(z3) + float32(0.25)*float32(z4)
|
||||
out[0][i] = classify(score)
|
||||
}
|
||||
}
|
||||
|
||||
// kernelUIntegrated: water and soil required, at least one of crop/veg;
|
||||
// U = (0.25*water + 0.25*soil + [0.25*crop] + [0.25*veg]) / (0.5 + [0.25] + [0.25]).
|
||||
func kernelUIntegrated(in [][]float64, out [][]int16, _ Params) {
|
||||
for i := range out[0] {
|
||||
water, soil, crop, veg := in[0][i], in[1][i], in[2][i], in[3][i]
|
||||
cropOK, vegOK := valid(crop), valid(veg)
|
||||
if !(valid(water) && valid(soil) && (cropOK || vegOK)) {
|
||||
out[0][i] = NoData
|
||||
continue
|
||||
}
|
||||
num := float32(0.25)*float32(water) + float32(0.25)*float32(soil)
|
||||
den := float32(0.5)
|
||||
if cropOK {
|
||||
num += float32(0.25) * float32(crop)
|
||||
den += 0.25
|
||||
}
|
||||
if vegOK {
|
||||
num += float32(0.25) * float32(veg)
|
||||
den += 0.25
|
||||
}
|
||||
out[0][i] = classify(num / den)
|
||||
}
|
||||
}
|
||||
|
||||
// kernelZMax: all four valid -> max.
|
||||
func kernelZMax(in [][]float64, out [][]int16, _ Params) {
|
||||
for i := range out[0] {
|
||||
m, ok := int16(0), true
|
||||
for _, layer := range in {
|
||||
v := layer[i]
|
||||
if !valid(v) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
if int16(v) > m {
|
||||
m = int16(v)
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
m = NoData
|
||||
}
|
||||
out[0][i] = m
|
||||
}
|
||||
}
|
||||
|
||||
// kernelUMax: water and soil required plus one of crop/veg; max over valid layers.
|
||||
func kernelUMax(in [][]float64, out [][]int16, _ Params) {
|
||||
for i := range out[0] {
|
||||
water, soil, crop, veg := in[0][i], in[1][i], in[2][i], in[3][i]
|
||||
cropOK, vegOK := valid(crop), valid(veg)
|
||||
if !(valid(water) && valid(soil) && (cropOK || vegOK)) {
|
||||
out[0][i] = NoData
|
||||
continue
|
||||
}
|
||||
m := max(int16(water), int16(soil))
|
||||
if cropOK {
|
||||
m = max(m, int16(crop))
|
||||
}
|
||||
if vegOK {
|
||||
m = max(m, int16(veg))
|
||||
}
|
||||
out[0][i] = m
|
||||
}
|
||||
}
|
||||
|
||||
// kernelE: Z and U valid -> max(Z, U).
|
||||
func kernelE(in [][]float64, out [][]int16, _ Params) {
|
||||
for i := range out[0] {
|
||||
z, u := in[0][i], in[1][i]
|
||||
if !(valid(z) && valid(u)) {
|
||||
out[0][i] = NoData
|
||||
continue
|
||||
}
|
||||
out[0][i] = max(int16(z), int16(u))
|
||||
}
|
||||
}
|
||||
|
||||
// kernelCConflicts: inputs P1..P5, E; output j = min(Pj, E) where both valid.
|
||||
func kernelCConflicts(in [][]float64, out [][]int16, _ Params) {
|
||||
e := in[5]
|
||||
for j := 0; j < 5; j++ {
|
||||
p := in[j]
|
||||
for i := range out[j] {
|
||||
if !(valid(p[i]) && valid(e[i])) {
|
||||
out[j][i] = NoData
|
||||
continue
|
||||
}
|
||||
out[j][i] = min(int16(p[i]), int16(e[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// kernelMaxAnyValid: max over the valid inputs; NoData when none is valid.
|
||||
// Used for P_max and C_max.
|
||||
func kernelMaxAnyValid(in [][]float64, out [][]int16, _ Params) {
|
||||
for i := range out[0] {
|
||||
m, any := int16(0), false
|
||||
for _, layer := range in {
|
||||
if v := layer[i]; valid(v) {
|
||||
any = true
|
||||
m = max(m, int16(v))
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
m = NoData
|
||||
}
|
||||
out[0][i] = m
|
||||
}
|
||||
}
|
||||
|
||||
// kernelFuncZoning implements TZ_functional_zoning_go.md §4. Inputs: E, then
|
||||
// S1..S5 (irrigation, snow, crop, pasture, tourism). Outputs: V1, V2, V3.
|
||||
func kernelFuncZoning(in [][]float64, out [][]int16, p Params) {
|
||||
e := in[0]
|
||||
rules := [3]func(e, s int16) bool{
|
||||
func(e, s int16) bool {
|
||||
return (e <= 2 && s >= int16(p.V1MinSE12)) || (e == 3 && s >= int16(p.V1MinSE3))
|
||||
},
|
||||
func(e, s int16) bool {
|
||||
return e <= 2 || (e == 3 && s >= int16(p.V2MinSE3)) || (e == 4 && s >= int16(p.V2MinSE4))
|
||||
},
|
||||
func(e, s int16) bool { return e <= 4 },
|
||||
}
|
||||
var s [5]int16
|
||||
var base [5]bool
|
||||
for i := range out[0] {
|
||||
if !valid(e[i]) {
|
||||
out[0][i], out[1][i], out[2][i] = NoData, NoData, NoData
|
||||
continue
|
||||
}
|
||||
ev := int16(e[i])
|
||||
anyBase := false
|
||||
for k := 0; k < 5; k++ {
|
||||
base[k] = valid(in[k+1][i])
|
||||
if base[k] {
|
||||
s[k] = int16(in[k+1][i])
|
||||
anyBase = true
|
||||
}
|
||||
}
|
||||
if !anyBase {
|
||||
out[0][i], out[1][i], out[2][i] = NoData, NoData, NoData
|
||||
continue
|
||||
}
|
||||
for v := 0; v < 3; v++ {
|
||||
var cand [5]bool
|
||||
maxS, anyCand := int16(0), false
|
||||
for k := 0; k < 5; k++ {
|
||||
cand[k] = base[k] && rules[v](ev, s[k])
|
||||
if cand[k] {
|
||||
anyCand = true
|
||||
if s[k] > maxS {
|
||||
maxS = s[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
if !anyCand {
|
||||
out[v][i] = 1 // regulated
|
||||
continue
|
||||
}
|
||||
sector := int16(0)
|
||||
for _, code := range p.TiePriority {
|
||||
k := code - 1
|
||||
if cand[k] && s[k] == maxS {
|
||||
sector = int16(code)
|
||||
break
|
||||
}
|
||||
}
|
||||
out[v][i] = sector + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
123
internal/landscape/kernels_test.go
Normal file
123
internal/landscape/kernels_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package landscape
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func run(k Kernel, nOut int, in ...[]float64) [][]int16 {
|
||||
out := make([][]int16, nOut)
|
||||
for j := range out {
|
||||
out[j] = make([]int16, len(in[0]))
|
||||
}
|
||||
k(in, out, DefaultParams())
|
||||
return out
|
||||
}
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
cases := map[float32]int16{1.0: 1, 1.8: 1, 1.81: 2, 2.6: 2, 2.75: 3, 3.4: 3, 3.5: 4, 4.2: 4, 4.25: 5, 5.0: 5}
|
||||
for x, want := range cases {
|
||||
if got := classify(x); got != want {
|
||||
t.Errorf("classify(%v)=%d want %d", x, got, want)
|
||||
}
|
||||
}
|
||||
if classify(0.9) != NoData || classify(5.1) != NoData {
|
||||
t.Error("out of range must be NoData")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelZIntegrated(t *testing.T) {
|
||||
// pixel0: all valid (1,2,3,4) -> 2.5 -> class 2 ; pixel1: one nodata -> NoData ; pixel2: all 5 -> 5 -> class 5
|
||||
got := run(kernelZIntegrated, 1,
|
||||
[]float64{1, -9999, 5}, []float64{2, 2, 5}, []float64{3, 3, 5}, []float64{4, 4, 5})
|
||||
if want := []int16{2, NoData, 5}; !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("got %v want %v", got[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelUIntegrated_Applicability(t *testing.T) {
|
||||
// order: water, soil, crop, veg
|
||||
// p0: all valid 1,1,5,5 -> (0.25+0.25+1.25+1.25)/1.0 = 3.0 -> class 3
|
||||
// p1: crop invalid: 1,1,-,5 -> (0.25+0.25+1.25)/0.75 = 2.333 -> class 2
|
||||
// p2: crop and veg invalid -> NoData
|
||||
// p3: water invalid -> NoData
|
||||
got := run(kernelUIntegrated, 1,
|
||||
[]float64{1, 1, 1, -9999}, []float64{1, 1, 1, 1}, []float64{5, 0, 0, 5}, []float64{5, 5, 0, 5})
|
||||
if want := []int16{3, 2, NoData, NoData}; !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("got %v want %v", got[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelZMax_RequiresAllValid(t *testing.T) {
|
||||
got := run(kernelZMax, 1, []float64{1, 1}, []float64{4, 4}, []float64{2, -9999}, []float64{3, 3})
|
||||
if want := []int16{4, NoData}; !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("got %v want %v", got[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelUMax_IgnoresInvalidCropVeg(t *testing.T) {
|
||||
// water, soil, crop, veg ; p0: 1,2,-,5 -> 5 ; p1: 3,2,0,0 -> NoData (needs crop or veg) ; p2: 3,2,1,0 -> 3
|
||||
got := run(kernelUMax, 1, []float64{1, 3, 3}, []float64{2, 2, 2}, []float64{0, 0, 1}, []float64{5, 0, 0})
|
||||
if want := []int16{5, NoData, 3}; !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("got %v want %v", got[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelE(t *testing.T) {
|
||||
got := run(kernelE, 1, []float64{2, 5, -9999}, []float64{4, 1, 3})
|
||||
if want := []int16{4, 5, NoData}; !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("got %v want %v", got[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelCConflicts(t *testing.T) {
|
||||
// 5 P then E; p0: E=3 -> min(P,3); p1: E invalid -> all NoData; p2: P2 invalid
|
||||
e := []float64{3, 0, 4}
|
||||
got := run(kernelCConflicts, 5,
|
||||
[]float64{5, 5, 5}, []float64{1, 1, 7}, []float64{3, 3, 3}, []float64{4, 4, 4}, []float64{2, 2, 2}, e)
|
||||
want := [][]int16{{3, NoData, 4}, {1, NoData, NoData}, {3, NoData, 3}, {3, NoData, 4}, {2, NoData, 2}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelMaxAnyValid(t *testing.T) {
|
||||
got := run(kernelMaxAnyValid, 1, []float64{1, 0, 0}, []float64{4, 0, 2}, []float64{-9999, 0, 0})
|
||||
if want := []int16{4, NoData, 2}; !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("got %v want %v", got[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
// TZ §7 edge cases. Inputs: E, then C1..C5 (irrigation, snow, crop, pasture, tourism).
|
||||
func TestKernelFuncZoning_TZEdgeCases(t *testing.T) {
|
||||
e := []float64{0, 3, 5, 4, 2, 2, 3}
|
||||
s1 := []float64{3, 0, 3, 5, 3, 2, 4}
|
||||
s2 := []float64{3, 0, 3, 5, 3, 2, 4}
|
||||
s3 := []float64{3, 0, 3, 2, 3, 2, 5}
|
||||
s4 := []float64{3, 0, 3, 2, 3, 2, 1}
|
||||
s5 := []float64{3, 0, 3, 2, 3, 2, 1}
|
||||
got := run(kernelFuncZoning, 3, e, s1, s2, s3, s4, s5)
|
||||
// pixel: 0 E invalid -> NoData ; 1 all S invalid -> NoData ; 2 E=5 -> regulated(1) everywhere ;
|
||||
// 3 E=4, S=5 for sectors 1,2 -> V1 regulated, V2/V3 sector 1 -> class 2 ;
|
||||
// 4 all S equal (3), E=2 -> irrigation (2) in all ; 5 E=2, S=2 -> V1 regulated (S<3), V2 best=irrigation (2), V3 irrigation (2)
|
||||
// 6 E=3: S=[4,4,5,1,1] -> V1 (S>=4): winners 1,2,3 max 5 -> crop (4); V2 (S>=3) -> crop (4); V3 -> crop (4)
|
||||
want := [][]int16{
|
||||
{NoData, NoData, 1, 1, 2, 1, 4}, // V1
|
||||
{NoData, NoData, 1, 2, 2, 2, 4}, // V2
|
||||
{NoData, NoData, 1, 2, 2, 2, 4}, // V3
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelFuncZoning_TiePriorityIsConfigurable(t *testing.T) {
|
||||
in := [][]float64{{2}, {3}, {3}, {3}, {3}, {3}}
|
||||
out := [][]int16{{0}, {0}, {0}}
|
||||
p := DefaultParams()
|
||||
p.TiePriority = []int{5, 4, 3, 2, 1}
|
||||
kernelFuncZoning(in, out, p)
|
||||
if out[0][0] != 6 || out[1][0] != 6 || out[2][0] != 6 {
|
||||
t.Fatalf("expected tourism (6) everywhere, got %v", out)
|
||||
}
|
||||
}
|
||||
81
internal/landscape/params.go
Normal file
81
internal/landscape/params.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package landscape
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Params are the run-time knobs of the graph (TZ §6): window size, tie
|
||||
// priority for functional zoning, and the V1/V2 suitability thresholds.
|
||||
type Params struct {
|
||||
WindowSize int `json:"window_size"`
|
||||
TiePriority []int `json:"tie_priority"`
|
||||
V1MinSE12 int `json:"v1_min_s_e12"`
|
||||
V1MinSE3 int `json:"v1_min_s_e3"`
|
||||
V2MinSE3 int `json:"v2_min_s_e3"`
|
||||
V2MinSE4 int `json:"v2_min_s_e4"`
|
||||
}
|
||||
|
||||
// DefaultParams returns the TZ defaults.
|
||||
func DefaultParams() Params {
|
||||
return Params{WindowSize: 2048, TiePriority: []int{1, 2, 3, 4, 5}, V1MinSE12: 3, V1MinSE3: 4, V2MinSE3: 3, V2MinSE4: 5}
|
||||
}
|
||||
|
||||
// WithDefaults fills zero-valued fields from DefaultParams.
|
||||
func (p Params) WithDefaults() Params {
|
||||
d := DefaultParams()
|
||||
if p.WindowSize == 0 {
|
||||
p.WindowSize = d.WindowSize
|
||||
}
|
||||
if len(p.TiePriority) == 0 {
|
||||
p.TiePriority = d.TiePriority
|
||||
}
|
||||
if p.V1MinSE12 == 0 {
|
||||
p.V1MinSE12 = d.V1MinSE12
|
||||
}
|
||||
if p.V1MinSE3 == 0 {
|
||||
p.V1MinSE3 = d.V1MinSE3
|
||||
}
|
||||
if p.V2MinSE3 == 0 {
|
||||
p.V2MinSE3 = d.V2MinSE3
|
||||
}
|
||||
if p.V2MinSE4 == 0 {
|
||||
p.V2MinSE4 = d.V2MinSE4
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Validate checks ranges: window 256..8192, thresholds 1..5, tie_priority a
|
||||
// permutation of 1..5.
|
||||
func (p Params) Validate() error {
|
||||
var errs []error
|
||||
if p.WindowSize < 256 || p.WindowSize > 8192 {
|
||||
errs = append(errs, fmt.Errorf("window_size must be in 256..8192"))
|
||||
}
|
||||
for name, v := range map[string]int{"v1_min_s_e12": p.V1MinSE12, "v1_min_s_e3": p.V1MinSE3, "v2_min_s_e3": p.V2MinSE3, "v2_min_s_e4": p.V2MinSE4} {
|
||||
if v < 1 || v > 5 {
|
||||
errs = append(errs, fmt.Errorf("%s must be in 1..5", name))
|
||||
}
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, k := range p.TiePriority {
|
||||
if k < 1 || k > 5 || seen[k] {
|
||||
errs = append(errs, fmt.Errorf("tie_priority must be a permutation of 1..5"))
|
||||
break
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
if len(p.TiePriority) != 5 && len(seen) == len(p.TiePriority) {
|
||||
errs = append(errs, fmt.Errorf("tie_priority must list all of 1..5"))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// ForNode keeps only the fields that influence the node's result (window size
|
||||
// never does), so fingerprints do not change when irrelevant knobs change.
|
||||
func (p Params) ForNode(code string) Params {
|
||||
if code == "30" {
|
||||
return Params{TiePriority: p.TiePriority, V1MinSE12: p.V1MinSE12, V1MinSE3: p.V1MinSE3, V2MinSE3: p.V2MinSE3, V2MinSE4: p.V2MinSE4}
|
||||
}
|
||||
return Params{}
|
||||
}
|
||||
58
internal/landscape/params_test.go
Normal file
58
internal/landscape/params_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package landscape
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultParamsValidate(t *testing.T) {
|
||||
if err := DefaultParams().Validate(); err != nil {
|
||||
t.Fatalf("DefaultParams().Validate() = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDefaultsFillsZeroFields(t *testing.T) {
|
||||
got := Params{}.WithDefaults()
|
||||
want := DefaultParams()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Params{}.WithDefaults() = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_TiePriorityDuplicateInvalid(t *testing.T) {
|
||||
p := DefaultParams()
|
||||
p.TiePriority = []int{1, 1, 2, 3, 4}
|
||||
if err := p.Validate(); err == nil {
|
||||
t.Fatal("expected error for tie_priority with duplicate values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_WindowSizeOutOfRangeInvalid(t *testing.T) {
|
||||
p := DefaultParams()
|
||||
p.WindowSize = 100
|
||||
if err := p.Validate(); err == nil {
|
||||
t.Fatal("expected error for window_size below 256")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForNode_NonFuncZoningNodeIsZero(t *testing.T) {
|
||||
p := DefaultParams()
|
||||
got := p.ForNode("00")
|
||||
if !reflect.DeepEqual(got, Params{}) {
|
||||
t.Fatalf("ForNode(00) = %+v, want zero value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForNode_FuncZoningHasNoWindowSize(t *testing.T) {
|
||||
p := DefaultParams()
|
||||
got := p.ForNode("30")
|
||||
if got.WindowSize != 0 {
|
||||
t.Fatalf("ForNode(30).WindowSize = %d, want 0", got.WindowSize)
|
||||
}
|
||||
if !reflect.DeepEqual(got.TiePriority, p.TiePriority) {
|
||||
t.Fatalf("ForNode(30).TiePriority = %v, want %v", got.TiePriority, p.TiePriority)
|
||||
}
|
||||
if got.V1MinSE12 != p.V1MinSE12 || got.V1MinSE3 != p.V1MinSE3 || got.V2MinSE3 != p.V2MinSE3 || got.V2MinSE4 != p.V2MinSE4 {
|
||||
t.Fatalf("ForNode(30) thresholds = %+v, want matching %+v", got, p)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user