name: crop-yield-proxy
version: "0.1"
status: draft                          # op composition follows documented signatures;
                                       # recipe NOT run through engine yet. See PROVENANCE.
category: time-series-trends
themes: [agriculture, imagery]
family: crop-phenology-monitoring
cites_papers: ["bolton-2013"]
description: >
  Per-field seasonal greenness integral as a first-order yield proxy, derived from a
  Sentinel-2 NDVI time series over a growing season. For each field boundary in the
  Fields of the World layer, cumulate NDVI across all clear passes (temporal_reduce
  sum) to form the season-integrated greenness, then aggregate that integral to the
  field polygon (zonal_stats mean). The resulting per-field table ranks fields by
  cumulative photosynthetic activity and serves as a lightweight, reproducible yield
  proxy that requires no ground-truth calibration.

  The method follows Bolton & Friedl 2013 (cites_papers: bolton-2013): integrated
  seasonal NDVI correlates with crop yield across major staple crops in CONUS, with
  the temporal sum over the growing season outperforming peak or mean in most settings.
  Swap inputs.date_range to isolate a single growing season, or swap inputs.fields
  for a parcel set, without touching the layer graph.

# ─────────────────────────────────────────────────────────────────────────────
# PROVENANCE (why this recipe exists)
#   Authored 2026-07-14 from the agricultural crop white-space suite. The
#   method is the Bolton & Friedl 2013 integrated-NDVI yield-proxy (bolton-2013):
#   cumulative seasonal greenness (sum of NDVI across clear passes) correlates
#   with staple-crop yield and requires no field-level calibration data.
#
#   This recipe composes three primitive ops (raster_calc -> temporal_reduce ->
#   zonal_stats) and ground-truths the per-field aggregation in the Fields of
#   the World (FTW) global field-boundary layer.  It is the yield-signal
#   counterpart to crop-monitoring (which fits harmonic phenology): crop-monitoring
#   answers "when and how vigorously did this field grow?"; crop-yield-proxy
#   answers "how much total greenness accumulated — a proxy for how much it
#   yielded?"
#
# STATUS: draft. The op signatures used here match each op's documented
#   interface (raster_calc temporal mode, temporal_reduce sum, zonal_stats zones
#   from a URI feature) but the recipe has NOT been validated against the engine.
#   Run `folia run` on the example AOI, reconcile any param/shape mismatches,
#   add a validations/ block with field-level r² against USDA NASS county yields,
#   then drop status: draft.
# ─────────────────────────────────────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# PARAMETERIZATION
#   settings.index:       the greenness index accumulated over the season.
#                         NDVI by default; swap expression + bands for EVI
#                         (which saturates less in dense canopies).
#   settings.season:      the temporal window over which to sum. Defaults to
#                         the full date_range; narrow to a single growing season
#                         (e.g. 2024-03-01..2024-09-30) for a single-year proxy.
#   inputs.fields.uri:    the per-field zones. Bound to @ftw/global-field-boundaries
#                         by default; any polygon set (FSA CLU, LPIS parcels) works.
#   inputs.aoi:           the bound instance — San Joaquin Valley here.
# ─────────────────────────────────────────────────────────────────────────────

settings:
  index:
    slug: ndvi
    expression: "((B08 - reflectance_offset) - (B04 - reflectance_offset)) / ((B08 - reflectance_offset) + (B04 - reflectance_offset))"
    bands: [B04, B08]                  # both native 10m, no grid alignment needed
    range: [-1, 1]
    colormap: greens
    output_band: ndvi

  reduce_op: sum                       # temporal_reduce operator: sum = cumulative greenness
                                       # (Bolton 2013); swap to max for peak-NDVI proxy

  field_stats: [mean, std, p10, p90]   # per-field aggregation of the seasonal integral
                                       # mean = the yield-proxy value; p10/p90 for within-field
                                       # spatial heterogeneity

  reflectance_offset: 1000             # >=04.00 BOA offset (DN - offset); scale cancels in ND
  max_bad_fraction: 0.30               # AOI-level scene cloud gate
  bad_scl_classes: [0, 1, 3, 8, 9, 10]

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

  fields:
    # The per-field zones the seasonal integral is aggregated into.
    # @ftw/global-field-boundaries = Fields of the World (FTW, CC-BY-4.0);
    # each field polygon carries a per-field confidence attribute (0–100) that
    # can be used to threshold low-confidence detections before aggregation.
    name: Fields of the World — San Joaquin Valley
    uri: "@ftw/global-field-boundaries"
    description: >
      Global field-boundary layer (FTW). Filter to AOI at runtime.
      The confidence attribute flags boundary-detection quality; consider
      dropping fields with confidence < 70 for production use.

  date_range:
    start: "2023-01-01"                # cover >=1 full growing season; sum over
    end:   "2026-06-12"               # the full range or narrow via settings.season

  collection:
    uri: "@esa/sentinel-2/l2a"
    assets: [B04, B08, SCL]            # NDVI bands + scene classification layer

