name: index-timeseries
version: "0.1"
category: time-series-trends          # derived from family pattern (index-timeseries)
themes: [imagery]                     # index-agnostic; instance themes ride the index (cryosphere here)
family: spectral-index-map-timeseries
description: >
  See any single spectral index change over time across an AOI, and filter by
  its value. For every clear Sentinel-2 pass over the AOI, compute a chosen
  normalized-difference index (NDSI, NDVI, NDWI, ...) on surface reflectance,
  and emit a per-scene raster stack plus a thresholded mask (e.g. index > 0.7).
  The index stack is the scrubable map layer; the threshold is a live control.

  This is the simplest member of the index-timeseries family: no temporal
  reduction, no domain-specific gates, no SAR fusion. Pick an index, pick a
  threshold, pick an AOI and a date window, and watch where and when the index
  crosses your line. snow-cover-fsc is the specialized, validated descendant
  that adds the Gascoin shadow gate, a continuous FSC sigmoid, and water-year
  phenology on top of this same skeleton.

# ─────────────────────────────────────────────────────────────────────────────
# PARAMETERIZATION
#   settings.index:  which index — its formula, bands, value range, and (if its
#                    bands differ in native resolution) a resample rule. Swapping
#                    this one block is what turns this recipe from NDSI into NDVI
#                    or NDWI. See examples/ndvi-central-valley.yaml.
#   settings.threshold + direction:  the value filter. The mask layer is
#                    index <direction> threshold; threshold-brush moves it live.
#   inputs:          the bound instance — AOI feature + date window + collection.
# ─────────────────────────────────────────────────────────────────────────────

settings:
  index:
    slug: ndsi
    # raster_calc `expression`: normalized snow index (green vs SWIR1) on BOA-offset-
    # corrected DN. The +offset cancels in a normalized difference's scale but NOT its
    # offset, so it must be subtracted from each band. `reflectance_offset` is passed as
    # a scalar param and referenced by name. JS expression syntax (the raster_calc VM).
    expression: "((b1 - reflectance_offset) - (b2 - reflectance_offset)) / ((b1 - reflectance_offset) + (b2 - reflectance_offset))"
    bands: [G, S1]                     # CANONICAL band codes (registry/datasets/esa/sentinel-2/l2a
                                       # `bands:`), not MSPC asset keys — every distribution maps
                                       # its own asset names onto these. b1/b2 bind in this order.
    range: [-1, 1]
    colormap: blues

  threshold: 0.7                       # value filter; pixels kept where index > threshold
  threshold_direction: above           # above | below

  reflectance_offset: 1000             # baseline >=04.00 BOA offset (DN - offset); scale cancels in ND

  max_bad_fraction: 0.30               # AOI-level cloud gate: drop a scene when bad SCL > 30% of the AOI
  bad_scl_classes: [0, 1, 3, 8, 9, 10] # 0 nodata,1 saturated,3 cloud-shadow,8/9 cloud med/high,10 cirrus

inputs:
  aoi:
    name: Mount Timpanogos Wilderness
    feature: "@overture/land_use/ef8710c4-a02f-3652-bbfa-2bed58d3dc41"
    geometry: research/index-explorer/src/data/timpanogos-wilderness.geojson
    # The read window. `feature`/`geometry` above are provenance: parse is offline, so the
    # engine resolves neither into an extent. This bbox is the geometry's own envelope.
    bbox: [-111.6906, 40.3481, -111.6021, 40.4322]
    crs_target: EPSG:32612
    mgrs_tile: "12TVK"

  date_range:
    start: "2024-06-01"                # 2 snow cycles keeps the MVP stack small
    end:   "2026-06-12"

  collection:
    uri: "@esa/sentinel-2/l2a"
    assets: [G, S1, SCL]               # settings.index.bands + SCL gate

