Files
gis/docs/superpowers/specs/2026-09-05-landscape-program-design.md
2026-09-05 12:57:53 +05:00

318 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
1. **Dataset versioning.** Datasets can belong to a *dataset group* ("одноимённые"
datasets), carry a `start_date` / `end_date` period, be filtered by those, and the
"freshest" dataset of a group can be fetched.
2. **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`)
```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.Dataset` and `domain.DatasetSummary` gain `DatasetGroupID *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 uses
`start_date` (required) / `end_date` (optional). Everything that previously said
`date`/`date_to` in code, OpenAPI and README is renamed. GeoJSON output keeps its
`data` (keyed by start date) and `periods` (start→end) shape.
- `domain.DatasetFilter` gains `DatasetGroupID *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:
```go
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 `[]float64` inputs → `[]int16`
outputs, 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 of `p.WindowSize`
(default 2048) with a worker pool: each worker owns its own `Reader` handles
(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`:
```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`):
1. Mark run `running`. For each stage in order, for each node concurrently:
2. **Resolve inputs** per input group: `overrides[group]` if present; else the dataset
produced for that group earlier in *this* run; else the freshest `ready` raster of
the group (§2.4). Missing → node fails with a clear message.
3. **Fingerprint** = sha256 of node code, sorted `(group, dataset_id)` pairs, and the
node-relevant params. If `from_stage` is NULL or `stage < from_stage`, and a
previous `succeeded` node row with the same fingerprint exists whose output
datasets all still exist → mark `skipped`, reuse those outputs. Otherwise compute.
4. Download inputs to a temp dir, `Compute`, then for each output create a raster
dataset (§3.6), record `output_datasets`, mark `succeeded`.
5. Any node failure fails the stage; remaining stages are not started; run `failed`
with the error. Otherwise run `succeeded`.
`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 `failed` with message;
run `failed`. 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 fake `raster.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).