name: crop-stress-multiindex
version: "0.1"
status: draft                          # compute graph VALIDATED via
                                       # examples/reference_impl.py (synthetic +
                                       # --real); engine-wiring pending.
category: time-series-trends
themes: [agriculture, imagery, vegetation]
family: crop-phenology-monitoring
cites_papers:
  - "huete-2002"                       # EVI — Enhanced Vegetation Index (Huete et al. 2002,
                                       #   Remote Sensing of Environment 83:195-213)
  - "gitelson-2003"                    # Red-edge chlorophyll index (Gitelson et al. 2003,
                                       #   Journal of Plant Physiology 160:271-282)
  - "gao-1996"                         # NDMI / land surface water index (Gao 1996,
                                       #   Remote Sensing of Environment 58:257-266)
description: >
  Per-field crop stress diagnosis from a Sentinel-2 multi-index time series: EVI
  (canopy structure, saturation-resistant), NDRE (red-edge chlorophyll and
  nitrogen status), and NDMI (SWIR canopy moisture / water stress). Together the
  three indices separate stress TYPE that NDVI alone cannot resolve — a field
  with low NDRE is nitrogen-deficient; low NDMI flags water stress; both falling
  simultaneously indicates compound stress.

  For each clear S2 pass the recipe computes EVI, NDRE, and NDMI at field
  resolution, derives seasonal means per index, and flags per-field stress using
  a combined stress index (CSI = mean of z-scored deficits across the three
  indices). The per-field table is the deliverable; the index stacks and seasonal
  mean rasters are the "show your work" layers.

  The recipe extends crop-monitoring (NDVI harmonic phenology) by adding two
  diagnostic axes — chlorophyll/N (NDRE) and water (NDMI) — alongside a
  saturation-resistant canopy index (EVI). Together they allow a field scout to
  ask: "is this field stressed, and WHY?"

  Index formulas (Sentinel-2 band names, baseline >= 04.00 BOA reflectance):
    EVI  = 2.5 * (B08 - B04) / (B08 + 6*B04 - 7.5*B02 + 1)   [Huete 2002]
    NDRE = (B08 - B05) / (B08 + B05)   red-edge NIR vs red-edge 705 nm [Gitelson 2003]
    NDMI = (B08 - B11) / (B08 + B11)   NIR vs SWIR-1 1610 nm [Gao 1996]

# ─────────────────────────────────────────────────────────────────────────────
# PROVENANCE
#   Authored 2026-07-16 to extend crop-phenology-monitoring with stress-TYPE
#   diagnosis. NDVI phenology (crop-monitoring) tells you WHEN a crop peaks and
#   how vigorous it is; this recipe tells you WHY vigor is suppressed: N
#   deficiency (NDRE < seasonal baseline) vs water stress (NDMI < seasonal
#   baseline) vs canopy structure loss (EVI < baseline). The combined stress
#   index (CSI) is a per-field triage metric; the three component z-scores are
#   the inspectable decomposition.
#
#   Papers:
#     Huete et al. (2002) "Overview of the radiometric and biophysical
#       performance of the MODIS vegetation index algorithm" — EVI
#     Gitelson et al. (2003) "Relationships between leaf chlorophyll content
#       and spectral reflectance" — NDRE / red-edge chlorophyll
#     Gao (1996) "NDWI: A normalized difference water index for remote sensing
#       of vegetation liquid water from space" — NDMI (land surface water index)
#
# STATUS: draft. Op composition follows documented op signatures. Validate with
#   `python3 examples/reference_impl.py` (synthetic) or `--real` (MPC). Then
#   run `folia run` on the example AOI, reconcile any param/shape mismatches,
#   add a validations/ block, and drop status:draft.
# ─────────────────────────────────────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# PARAMETERIZATION
#   settings.reflectance_offset/scale: S2 baseline >= 04.00 BOA DN correction.
#   settings.bad_scl_classes / max_bad_fraction: per-scene AOI cloud gate.
#   settings.stress_z_threshold: z-score below which a field is flagged stressed
#     (negative = below AOI mean; -1.0 = 1 sigma below mean).
#   settings.field_stats: per-field aggregation over each seasonal-mean raster.
#   inputs.fields: the zone layer — default = Fields of the World (@ftw/global-
#     field-boundaries). Swap for any parcel set without touching the graph.
#   inputs.aoi / date_range / collection: the bound instance.
# ─────────────────────────────────────────────────────────────────────────────