layers:

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

  source/s2:
    type: raster_timeseries
    uri: "@esa/sentinel-2/l2a"
    description: >
      Every clear S2 L2A pass over the AOI within date_range, one item per
      acquisition date. The SCL band gates each scene at the AOI-polygon level
      before NDVI bands are read, discarding scenes where more than
      max_bad_fraction of the AOI is cloud/shadow/unclassified.
    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 ──────────────────────────────────────────────────────────────────

  compute/ndvi:
    type: raster_timeseries
    description: >
      NDVI per scene (raster_calc in temporal mode over B04/B08). Produces a
      (t, y, x) greenness stack — one band per clear acquisition date. This is
      the scrubable time series shown in show_your_work and the direct input
      to the seasonal summation. The BOA reflectance offset is subtracted
      before the normalized difference so the formula is numerically stable
      even when one band is near zero.
    compute:
      op: raster_calc
      inputs:
        data: { layer: source/s2 }
      params:
        expression: "${settings.index.expression}"
        reflectance_offset: "${settings.reflectance_offset}"
        output_band: "${settings.index.output_band}"

  compute/seasonal_integral:
    type: raster
    description: >
      Season-integrated greenness: temporal_reduce(sum) collapses the (t, y, x)
      NDVI stack to a single (y, x) raster where each pixel value is the sum of
      all clear-sky NDVI observations over the season. This cumulative greenness
      is the Bolton 2013 yield proxy at pixel resolution — brighter = more total
      photosynthetic activity over the growing period. Note: the integral is
      observation-count-dependent; if clear-pass frequency varies spatially,
      consider normalizing by observation count (not implemented here — add a
      raster_calc divide step if inter-annual comparisons require it).
    compute:
      op: temporal_reduce
      inputs:
        collection: { layer: compute/ndvi }
      params:
        reducer: "${settings.reduce_op}"   # sum = cumulative greenness (Bolton 2013)

  compute/field_yield_proxy:
    type: table
    description: >
      The yield-proxy product: one row per FTW field polygon with the mean (and
      spread statistics) of the seasonal NDVI integral inside the field boundary.
      zonal_stats aggregates compute/seasonal_integral over each @ftw field zone,
      producing field_mean_ndvi_integral as the primary yield-proxy column.
      Fields with a higher mean integral accumulated more total greenness and are
      predicted to have higher yield, consistent with Bolton & Friedl 2013.
      The per-field confidence attribute from @ftw is carried through so downstream
      users can threshold on boundary quality.
    compute:
      op: zonal_stats
      inputs:
        raster: { layer: compute/seasonal_integral }
        zones:  { feature: "${inputs.fields.uri}" }
      params:
        stats: "${settings.field_stats}"   # mean = yield proxy; std/p10/p90 = spatial spread

outputs:
  primary:
    - compute/field_yield_proxy        # geojson — per-field seasonal NDVI integral (THE product)
  show_your_work:
    - compute/ndvi                     # zarr (t,y,x) — per-scene NDVI stack behind the sum
    - compute/seasonal_integral        # cog (y,x) — pixel-level cumulative greenness
  provenance:
    - source_scene_ids                 # STAC item id per timestamp

# ── DASHBOARD ────────────────────────────────────────────────────────────────
dashboard:
  patterns:
    - slug: zonal-rank                 # fields shaded and ranked by mean integral;
      slot: aggregation                # reveals which fields accumulated the most greenness
      layer: compute/field_yield_proxy
    - slug: legend-as-histogram        # distribution of integral values across the AOI;
      slot: recipe-manipulation        # shows whether yield proxy is bimodal (irrigated vs dry)
      layer: compute/field_yield_proxy
    - slug: small-multiples            # side-by-side NDVI for selected dates (early/mid/late
      slot: comparison                 # season); shows how the integral accumulates over time
      layer: compute/ndvi

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 (harmonic phenology — "when/how vigorous?" vs "how much?");
#   index-timeseries (the source->index skeleton this shares source/s2 + compute/ndvi with).
# Paper provenance: Bolton & Friedl 2013 (bolton-2013) — integrated NDVI ~ staple-crop yield.
