name: eudr-active-farming
version: "0.1"
status: draft                          # op composition grounded in documented signatures;
                                       # NOT yet run through the engine. See PROVENANCE.
category: change-detection
themes: [agriculture, forest]
family: crop-phenology-monitoring
cites_papers:
  - boryan-2011                        # cropland mapping + NDVI seasonality gate
  - kerner-2024                        # ML-augmented crop status & change signals
description: >
  Per-parcel EUDR due-diligence signal: is each field actively farmed (seasonal
  NDVI cycle present) or recently cleared / deforested (anomalous change vs a
  baseline year)? The recipe runs a two-stage gate over Fields of the World
  parcel boundaries. Stage 1 is an active-cropland gate: harmonic_fit amplitude
  of the NDVI time series distinguishes seasonally cropped land (strong
  annual swing) from cleared or idle parcels (flat/anomalous). Stage 2 is a
  change signal: change_detection against a user-defined baseline window flags
  parcels where vegetation has declined since the reference year, consistent
  with recent clearing. Per-field zonal_stats collapses both signals to one row
  per FTW parcel — the table the compliance workflow consumes.

  The approach is grounded in Boryan et al. (2011) on NDVI-seasonality-based
  cropland mapping and Kerner et al. (2024) on learned crop-status signals; the
  implementation uses only declarative Folia ops so every step is inspectable
  and re-runnable from the same inputs.

# ─────────────────────────────────────────────────────────────────────────────
# PROVENANCE (why this recipe exists)
#   Authored 2026-07-14 as part of the crop white-space recipe suite derived
#   from commons/graph/WHITESPACE.md. Implements the EUDR due-diligence pattern:
#   active-farming gate (NDVI seasonality via harmonic_fit, Boryan 2011) +
#   change signal (change_detection vs baseline, Kerner 2024) aggregated per
#   FTW field parcel (zonal_stats). Companion to crop-monitoring (which covers
#   the general vigor/phenology product without the compliance framing).
#
# STATUS: draft. Op signatures follow
#   folia/domains/.../{raster_calc,harmonic_fit,change_detection,zonal_stats}.yaml
#   but the recipe has NOT been run through the engine. Validate with
#   `folia run` against the example AOI, reconcile any param/shape mismatches,
#   add a validations/ block, then drop status:draft.
# ─────────────────────────────────────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# PARAMETERIZATION
#   settings.index:        the greenness index (NDVI). Swap expression for EVI
#                          or NDMI without touching the graph.
#   settings.n_harmonics / fit_trend / period:  the seasonal model.
#                          2 harmonics captures main season + secondary crop.
#                          amplitude_threshold separates active from idle.
#   settings.baseline_*:   the reference window for change_detection. Set to
#                          the year prior to the audit window.
#   settings.field_stats:  per-field aggregate statistics written to the output
#                          compliance table.
#   inputs.fields:         the FTW parcel layer — the zones amplitude + change
#                          are aggregated into. Required for the compliance table.
#   inputs.aoi / date_range / collection:  the bound instance.
# ─────────────────────────────────────────────────────────────────────────────

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

  n_harmonics: 2                       # annual + semi-annual; 2 captures double-cropping
  fit_trend: true                      # detect multi-year greening/browning per parcel
  period: 365.25                       # day-of-year time axis

  amplitude_threshold: 0.15            # min NDVI amplitude to classify a parcel active-cropped;
                                       # Boryan 2011 §3.2 recommends ~0.10–0.20 depending on crop type

  baseline_start: "2020-01-01"         # reference window for change_detection
  baseline_end:   "2021-12-31"

  field_stats: [mean, std, min, max]   # per-field aggregation of amplitude + change bands

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

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

  fields:
    # Fields of the World global parcel layer. Each polygon carries a per-field
    # confidence attribute (ftw:confidence) that can be used to filter low-quality
    # delineations before running zonal_stats. Required — without it the recipe
    # delivers rasters only, not the per-parcel compliance table.
    name: Fields of the World — San Joaquin Valley
    uri: "@ftw/global-field-boundaries"
    description: >
      FTW v1 global field boundary polygons (CC-BY-4.0). Filtered to
      inputs.aoi.bbox at query time. Use ftw:confidence >= 0.7 to gate
      low-confidence parcel delineations before aggregation.

  date_range:
    start: "2023-01-01"                # audit window — >=2 growing seasons for stable harmonic fit
    end:   "2026-06-12"

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