settings:
  # S2 L2A baseline >= 04.00 BOA reflectance conversion
  reflectance_offset: 1000
  reflectance_scale: 10000

  # AOI-level scene gate (drop scene if too cloudy over the AOI)
  max_bad_fraction: 0.30
  bad_scl_classes: [0, 1, 3, 8, 9, 10]     # nodata, defective, cloud-shadow,
                                             # cloud-med, cloud-high, thin-cirrus

  # Composite grid resolution (B05, B11 are native 20 m; resample B08 up)
  composite_resolution_m: 20

  # Per-field aggregation statistics for each seasonal-mean index raster
  field_stats: [mean, std, min, max]

  # Combined Stress Index: z-score threshold below which a field is flagged.
  # Negative = below AOI mean. -0.5 = mild stress flag; -1.0 = severe.
  stress_z_threshold: -0.5

  # EVI formula coefficients (Huete 2002)
  evi_L: 1.0
  evi_C1: 6.0
  evi_C2: 7.5
  evi_G: 2.5

inputs:
  aoi:
    name: San Joaquin Valley cropland block — Fresno County
    bbox: [-120.05, 36.55, -119.85, 36.72]
    crs_target: EPSG:32611                 # UTM 11N
    mgrs_tile: "11SKB"

  fields:
    # Zone layer — Fields of the World (global, CC-BY, per-field confidence,
    # fiboa schema). Any polygon set works; FTW is the canonical global default.
    name: Fields of the World — field boundaries (AOI subset)
    uri: "@ftw/global-field-boundaries"
    confidence_attr: confidence
    # Runnable stand-in for local/offline validation:
    geometry: registry/recipes/crop-monitoring/examples/fresno-fields.geojson

  date_range:
    start: "2023-01-01"                    # >= 1 full growing season
    end:   "2026-06-12"

  collection:
    uri: "@esa/sentinel-2/l2a"
    # B02 blue (EVI denominator), B04 red, B05 red-edge 705 nm (NDRE),
    # B08 NIR broad, B11 SWIR-1 1610 nm (NDMI), SCL cloud mask
    assets: [B02, B04, B05, B08, B11, SCL]

