From 831ddd115408eb32e9e386f73fc6fd3dc7af5a54 Mon Sep 17 00:00:00 2001 From: Bakhtiyar Issakhmetov Date: Sat, 5 Sep 2026 13:16:17 +0500 Subject: [PATCH] feat(landscape): computation graph, params and per-pixel kernels Co-Authored-By: Claude Fable 5.1 --- internal/landscape/graph.go | 153 +++++++++++++++++++++ internal/landscape/graph_test.go | 134 ++++++++++++++++++ internal/landscape/kernels.go | 212 +++++++++++++++++++++++++++++ internal/landscape/kernels_test.go | 123 +++++++++++++++++ internal/landscape/params.go | 81 +++++++++++ internal/landscape/params_test.go | 58 ++++++++ 6 files changed, 761 insertions(+) create mode 100644 internal/landscape/graph.go create mode 100644 internal/landscape/graph_test.go create mode 100644 internal/landscape/kernels.go create mode 100644 internal/landscape/kernels_test.go create mode 100644 internal/landscape/params.go create mode 100644 internal/landscape/params_test.go diff --git a/internal/landscape/graph.go b/internal/landscape/graph.go new file mode 100644 index 0000000..5059e81 --- /dev/null +++ b/internal/landscape/graph.go @@ -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 +} diff --git a/internal/landscape/graph_test.go b/internal/landscape/graph_test.go new file mode 100644 index 0000000..ec04762 --- /dev/null +++ b/internal/landscape/graph_test.go @@ -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") + } +} diff --git a/internal/landscape/kernels.go b/internal/landscape/kernels.go new file mode 100644 index 0000000..537ec32 --- /dev/null +++ b/internal/landscape/kernels.go @@ -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 + } + } +} diff --git a/internal/landscape/kernels_test.go b/internal/landscape/kernels_test.go new file mode 100644 index 0000000..5975d67 --- /dev/null +++ b/internal/landscape/kernels_test.go @@ -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) + } +} diff --git a/internal/landscape/params.go b/internal/landscape/params.go new file mode 100644 index 0000000..c2606b2 --- /dev/null +++ b/internal/landscape/params.go @@ -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{} +} diff --git a/internal/landscape/params_test.go b/internal/landscape/params_test.go new file mode 100644 index 0000000..bf5717e --- /dev/null +++ b/internal/landscape/params_test.go @@ -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) + } +}