name: topo-derivatives
version: "0.1"
category: features-objects            # rolls up via family pattern terrain-analysis
themes: [terrain]
# Concepts this recipe touches: elevation is the input it consumes; the rest
# are the derivatives it produces. build-recipes-catalog.py reads this list and
# the library RefIndex inverts it so each concept page lists this recipe.
concepts: [elevation, slope, aspect, curvature, tpi, tri]
family: terrain-derivatives-zonal
license: CC-BY-4.0
description: >
  Six canonical terrain derivatives from a DEM — slope, aspect, curvature,
  TPI, TRI, geomorphons — emitted as single-band COGs over an AOI.

  This is the substrate layer beneath suitability, routing, microclimate,
  shade, hardiness, view-shed and parametric-flood recipes across the
  catalog. The default input is USGS 3DEP 10m for US AOIs (highest resolution
  in the registry within US extent); non-US AOIs should fork and override
  the input to @esa/copernicus-dem/glo-30.

  All six derivatives are deterministic functions of the DEM; there are no
  model knobs that need calibration. The only knobs are the neighborhood
  scales for TPI and geomorphons, which control the SIZE of the landforms
  the classifier resolves. Defaults match GRASS r.geomorphon's reference
  values (search_radius=5 cells, flatness=1.0 deg).

# -----------------------------------------------------------------------------
# PARAMETERIZATION
#   settings: method knobs with defaults — inherited by every AOI fork.
#   inputs:   the bound instance — the AOI feature + DEM source. Forks override
#             ONLY `inputs` (see examples/slc-90min.yaml).
# -----------------------------------------------------------------------------

settings:
  # CRS: pick UTM zone matching the AOI centroid. Default is UTM 12N (covers
  # most of Utah); forks override via inputs.crs when needed.
  default_crs: EPSG:32612

  # TPI neighborhood — radius in cells. 5 -> 11x11 window. At 10 m DEM that's
  # ~110 m, enough to separate a single ridge crest from valley bottom in
  # the Wasatch. Larger = broader landforms.
  tpi_radius: 5

  # Geomorphons knobs. search_radius is the line-of-sight cap in cells; at
  # 10 m DEM, 5 cells = 50 m. The threshold below which the LOS zenith /
  # nadir angle is treated as "flat" is in degrees.
  geomorphons_search_radius: 5
  geomorphons_flatness_threshold_deg: 1.0

  # Curvature: total = Laplacian (convex+, concave-). Profile and planform
  # are available via the same op if a fork wants flow-routed analysis.
  curvature_type: total

  # Slope is reported in degrees (0-90), aspect in degrees (0-360, N=0).
  slope_units: degrees
  slope_algorithm: horn

inputs:
  aoi:
    name: Default — SLC 90-minute drive isochrone bbox
    bbox: [-113.0, 40.0, -111.0, 41.5]    # ~220 km E-W x 165 km N-S
    crs_target: EPSG:32612                # UTM 12N (most of Utah)

  dem:
    uri: "@usgs/3dep/10m"                 # CONUS+AK+HI+PR; 10 m seamless
    collection: 3dep-seamless              # MSPC collection id
    asset: data

layers:

  # --- SOURCE ---------------------------------------------------------------

  source/dem:
    type: raster
    uri: "${inputs.dem.uri}"
    description: >
      USGS 3DEP 10m seamless DEM, windowed to the AOI bbox and reprojected
      to inputs.crs_target. The fetched window covers the AOI plus a one-cell
      buffer so the six derivative ops have clean edges.
    params:
      bbox: "${inputs.aoi.bbox}"
      crs: "${inputs.aoi.crs_target}"

  # --- DERIVATIVES (six COGs) ----------------------------------------------

  derived/slope:
    type: raster
    description: >
      Slope angle in degrees (0 = flat, 90 = vertical). Horn (1981)
      3x3 weighted average — the GDAL default. Float32. Output COG.
    compute:
      op: terrain_slope
      inputs:
        dem: { layer: source/dem }
      params:
        units: "${settings.slope_units}"
        algorithm: "${settings.slope_algorithm}"
    style:
      renderer: maplibre
      palette: slope_full
      rescale: "0,60"

  derived/aspect:
    type: raster
    description: >
      Direction the slope faces, in degrees from north, clockwise.
      0 = N, 90 = E, 180 = S, 270 = W. Flat pixels are nodata (transparent).
      Float32. Output COG with nearest-neighbor overviews (averaging
      circular angles is wrong).
    compute:
      op: terrain_aspect
      inputs:
        dem: { layer: source/dem }
      params:
        algorithm: "${settings.slope_algorithm}"
    style:
      renderer: maplibre
      palette: aspect_8dir
      rescale: "0,360"

  derived/curvature:
    type: raster
    description: >
      Total (Laplacian) curvature: d2z/dx2 + d2z/dy2. Positive = convex
      (ridges, divergent flow), negative = concave (valleys, convergent
      flow). Units of 1/length (1/m at 10 m DEM). Float32.
    compute:
      op: terrain_curvature
      inputs:
        dem: { layer: source/dem }
      params:
        type: "${settings.curvature_type}"
    style:
      renderer: maplibre
      palette: rdylbu
      rescale: "-0.01,0.01"

  derived/tpi:
    type: raster
    description: >
      Topographic Position Index: elevation minus mean elevation of an
      11x11 neighborhood (radius = 5 cells = ~50 m at 10 m DEM).
      Positive = ridges/hilltops, negative = valleys/depressions, zero
      = flat or mid-slope. Float32, units = meters.
    compute:
      op: terrain_tpi
      inputs:
        dem: { layer: source/dem }
      params:
        radius: "${settings.tpi_radius}"
    style:
      renderer: maplibre
      palette: rdylbu
      rescale: "-30,30"

  derived/tri:
    type: raster
    description: >
      Terrain Ruggedness Index: sqrt(mean(z_center - z_neighbor)^2) over
      the 8 neighbors. Higher = more rugged. Riley et al. (1999).
      Float32, units = meters of local relief.
    compute:
      op: terrain_tri
      inputs:
        dem: { layer: source/dem }
    style:
      renderer: maplibre
      palette: ylorbr
      rescale: "0,50"

  derived/geomorphons:
    type: raster
    description: >
      Landform classification (Jasiewicz & Stepinski 2013): each pixel
      classified into one of 10 classes from the line-of-sight pattern
      of its 8-neighborhood at the configured search radius.

      Classes (uint8): 1=flat, 2=peak, 3=ridge, 4=shoulder, 5=spur,
      6=slope, 7=hollow, 8=footslope, 9=valley, 10=pit. 0 = nodata.
      The scale of "what counts as a peak" is set by search_radius;
      defaults to 5 cells (~50 m at 10 m DEM).
    compute:
      op: terrain_geomorphons
      inputs:
        dem: { layer: source/dem }
      params:
        search_radius: "${settings.geomorphons_search_radius}"
        flatness_threshold_deg: "${settings.geomorphons_flatness_threshold_deg}"
    style:
      renderer: maplibre
      palette: geomorphons
      categorical: true

outputs:
  primary:
    - derived/slope          # COG, float32, degrees
    - derived/aspect         # COG, float32, degrees (0-360)
    - derived/curvature      # COG, float32, 1/m
    - derived/tpi            # COG, float32, meters
    - derived/tri            # COG, float32, meters
    - derived/geomorphons    # COG, uint8, 1-10 categorical

# Reference runner (the script that exercises this recipe with default
# inputs and a small AOI end-to-end, no Folia executor):
#   registry/recipes/topo-derivatives/run.py