layers:

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

  source/s2:
    type: raster_timeseries
    uri: "@esa/sentinel-2/l2a"
    description: >
      Every clear S2 L2A pass over the AOI, one item per acquisition date.
      SCL is read at AOI window level to gate scenes before the index bands
      (B02, B04, B05, B08, B11) are fetched, avoiding unnecessary data transfer
      for cloudy passes.
    params:
      aoi: { bbox: "${inputs.aoi.bbox}" }
      datetime: "${inputs.date_range.start}/${inputs.date_range.end}"
      assets: "${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: per-scene reflectance ──────────────────────────────────────────

  compute/reflectance:
    type: raster_timeseries
    description: >
      Per-scene BOA reflectance stack (B02, B04, B05, B08, B11) at 20 m.
      10 m bands (B02, B04, B08) are 2x2 block-averaged onto the 20 m grid
      of B05 and B11. Reflectance = (DN - offset) / scale. SCL-masked pixels
      set to NaN.
    compute:
      op: raster_calc
      inputs:
        data: { layer: source/s2 }
      params:
        reflectance_offset: "${settings.reflectance_offset}"
        reflectance_scale: "${settings.reflectance_scale}"
        target_resolution_m: "${settings.composite_resolution_m}"
        scl_mask:
          asset: SCL
          invalid_classes: "${settings.bad_scl_classes}"

  # ── COMPUTE: per-scene indices ───────────────────────────────────────────────

  compute/evi:
    type: raster_timeseries
    description: >
      EVI per scene — Enhanced Vegetation Index (Huete et al. 2002). Reduces
      canopy background noise and atmospheric residuals vs NDVI; less saturated
      at high biomass. Formula: G * (B08 - B04) / (B08 + C1*B04 - C2*B02 + L).
      Clipped to [-1, 1].
    compute:
      op: raster_calc
      inputs:
        data: { layer: compute/reflectance }
      params:
        expression: >
          2.5 * (B08 - B04) / (B08 + 6.0 * B04 - 7.5 * B02 + 1.0)
        output_band: evi
        clip: [-1, 1]

  compute/ndre:
    type: raster_timeseries
    description: >
      NDRE per scene — Normalized Difference Red-Edge Index (Gitelson et al.
      2003). Red-edge band (B05, 705 nm) is more sensitive to chlorophyll
      concentration and nitrogen status than B04 (red). NDRE drops earlier than
      NDVI under N-deficiency, making it the primary N-stress signal. Formula:
      (B08 - B05) / (B08 + B05). Range [-1, 1].
    compute:
      op: raster_calc
      inputs:
        data: { layer: compute/reflectance }
      params:
        expression: "(B08 - B05) / (B08 + B05)"
        output_band: ndre
        clip: [-1, 1]

  compute/ndmi:
    type: raster_timeseries
    description: >
      NDMI per scene — Normalized Difference Moisture Index (Gao 1996). SWIR-1
      (B11, 1610 nm) is strongly absorbed by leaf water content; NIR (B08) is
      not. NDMI tracks canopy water content and is the primary water-stress
      signal — it drops 3-7 days before visible wilting. Formula:
      (B08 - B11) / (B08 + B11). Range [-1, 1].
    compute:
      op: raster_calc
      inputs:
        data: { layer: compute/reflectance }
      params:
        expression: "(B08 - B11) / (B08 + B11)"
        output_band: ndmi
        clip: [-1, 1]

  # ── COMPUTE: seasonal means (temporal_reduce) ────────────────────────────────

  compute/evi-mean:
    type: raster
    description: >
      Growing-season mean EVI (per-pixel NaN-aware median over all clear passes).
      This is the canopy-structure reference map — fields with low EVI are either
      fallow or have suppressed canopy development.
    compute:
      op: temporal_reduce
      inputs:
        stack: { layer: compute/evi }
      params:
        reducer: median
        ignore_nan: true

  compute/ndre-mean:
    type: raster
    description: >
      Growing-season mean NDRE. Fields with NDRE below their expected seasonal
      baseline are N-deficient — the primary chlorophyll/nitrogen stress signal.
    compute:
      op: temporal_reduce
      inputs:
        stack: { layer: compute/ndre }
      params:
        reducer: median
        ignore_nan: true

  compute/ndmi-mean:
    type: raster
    description: >
      Growing-season mean NDMI. Fields with NDMI below seasonal baseline are
      experiencing water stress — the primary canopy moisture signal.
    compute:
      op: temporal_reduce
      inputs:
        stack: { layer: compute/ndmi }
      params:
        reducer: median
        ignore_nan: true

  # ── COMPUTE: combined stress index ───────────────────────────────────────────

  compute/stress-index:
    type: raster
    description: >
      Combined Stress Index (CSI) — per-pixel mean of the z-scored deficit
      across EVI, NDRE, and NDMI seasonal means. Negative CSI = stressed
      relative to AOI mean; magnitude indicates severity. Separates canopy
      structure loss (EVI), chlorophyll/N deficit (NDRE), and water stress
      (NDMI) by inspecting which component dominates.

      Formula: CSI = mean( zscore(EVI_mean), zscore(NDRE_mean), zscore(NDMI_mean) )
      where zscore is computed across the AOI (not per-field).
    compute:
      op: raster_calc
      inputs:
        evi_mean:  { layer: compute/evi-mean }
        ndre_mean: { layer: compute/ndre-mean }
        ndmi_mean: { layer: compute/ndmi-mean }
      params:
        expression: >
          (((evi_mean - mean(evi_mean)) / (std(evi_mean) + 1e-6)) +
           ((ndre_mean - mean(ndre_mean)) / (std(ndre_mean) + 1e-6)) +
           ((ndmi_mean - mean(ndmi_mean)) / (std(ndmi_mean) + 1e-6))) / 3.0
        output_band: csi

  # ── COMPUTE: stress flag (threshold) ─────────────────────────────────────────

  compute/stress-flag:
    type: raster
    description: >
      Binary stress flag: 1 where CSI < stress_z_threshold (default -0.5 sigma),
      0 elsewhere. Intended as a field-triage layer, not a ground-truth
      classification.
    compute:
      op: reclassify
      inputs:
        raster: { layer: compute/stress-index }
      params:
        rules:
          - { condition: "value < ${settings.stress_z_threshold}", output: 1 }
          - { condition: default, output: 0 }
        output_band: stress_flag

  # ── COMPUTE: per-field aggregation ──────────────────────────────────────────

  compute/field-stress:
    type: table
    description: >
      The crop stress product: one row per field with the mean/std/min/max of
      EVI, NDRE, NDMI seasonal means and the CSI, plus a stress_flag column
      (1 = field mean CSI below threshold). Sort by csi_mean to triage the
      most-stressed fields first; inspect ndre_mean vs ndmi_mean to determine
      stress type.
    compute:
      op: zonal_stats
      inputs:
        raster:
          layers:
            - { layer: compute/evi-mean,   band: evi_mean }
            - { layer: compute/ndre-mean,  band: ndre_mean }
            - { layer: compute/ndmi-mean,  band: ndmi_mean }
            - { layer: compute/stress-index, band: csi }
            - { layer: compute/stress-flag,  band: stress_flag }
        zones: { feature: "${inputs.fields.geometry}" }
      params:
        stats: "${settings.field_stats}"