layers:

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

  source/s2:
    type: raster_timeseries
    uri: "@esa/sentinel-2/l2a"
    description: >
      Every clear S2 L2A pass over the AOI within the audit window, one item
      per acquisition date, gated at the AOI-polygon level from its SCL window
      before the index bands are read. Baseline window items are fetched
      separately by compute/change via source/s2_baseline.
    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}"

  source/s2_baseline:
    type: raster_timeseries
    uri: "@esa/sentinel-2/l2a"
    description: >
      Reference-period S2 scenes for the change_detection baseline.
      Kept as a separate source layer so the baseline window is explicit and
      independently auditable — a compliance requirement.
    params:
      aoi: { bbox: "${inputs.aoi.bbox}" }
      datetime: "${settings.baseline_start}/${settings.baseline_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 temporal mode over B04/B08). Produces a
      (t, y, x) stack for the audit window — the scrubable greenness layer and
      the input to the harmonic seasonality fit.
    compute:
      op: raster_calc
      inputs:
        data: { layer: source/s2 }
      params:
        expression: "${settings.index.expression}"
        reflectance_offset: "${settings.reflectance_offset}"
        output_band: ndvi

  compute/ndvi_baseline:
    type: raster_timeseries
    description: >
      NDVI stack for the reference period, identically computed. Used as the
      "before" state in the change_detection layer so the comparison is
      index-to-index rather than raw-DN-to-index (eliminates sensor drift bias).
    compute:
      op: raster_calc
      inputs:
        data: { layer: source/s2_baseline }
      params:
        expression: "${settings.index.expression}"
        reflectance_offset: "${settings.reflectance_offset}"
        output_band: ndvi

  compute/seasonality:
    type: raster
    description: >
      Per-pixel seasonal decomposition of the audit-window NDVI stack.
      harmonic_fit returns amplitude (annual greenness swing — the active-crop
      signal: high amplitude = seasonal crop, near-zero = cleared/idle/fallow),
      phase (day-of-year of peak greenness = crop calendar), and trend
      (multi-year greening/browning, a secondary deforestation indicator).
      Grounded in Boryan et al. (2011): NDVI amplitude threshold separates
      cropland from non-cropland at sub-field resolution.
    compute:
      op: harmonic_fit
      inputs:
        collection: { layer: compute/ndvi }
      params:
        n_harmonics: "${settings.n_harmonics}"
        fit_trend: "${settings.fit_trend}"
        period: "${settings.period}"

  compute/change:
    type: raster
    description: >
      Pixel-level change magnitude between the baseline NDVI composite and the
      audit-window NDVI composite. change_detection returns a signed change band
      (negative = vegetation loss, consistent with clearing/deforestation) and a
      significance mask. This is the second gate in the EUDR signal: a parcel
      with high NDVI amplitude in the audit window but also a large negative
      change vs baseline warrants additional scrutiny (possible re-planting
      after clearing). Implements Kerner et al. (2024) §2.3 change-signal layer.
    compute:
      op: change_detection
      inputs:
        before: { layer: compute/ndvi_baseline }
        after:  { layer: compute/ndvi }
      params:
        method: mean_composite           # reduce each window to a mean before differencing
        output_bands: [change_magnitude, significance]

  compute/parcel_status:
    type: table
    description: >
      The per-parcel EUDR compliance table: one row per FTW field polygon with
      mean/std/min/max of amplitude, phase, trend, and change_magnitude inside
      the parcel boundary. zonal_stats over @ftw/global-field-boundaries.
      Downstream a rule: amplitude_mean >= amplitude_threshold AND
      change_magnitude_mean > -0.10 classifies a parcel as "active-cropped /
      low-clearing-risk"; otherwise it enters the enhanced-scrutiny queue.
      # NOTE: the threshold reclassify step (amplitude -> active/idle label) is
      # not yet a named op in the current op registry. Model it as a
      # post-processing filter on the output table, or add a reclassify layer
      # over the amplitude band when that op is wired to tabular inputs.
    compute:
      op: zonal_stats
      inputs:
        raster: { layer: compute/seasonality }   # amplitude + phase + trend bands
        zones:  { feature: "${inputs.fields.uri}" }
      params:
        stats: "${settings.field_stats}"
        extra_rasters:
          - { layer: compute/change, bands: [change_magnitude] }
        zone_id_field: ftw:field_id

outputs:
  primary:
    - compute/parcel_status            # geojson / parquet — per-field compliance table (THE product)
  show_your_work:
    - compute/seasonality              # cog (amplitude, phase, trend) — the seasonal signal
    - compute/change                   # cog (change_magnitude, significance) — the change signal
    - compute/ndvi                     # zarr (t,y,x) — the audit-window NDVI stack
  provenance:
    - source_scene_ids                 # STAC item id per timestamp (audit + baseline windows)

# ── DASHBOARD ────────────────────────────────────────────────────────────────
dashboard:
  patterns:
    - slug: crossing-date-map          # map of when/where change crossed the threshold
      slot: temporal
      layer: compute/change
    - slug: zonal-rank                 # parcels ranked by amplitude or change_magnitude
      slot: aggregation
      layer: compute/parcel_status

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 (the general vigor/phenology product, same family);
#   integrated-disturbance (forest-change framing without the per-parcel gate);
#   landtrendr-change (longer-horizon deforestation signal for the same FTW zones).
# EUDR framing: EC Regulation 2023/1115; due-diligence obligation triggers on
#   "relevant commodity" supply chains — this recipe is the farmgate evidence layer.
