17 KiB
Landscape Program (ЛП) + dataset groups and dates — design
Date: 2026-09-05. Source: LP_20260902.excalidraw, TZ_functional_zoning_go.md,
zoning_core.py, Landscape.ipynb (reference numpy implementation).
Decisions taken with the product owner are recorded inline as Decision.
1. Goals
- Dataset versioning. Datasets can belong to a dataset group ("одноимённые"
datasets), carry a
start_date/end_dateperiod, be filtered by those, and the "freshest" dataset of a group can be fetched. - Landscape Program pipeline. A DAG of raster computations (Z, U, E, C, P_max, C_max, functional zoning V1–V3) executed by the worker, with results stored as ordinary raster datasets (COG + SLD) served by the existing dataset endpoints, incremental recompute, and a dedicated upload for input rasters.
Non-goals: spatial operations (resampling, reprojection), GUI, changing the vector_with_kato flow beyond the column rename below.
2. Part 1 — Dataset groups and dates
2.1 Schema (migration 00010_dataset_groups_and_dates.sql)
CREATE TABLE dataset_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(255) NOT NULL UNIQUE, -- slug, same rules as categories.code
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
sld_storage_key TEXT,
sld_filename VARCHAR(255),
sld_content_type VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE datasets ADD COLUMN dg_id UUID REFERENCES dataset_groups(id) ON DELETE RESTRICT;
ALTER TABLE datasets ADD COLUMN start_date DATE;
ALTER TABLE datasets ADD COLUMN end_date DATE;
CREATE INDEX idx_datasets_dg_id_end_date ON datasets (dg_id, end_date DESC NULLS LAST, created_at DESC);
CREATE INDEX idx_datasets_start_date ON datasets (start_date);
CREATE INDEX idx_datasets_end_date ON datasets (end_date);
-- Decision: rename the observation/mapping period columns (breaking API change).
ALTER TABLE dataset_observations RENAME COLUMN date TO start_date;
ALTER TABLE dataset_observations RENAME COLUMN date_to TO end_date;
-- unique index and idx_dataset_observations_kato_date recreated on the new names.
-- datasets.year_columns JSONB: every element's keys "date" -> "start_date",
-- "date_to" -> "end_date" (UPDATE with jsonb_agg over jsonb_array_elements; NULL-safe).
Constraint: end_date >= start_date when both set (CHECK constraint + service validation).
2.2 Domain changes
domain.DatasetGroup{ID, Code, Name, Description, SldStorageKey, SldFilename, SldContentType, CreatedAt, UpdatedAt}.domain.Datasetanddomain.DatasetSummarygainDatasetGroupID *uuid.UUID(json:"dg_id"),StartDate *string(json:"start_date",YYYY-MM-DD),EndDate *string(json:"end_date").domain.Observation:Date→StartDate(json:"start_date"),DateTo→EndDate(json:"end_date").domain.YearColumn: same rename. Mapping request body usesstart_date(required) /end_date(optional). Everything that previously saiddate/date_toin code, OpenAPI and README is renamed. GeoJSON output keeps itsdata(keyed by start date) andperiods(start→end) shape.domain.DatasetFiltergainsDatasetGroupID *uuid.UUID,StartDateFrom,StartDateTo,EndDateFrom,EndDateTo *string(inclusive bounds).
2.3 API
| Method & path | Behaviour |
|---|---|
GET /dataset-groups |
paginated list, ?code= exact filter optional |
POST /dataset-groups |
{code, name, description}; 201 |
GET /dataset-groups/{id} |
group |
PUT /dataset-groups/{id} |
replace {code, name, description} |
DELETE /dataset-groups/{id} |
204; 409 when datasets reference it; also removes group SLD object |
GET /dataset-groups/{id}/latest |
freshest dataset of the group: order by end_date DESC NULLS LAST, created_at DESC; optional ?status=, ?file_type=; 404 when none |
POST /dataset-groups/{id}/sld |
multipart file (.sld/.xml), replaces; mirrors dataset SLD upload |
GET /dataset-groups/{id}/sld |
download; 404 when none |
GET /datasets |
new filters dg_id, start_date_from, start_date_to, end_date_from, end_date_to (all YYYY-MM-DD, inclusive, any combination) |
POST /datasets |
new optional form fields dg_id, start_date, end_date |
PUT /datasets/{id} |
body gains dg_id, start_date, end_date (nullable; omitted/null clears) |
PATCH /datasets/{id} |
sparse; same three fields, null clears |
GET /datasets/{id}/sld |
fallback: when the dataset has no SLD but its group has one, the group SLD is served |
Validation: dg_id must reference an existing group (404/422 as for category_id);
dates parse as 2006-01-02; end_date not before start_date.
DatasetSummary (list responses) includes dg_id, start_date, end_date.
2.4 Freshness rule
"Самый свежий" = greatest end_date; datasets with NULL end_date sort after all
dated ones; ties broken by created_at DESC. The same rule is used by the pipeline
to resolve inputs (with status = ready, file_type = raster).
3. Part 2 — Landscape Program
3.1 Computation graph
Node code XY: X = stage, Y = function in the stage. A stage starts only when
every node of all lower stages finished. Every input and output of every node is a
dataset group; leaf groups are the slots users upload to.
| Node | Name | Inputs (group codes) | Outputs (group codes) |
|---|---|---|---|
| 00 | z_integrated | lp-z-biodiversity, lp-z-lcr, lp-z-soil-protection, lp-z-wr |
lp-z-integrated |
| 01 | u_integrated | lp-u-surf-water, lp-u-soil, lp-u-crop, lp-u-veg |
lp-u-integrated |
| 02 | z_max | Z1..Z4 as above | lp-z-max |
| 03 | u_max | U as above | lp-u-max |
| 10 | e_ecological_constraint | lp-z-integrated, lp-u-integrated |
lp-e |
| 20 | c_conflicts | lp-p-irrigation, lp-p-snow, lp-p-crop, lp-p-pasture, lp-p-tourism, lp-e |
lp-c-irrigation, lp-c-snow, lp-c-crop, lp-c-pasture, lp-c-tourism |
| 21 | p_max | five P groups | lp-p-max |
| 30 | func_zoning | lp-e, five C groups |
lp-v1-conservative, lp-v2-balanced, lp-v3-development |
| 31 | c_max | five C groups | lp-c-max |
Sector order everywhere: 1 irrigation, 2 snow, 3 crop, 4 pasture, 5 tourism.
Decision: func_zoning takes S_k = C_k (folder 300-…-Equals_102_202), not P_k.
Groups and the category landscape ("Ландшафтная программа") are seeded by
migration 00011_landscape.sql (INSERT … ON CONFLICT (code) DO NOTHING). The
graph itself lives in Go (internal/landscape/graph.go), not in the DB.
3.2 Pixel rules (from the reference notebook; must match it bitwise)
Notation: a value is valid iff 1 <= v <= 5; anything else (including nodata) is
invalid. Output nodata is -9999. classify(x) maps a float32 score to a class:
x <= 1.8 → 1, <= 2.6 → 2, <= 3.4 → 3, <= 4.2 → 4, <= 5.0 → 5 (and requires
x >= 1.0); comparisons are done in float32 against float32 constants, exactly as
numpy does with a float32 array.
| Node | Valid domain (pixel) | Value |
|---|---|---|
| z_integrated | all four Z valid | classify(0.25*Z1 + 0.25*Z2 + 0.25*Z3 + 0.25*Z4) (float32) |
| u_integrated | water ∧ soil ∧ (crop ∨ veg) | classify(num/den), num = 0.25*water + 0.25*soil + [0.25*crop if crop valid] + [0.25*veg if veg valid], den = 0.5 + [0.25] + [0.25] |
| z_max | all four valid | max(Z1..Z4) |
| u_max | water ∧ soil ∧ (crop ∨ veg) | max over the valid ones of water, soil, crop, veg |
| e | Z ∧ U valid | max(Z, U) |
| c_conflicts (5 outputs) | P_j ∧ E valid | min(P_j, E) |
| p_max | at least one P valid | max over valid P |
| c_max | at least one C valid | max over valid C |
| func_zoning (3 outputs) | see TZ §4 | see below |
Functional zoning (TZ TZ_functional_zoning_go.md, reference zoning_core.py):
eValid = valid(E); base_k = eValid && valid(S_k); analysisValid = eValid && any(base_k)
if !analysisValid → -9999 in all scenarios
candidate_k = base_k && rule_v(E, S_k)
V1: (E <= 2 && S >= v1_min_s_e12) || (E == 3 && S >= v1_min_s_e3) (defaults 3, 4)
V2: (E <= 2) || (E == 3 && S >= v2_min_s_e3) || (E == 4 && S >= v2_min_s_e4) (defaults 3, 5)
V3: (E <= 4)
no candidates → class 1 (regulated); else winners = candidates with S == max(S over candidates);
sector = first of tie_priority (default [1,2,3,4,5]) among winners; class = sector + 1
Output classes: 1 regulated, 2 irrigation, 3 snow, 4 crop, 5 pasture, 6 tourism.
3.3 Raster I/O abstraction (Decision: godal behind an interface)
Package internal/raster (existing) gains an engine-agnostic interface so the
implementation can be swapped (godal today; gdal_calc, ENVI+CLI or another binding
later) without touching the pipeline:
type Grid struct { Width, Height int; GeoTransform [6]float64; ProjectionWKT string }
type Window struct { X, Y, Width, Height int }
type Reader interface {
Grid() Grid
NoData() (value float64, ok bool)
ReadFloat64(w Window, dst []float64) error // band 1, converted by the driver
Close() error
}
type WriterOptions struct { NoData int16; TileSize int; Compression string /* DEFLATE|ZSTD */ }
type Writer interface {
WriteInt16(w Window, src []int16) error // band 1
Close() error
}
type IO interface {
Open(ctx context.Context, path string) (Reader, error)
Create(ctx context.Context, path string, g Grid, o WriterOptions) (Writer, error)
SameGrid(a, b Grid) bool // size, transform (abs tol 1e-9), CRS (GDAL IsSame)
}
internal/raster/godal.go implements IO with github.com/airbusgeo/godal (cgo).
Output GeoTIFF: int16, nodata -9999, TILED=YES, BLOCKXSIZE=BLOCKYSIZE=512,
COMPRESS=DEFLATE, PREDICTOR=2, BIGTIFF=IF_SAFER; georeference copied from the
node's first input (for func_zoning: E). Inputs are read as float64 regardless of
their storage type; multi-band inputs use band 1.
The existing RasterConverter (gdal_translate / gdalinfo / ogr2ogr CLI) stays as is
and keeps producing the COG for every result dataset.
Build: CGO_ENABLED=1; go.mod gets toolchain go1.26.2 (the local SDK is
incomplete) and the godal dependency. Dockerfile: builder golang:1.26-alpine +
gdal-dev gcc musl-dev pkgconfig; runtime alpine:3.22 + gdal gdal-tools.
3.4 Compute engine (internal/landscape)
graph.go: slots, nodes, stages, group codes, node-level param subsets.kernels.go: pure per-window functions over[]float64inputs →[]int16outputs, one per node. No I/O, fully unit-tested (TZ §7 edge cases included).compute.go:Compute(ctx, io raster.IO, node Node, inputs map[string]string /*group→local path*/, outDir string, p Params) (map[string]string /*group→path*/, error). Opens inputs, fails if grids differ (Decision: no resampling), creates outputs on the first input's grid, iterates windows ofp.WindowSize(default 2048) with a worker pool: each worker owns its ownReaderhandles (GDAL datasets are not goroutine-safe); writes are serialised per output through a mutex. Window order does not affect results.- Nodes of one stage run concurrently (
errgroup); stages run sequentially.
3.5 Runs, incremental recompute
Migration 00011_landscape.sql:
CREATE TABLE lp_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(32) NOT NULL, -- queued | running | succeeded | failed
from_stage SMALLINT, -- NULL = incremental
params JSONB NOT NULL,
overrides JSONB NOT NULL DEFAULT '{}', -- {group_code: dataset_id}
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ
);
CREATE TABLE lp_run_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID NOT NULL REFERENCES lp_runs(id) ON DELETE CASCADE,
node_code VARCHAR(8) NOT NULL,
stage SMALLINT NOT NULL,
status VARCHAR(32) NOT NULL, -- pending | running | skipped | succeeded | failed
fingerprint TEXT NOT NULL,
input_datasets JSONB NOT NULL, -- [{group, dataset_id}]
output_datasets JSONB, -- [{group, dataset_id}]
error TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
UNIQUE (run_id, node_code)
);
CREATE INDEX idx_lp_run_nodes_lookup ON lp_run_nodes (node_code, fingerprint, status);
Run execution (worker, queue gis.landscape.run, routing key landscape.run):
- Mark run
running. For each stage in order, for each node concurrently: - Resolve inputs per input group:
overrides[group]if present; else the dataset produced for that group earlier in this run; else the freshestreadyraster of the group (§2.4). Missing → node fails with a clear message. - Fingerprint = sha256 of node code, sorted
(group, dataset_id)pairs, and the node-relevant params. Iffrom_stageis NULL orstage < from_stage, and a previoussucceedednode row with the same fingerprint exists whose output datasets all still exist → markskipped, reuse those outputs. Otherwise compute. - Download inputs to a temp dir,
Compute, then for each output create a raster dataset (§3.6), recordoutput_datasets, marksucceeded. - Any node failure fails the stage; remaining stages are not started; run
failedwith the error. Otherwise runsucceeded.
from_stage = 0 recomputes everything ("триггер на перерасчёт всего").
3.6 Result datasets
Every output is stored through a new service method
DatasetService.CreateRasterFromFile(ctx, CreateRasterInput{CategoryID, GroupID, Name, Filename, Path, StartDate, EndDate, Meta}): uploads the file, inserts a
raster dataset (automated = true, status = processing), enqueues the existing
COG conversion. Name = <Output name> <run started_at date>, Filename =
<group>.tif, start_date/end_date = min/max over the node's inputs (NULL if all
NULL), meta = {"lp_run_id", "lp_node", "inputs": [{group, dataset_id}]}.
Downstream nodes read the original object (storage_key), not the COG, so they never
wait for conversion. SLD for a result comes from its group (dataset SLD fallback, §2.3).
3.7 Landscape API (/landscape)
| Method & path | Behaviour |
|---|---|
GET /landscape/slots |
every leaf slot: code, title, group id, latest dataset summary (any status) or null |
POST /landscape/slots/{code} |
dedicated input upload: multipart file (.tif/.tiff, TIFF magic check), optional name, start_date, end_date. Creates a raster dataset in the slot's group under the landscape category via the regular upload path (COG conversion enqueued). 201 Dataset. Unknown slot → 404 |
POST /landscape/runs |
body {from_stage?: 0..3, overrides?: {group_code: dataset_id}, params?: {window_size?, tie_priority?, v1_min_s_e12?, v1_min_s_e3?, v2_min_s_e3?, v2_min_s_e4?}}; validates overrides (existing ready raster), inserts run queued + pending node rows, publishes job; 202 with run |
GET /landscape/runs |
paginated, newest first |
GET /landscape/runs/{id} |
run with nodes (status, inputs, outputs, error, timings) |
GET /landscape/results |
per output group: group, title, node, latest ready dataset (or null) with cog_url = /datasets/{id}.cog, sld_url when the group has an SLD |
Params defaults: window_size 2048, tie_priority [1,2,3,4,5], v1 3/4, v2 3/5.
Validation: tie_priority is a permutation of 1..5; thresholds in 1..5; window_size
256..8192.
3.8 Error handling
- Grid mismatch, invalid CRS, missing input, GDAL error → node
failedwith message; runfailed. No retry (consistent with existing consumers: nack without requeue). - Non-nodata values outside 1..5 are treated as invalid pixels (TZ §2), not fatal.
- Result dataset creation failure after compute → node failed, temp files removed.
- Temp files always cleaned with
defer.
3.9 Testing
- Kernels: table tests incl. TZ §7 cases (invalid E, all S invalid, E=5, E=4 with several S=5, all S equal, applicability of crop/veg for U, "any valid" for max nodes).
Compute: tests with an in-memory fakeraster.IO(10×10 grids), including grid mismatch failure and window tiling with sizes that do not divide the raster.- godal implementation: round-trip test on a temp GeoTIFF (runs when GDAL present).
- Services: stub repositories as in
dataset_test.go; run planning (fingerprint, skip/reuse, from_stage), input resolution order (override → this run → latest). - HTTP: handler tests for validation paths.
- Part 1: filter clause building, latest ordering (nulls last), rename in mapping and observations, SLD fallback.
3.10 Out of scope / follow-ups
- Resampling/alignment of inputs (fails by design).
- Continuous (float) intermediate rasters and diagnostic layers from the notebook (tie counts, component counts, critical flags).
- Real-data acceptance run against the Drive test set (inputs there are not aligned).