layers:

  # ── SOURCE ──────────────────────────────────────────────────────────────────

  source/s2:
    type: raster
    uri: "@esa/sentinel-2/l2a"
    description: >
      Every S2 L2A pass over the AOI, windowed (block-snapped range reads), one
      item per acquisition date (prefer lowest nodata on MPC reprocessed dupes).
      Each scene is gated at the AOI-polygon level from its SCL window before the
      index bands are read, so a cloudy pass costs one tiny SCL read.
    params:
      # `scenes: true` selects the PER-SCENE bridge (LAYERS.md #source-spelling rule 3): one
      # band-set per acquisition, which is what every "(t, y, x)" and "per-scene" claim in
      # this file assumes. Without it this layer resolved to a single composited raster —
      # `type: raster_timeseries` was the only thing expressing the intent, and the engine
      # reads `type:` for the size estimate only, never for mechanism selection.
      scenes: true
      # SANCTIONED spelling: `bbox`/`temporal`/`bands`. The STAC-flavored aliases
      # (`aoi:`/`datetime:`/`assets:`) still fold onto these at parse, but they also enter the
      # layer key as dead material, so the canonical spelling is both clearer and cheaper.
      bbox: "${inputs.aoi.bbox}"
      temporal: ["${inputs.date_range.start}", "${inputs.date_range.end}"]
      bands: "${inputs.collection.assets}"
      dedup_by: acquisition_date
      aoi_cloud_gate:
        scl_asset: SCL
        bad_classes: "${settings.bad_scl_classes}"
        max_bad_fraction: "${settings.max_bad_fraction}"

  # ── COMPUTE ──────────────────────────────────────────────────────────────────

  compute/index:
    type: raster
    description: >
      The index per scene. source/s2 is a per-scene stack (`scenes: true`), so raster_calc runs
      in temporal mode: it evaluates `expression` per acquisition date over the
      green/swir-1 bands and returns a date-keyed stack. That (t, y, x) stack IS the
      scrubable map layer. Band-grid alignment (G 10m vs S1 20m) is handled at
      read time by the source maxSize normalization; see T2 verify.
    compute:
      op: raster_calc
      inputs:
        data: { layer: source/s2 }
      params:
        expression: "${settings.index.expression}"
        reflectance_offset: "${settings.reflectance_offset}"
        output_band: index

  compute/mask:
    type: raster
    description: >
      The value filter as a per-scene 0/1 stack: 1 where the index crosses the
      threshold, else 0. raster_calc temporal mode again, one band per date.
      threshold-brush recomputes this client-side as the user drags the cutoff
      (host.recompute); the baked layer is the default at settings.threshold.
    compute:
      op: raster_calc
      inputs:
        data: { layer: compute/index }           # raster_calc reads `data` (or `raster`); an
                                                 # edge under any other name is never bound.
      params:
        # `where(cond, then, else)` is the dialect's conditional — there is NO ternary in the
        # expression VM (`? :` fails to compile). The upstream layer's single band binds as
        # `b1`; band names are not identifiers. Dialect:
        # folia/domains/geo/operations/_functions.yaml
        expression: "where(b1 > threshold, 1, 0)"  # direction=above
        threshold: "${settings.threshold}"
        output_band: mask

outputs:
  primary:
    - compute/index                   # zarr (t,y,x) float32 — the index stack
    - compute/mask                    # zarr (t,y,x) uint8   — the thresholded stack
  show_your_work:
    - compute/index
  provenance:
    - source_scene_ids                # STAC item id per timestamp

# ── DASHBOARD ───────────────────────────────────────────────────────────────
# Which view-patterns compose this recipe's result screen, and which output
# layer fills each. This is the recipe -> pattern-set link (Option 3): a curated
# default set; a UI may offer any other type-compatible pattern from the gallery.
# Patterns live in packages/view-patterns; each speaks only the PatternHost seam.
dashboard:
  patterns:
    - slug: time-scrub-availability   # scrubber + clear/clouded strip over the stack
      slot: temporal
      layer: compute/index
    - slug: threshold-brush           # drag the cutoff; recompute compute/mask
      slot: recipe-manipulation
      layer: compute/index
      binds: settings.threshold
    - slug: legend-as-histogram       # value distribution of the current scene
      slot: recipe-manipulation
      layer: compute/index
    - slug: pixel-trajectory-climatology   # click a pixel -> its index over time
      slot: temporal
      layer: compute/index

license:
  recipe: Apache-2.0
  output_dataset: CC-BY-4.0
  upstream_data:
    - { source: sentinel-2-l2a, license: usgs-public-domain }

# Reference implementation this recipe generalizes (NDSI special case):
#   research/index-explorer/scripts/timpanogos_ndsi.py