outputs:
  primary:
    - compute/field-stress               # GeoJSON — per-field stress triage table (THE product)
  show_your_work:
    - compute/evi-mean                   # COG — seasonal mean EVI (canopy structure)
    - compute/ndre-mean                  # COG — seasonal mean NDRE (chlorophyll/N)
    - compute/ndmi-mean                  # COG — seasonal mean NDMI (moisture)
    - compute/stress-index               # COG — Combined Stress Index (CSI)
    - compute/stress-flag                # COG — binary stress flag
    - compute/evi                        # Zarr (t,y,x) — per-scene EVI stack
    - compute/ndre                       # Zarr (t,y,x) — per-scene NDRE stack
    - compute/ndmi                       # Zarr (t,y,x) — per-scene NDMI stack
  provenance:
    - source_scene_ids                   # STAC item id per kept timestamp
    - scenes_checked
    - scenes_kept

# ── DASHBOARD ───────────────────────────────────────────────────────────────
dashboard:
  patterns:
    - slug: choropleth-ranked            # fields shaded by CSI; worst-stressed darkest
      slot: recipe-manipulation
      layer: compute/field-stress
      style:
        band: csi_mean
        colormap: RdYlGn                 # red = stressed, green = healthy
        label: "Combined Stress Index (mean)"
    - slug: time-scrub-availability      # scrub EVI/NDRE/NDMI over the season
      slot: temporal
      layer: compute/evi                 # switch to ndre/ndmi via layer selector
    - slug: pixel-trajectory-climatology # click a field -> its NDRE/NDMI season
      slot: temporal
      layer: compute/ndre
    - slug: legend-as-histogram          # distribution of CSI across all fields
      slot: recipe-manipulation
      layer: compute/field-stress

license:
  recipe: Apache-2.0
  output_dataset: CC-BY-4.0
  upstream_data:
    - { source: sentinel-2-l2a, license: usgs-public-domain }
    - { source: "@ftw/global-field-boundaries", license: CC-BY-4.0 }

# Related: crop-monitoring (NDVI phenology — the harmonic backbone);
#   spectral-indices (snapshot multi-index without field aggregation).
# Extends the crop-phenology-monitoring family with stress-type diagnosis.
