name: raster-timeseries
version: "0.1"
category: time-series-trends
themes: [imagery]
family: spectral-index-map-timeseries
description: >
  Scrub through annual composites of a normalized-difference index over any
  AOI and date window. Sources Sentinel-2 L2A from Microsoft Planetary
  Computer, gates per-scene pixels with the SCL classification mask, computes
  a per-scene index, then collapses each calendar year to a single median
  raster. Output is a year-keyed band stack that folia-view exposes as a
  scene scrubber via `folia-raster-bands`; the runner mounts a minimal year
  slider on top.

  Sibling of `index-timeseries` (per-scene) — this one trades temporal grain
  (annual, not per-pass) for a clean cross-decade story without cloud
  artifacts in any single frame.

# NOTE on backing dataset
# ───────────────────────
# v0 ships against Sentinel-2 L2A because MSPC's HLS storage account
# (`hls2euwest.blob.core.windows.net`) does NOT have CORS configured for
# browser access — SAS-signed asset URLs return 206 without the
# Access-Control-Allow-Origin header, so range reads fail in the browser.
# When that's resolved (or when we add an HLS proxy in api.folia.sh), swap
# the source URI to `stac://microsoft-pc/hls2-l30` and the assets to
# `[B04, B05, Fmask]` with a `Fmask < 16` clear-sky gate (HLS Fmask is
# bit-encoded; bits 4-7 are cirrus/cloud/adj-cloud/shadow).

# NOTE on authoring vocabulary
# ─────────────────────────────
# Authored workspace-direct (same shape as landtrendr-s2-bootleg-fire) so the
# generic recipe runner renders it without a recipe→workspace translator.
# When .tickets/impl/index-timeseries-mvp/runner-and-result-manifest.md (T4)
# lands a `type: raster_timeseries` translator, re-author in that vocabulary.

settings:
  # Default AOI: ~5.5 km × 5 km over the Yamsay Mountain core of the Bootleg
  # Fire scar (Fremont-Winema NF, Oregon). High-severity burn fills the frame,
  # 2021 NDVI collapse and 2022-2025 partial recovery are unmistakable in the
  # year scrub. ~30 km² ≈ 4% of the full fire perimeter — kept small on
  # purpose because every linear dimension of the bbox scales (a) network
  # bytes for COG range reads, (b) decoded-pixel memory during the compute
  # DAG, and (c) WASM raster_calc ops. A 28 km × 28 km bbox at S2 native 10 m
  # hits ~2.8 GB peak float32 memory across 30 scenes; this one is ~120 MB.
  # See examples/bootleg-wide.yaml for the wider view when context matters
  # more than speed. Override at runtime via ?settings.default_bbox=[w,s,e,n].
  default_bbox: [-121.39, 42.72, -121.34, 42.77]

  # S2 L2A surface reflectance is offset by 1000 (baseline processing >= 4.00).
  # In a normalized difference, scale cancels but offset does not — subtract
  # the offset from each band before differencing. The offset is passed as a
  # scalar param to raster_calc so it can be referenced by name.
  reflectance_offset: 1000

  # Index formula on S2 L2A asset names:
  #   B04 = Red (~665 nm)   B08 = NIR (~833 nm)
  # NDVI = (NIR - Red) / (NIR + Red).
  index:
    slug: ndvi
    range: [-0.2, 0.9]
    palette: rdylgn      # red-yellow-green: bare → vegetated

  # SCL gate (same as landtrendr-s2-bootleg-fire):
  #   0 nodata | 1 saturated | 3 cloud-shadow | 8 cloud-medium |
  #   9 cloud-high | 10 cirrus
  # raster_calc has no bitwise ops; sum-of-bool comparisons stand in for OR.
  bad_scl_classes: [0, 1, 3, 8, 9, 10]

  # Scene budget, PER YEAR (imagery_composite 3.0.0), kept small for fast
  # cold-cache iteration. Was 30 across the whole 2020-2025 range, which the
  # stratified search spread to ~5/year; 5 states that directly and no longer
  # moves when the date_range widens. group_by:year still gives each year its
  # own search rather than letting recent years hoard the cloud-cover sort.
  scene_limit: 5

inputs:
  date_range:
    start: "2020-06-01"            # Jun-Sep growing-season window each year
    end:   "2025-09-30"

layers:

  # ── SOURCE ────────────────────────────────────────────────────────────────
  # `group_by: year` triggers source-resolver's stacSearchByYear path:
  # per-year sub-queries with the Jun-Sep window preserved across years.
  # The MGRS tile filter is load-bearing for S2 L2A — without it, the
  # resolver flattens assets to a single `b1` band instead of named
  # B04/B08/SCL expression variables. Bootleg sits inside tile 10TFN.
  source/s2:
    type: raster
    uri: "stac://microsoft-pc/sentinel-2-l2a"
    bands: [B04, B08, SCL]
    params:
      assets: [B04, B08, SCL]
      limit: "${settings.scene_limit}"
      temporal: ["${inputs.date_range.start}", "${inputs.date_range.end}"]
      group_by: year
      sort: "eo:cloud_cover"
      query:
        "s2:mgrs_tile": { eq: "10TFN" }
        "eo:cloud_cover": { lt: 30 }
        "s2:nodata_pixel_percentage": { lt: 10 }

  # ── PER-SCENE NDVI ────────────────────────────────────────────────────────
  # SCL gate first: NaN-out cloud/shadow/cirrus before the ratio so no
  # contamination reaches the annual reducer. `0/0` is the raster_calc idiom
  # for IEEE-754 NaN (no NaN identifier in the expression VM).
  compute/index-per-scene:
    type: raster
    compute:
      op: raster_calc
      inputs:
        data: { layer: source/s2 }
      params:
        expression: "where((SCL==0)+(SCL==1)+(SCL==3)+(SCL==8)+(SCL==9)+(SCL==10) > 0, 0/0, ((B08 - reflectance_offset) - (B04 - reflectance_offset)) / ((B08 - reflectance_offset) + (B04 - reflectance_offset)))"
        reflectance_offset: "${settings.reflectance_offset}"
        output_band: ndvi

  # ── ANNUAL MEDIAN COMPOSITE ───────────────────────────────────────────────
  # imagery_composite groups date-keyed bands by their first 4 chars (year)
  # and reduces each group with NaN-aware median. Output keys: 2020_ndvi,
  # 2021_ndvi, ..., 2025_ndvi — exactly the date-keyed stack shape that
  # folia-view's scrubber path expects.
  compute/index-annual:
    type: raster
    compute:
      op: imagery_composite
      inputs:
        data: { layer: compute/index-per-scene }
      params:
        method: median
        group_by: year
    style:
      palette: rdylgn
      clim: [-0.2, 0.9]

views:
  - name: Annual NDVI scrubbable
    layers: [compute/index-annual]

# The runner inspects this block. `time-scrub-years` tells the runner to mount
# its built-in year slider over the named layer's date-keyed bands (it reads
# `folia-raster-bands` from the view and calls `setActiveBand` on swap).
dashboard:
  patterns:
    - slug: time-scrub-years
      slot: temporal
      layer: compute/index-annual

license:
  recipe: Apache-2.0
  output_dataset: CC-BY-4.0
  upstream_data:
    - { source: sentinel-2-l2a, license: copernicus-open }

# Op implementations:
#   packages/compute/src/ops/raster-calc.ts        (expression VM)
#   packages/compute/src/ops/imagery-composite.ts  (annual reducer)
#   packages/compute/src/io/source-resolver.ts     (stacSearchByYear)
