name: crop-type-map
version: "0.1"
status: draft                          # unsupervised chain VALIDATED on synthetic
                                       # phenologies via examples/reference_impl.py;
                                       # engine-wiring + a --real run pending. See PROVENANCE.
category: land-cover-classification
themes: [agriculture, imagery]
family: crop-phenology-monitoring
cites_papers:
  - boryan-2011                        # CDL program — the crop-type target we cluster toward
  - zhu-2014                           # harmonic (Fourier) time-series model of reflectance
description: >
  Label-free (unsupervised) per-field crop-type mapping from a multi-index
  Sentinel-2 phenology SIGNATURE. For an AOI covered by a Fields of the World
  boundary set, compute an NDVI + EVI + NDRE time series, fit harmonic curves to
  each index to extract its amplitude (how much the crop greens/matures) and phase
  (WHEN it peaks — the crop calendar), stack those (index × harmonic) features into
  a per-pixel signature, and cluster the signature with k-means into crop GROUPS.
  Per-field majority vote over the cluster raster gives one crop-group label per
  field. The per-field group table is the primary product; the multi-index stack
  and harmonic amplitude/phase rasters are the inspectable "show your work" layers.

  This is the honest FIRST version: it needs NO training labels — it discovers
  crop groups from the phenology structure itself. The clusters are anonymous
  ("group 0/1/2…") until named; where USDA CDL (boryan-2011) covers the AOI, the
  optional validation step names each cluster by its majority CDL class and reports
  cluster↔CDL agreement. The SUPERVISED path (a CDL-trained classifier that emits
  named crop classes directly, no post-hoc naming) is the v2 — see the NOTE on
  compute/crop_cluster.

  This extends crop-monitoring (single-index NDVI vigor/phenology) with two
  additions: a multi-index signature (NDRE separates canopy N / structure that
  NDVI alone misses) and an unsupervised clustering head that turns the signature
  into a discrete crop-group layer.

# ─────────────────────────────────────────────────────────────────────────────
# PROVENANCE (why this recipe exists)
#   Authored 2026-07-14 from the crop white-space suite. CDL-style crop-type
#   mapping (Boryan et al. 2011) is the most-requested agricultural classification
#   workflow and was absent from the Folia recipe library. The honest first method
#   this recipe implements is UNSUPERVISED: (1) a time-series multi-index phenology
#   signature per pixel/field (NDVI + EVI + NDRE harmonics), then (2) k-means
#   clustering of that signature into crop GROUPS — no training labels required.
#   Folia already had the primitives for step 1 (radiometric_indices, harmonic_fit,
#   zonal_stats); step 2 is k-means (sklearn) over the harmonic feature stack, with
#   an optional CDL-agreement check to NAME the clusters where CDL covers the AOI.
#   cites_papers: boryan-2011 (the CDL crop-type target the clusters map toward),
#   zhu-2014 (the harmonic time-series model the signature is built from).
#
# STATUS: draft. The source->indices->phenology->cluster->field chain composes each
#   op's documented signature and the k-means head; the reference impl (examples/
#   reference_impl.py) validates it end-to-end on SYNTHETIC data by injecting two
#   distinct phenologies and asserting two clusters are recovered. What remains:
#   engine-wiring of the k-means head (currently in the reference impl, not yet a
#   registry op), a --real run over the Fresno AOI, and a validations/ block. The
#   supervised v2 (CDL-trained classifier emitting named classes) is deferred.
# ─────────────────────────────────────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# PARAMETERIZATION
#   settings.indices:      the multi-index set — NDVI (greenness), EVI (structural
#                          LAI), NDRE (red-edge chlorophyll / canopy N). Together
#                          they form the phenology signature that separates crop
#                          types; NDRE (from B05 red-edge) is the discriminator NDVI
#                          alone misses. NDMI (SWIR water) is available as a 4th index.
#   settings.n_harmonics / fit_trend / period:  the harmonic model. 2 harmonics
#                          captures main season + secondary (double-cropping); trend
#                          is off so clustering sees a stable intra-season signature.
#   settings.n_clusters:   how many crop GROUPS k-means splits the signature into.
#                          Set to the expected count of distinct crop calendars in
#                          the AOI (San Joaquin: corn/cotton/almond/tomato/fallow ≈ 6).
#   settings.validate_with_cdl:  optional — when USDA CDL covers the AOI, name each
#                          cluster by its majority CDL class and report agreement.
#   inputs.fields:         Fields of the World global boundary set — the per-field
#                          zones for the majority-vote crop-group label.
#   inputs.aoi / date_range / collection:  the bound instance (San Joaquin Valley).
# ─────────────────────────────────────────────────────────────────────────────

settings:
  indices: [NDVI, EVI, NDRE]           # multi-index phenology signature (NDRE from B05
                                       # red-edge is the key crop-type discriminator).
                                       # Add NDMI (needs B11 SWIR) for a 4th water axis.

  n_harmonics: 2                       # annual + semi-annual (double-cropping)
  fit_trend: false                     # trend off — clustering sees a stable
                                       # intra-season signature, not inter-year drift
  period: 365.25                       # day-of-year time axis

  n_clusters: 6                        # crop groups k-means splits the signature into
                                       # (San Joaquin: corn/cotton/almond/tomato/…/fallow)
  cluster_seed: 42                     # deterministic k-means init (reproducibility)

  validate_with_cdl: false             # optional: name clusters by majority USDA CDL
                                       # class + report cluster↔CDL agreement (boryan-2011)

  field_stats: [majority, mean, std]   # per-field aggregation over the cluster raster;
                                       # majority = the field-level crop-group vote

  reflectance_offset: 1000             # >=04.00 BOA offset; scale cancels in ND ratios
  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 field-boundary dataset. Each feature carries a
    # `confidence` attribute (model certainty of the boundary); filter low-confidence
    # features before aggregation to avoid class bleed across field edges.
    name: Fields of the World — San Joaquin Valley
    uri: "@ftw/global-field-boundaries"
    description: >
      Per-field polygons from the Fields of the World dataset. The confidence
      attribute is preserved through zonal_stats for downstream filtering. Bind
      a sub-national cadastral layer here when parcel-level accuracy is needed.

  date_range:
    start: "2023-01-01"                # >=2 full growing seasons for a stable
    end:   "2026-06-12"                # harmonic fit per index

  collection:
    uri: "@esa/sentinel-2/l2a"
    assets: [B02, B04, B05, B08, SCL]
    # B02=blue (EVI), B04=red (NDVI/EVI), B05=red-edge (NDRE — the discriminator),
    # B08=NIR-10m (NDVI/EVI/NDRE), SCL=cloud. Add B11 (SWIR) for the optional NDMI.
    # NOTE: NDRE needs B05 red-edge (native 20 m) resampled onto the 10 m grid with
    # B04/B08 — the source grid normalization handles this before radiometric_indices.

layers:

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

  source/s2:
    type: raster_timeseries
    uri: "@esa/sentinel-2/l2a"
    description: >
      Every clear S2 L2A pass over the AOI for the full date range, gated at the
      AOI-polygon level from its SCL window before any band is read. Carries the
      bands needed for the multi-index signature (NDVI/EVI/NDRE from B02/B04/B05/B08)
      in one multi-band timeseries to avoid redundant fetches. B05 red-edge (20 m)
      is resampled onto the 10 m grid here so all three indices share a pixel grid.
    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/indices:
    type: raster_timeseries
    description: >
      Per-scene multi-index stack (NDVI, EVI, NDRE) computed from the S2 multi-band
      timeseries. radiometric_indices applies each index formula in a single pass
      over the pixel grid, returning a (t, index, y, x) tensor. Together these three
      indices form the phenology signature that separates crop types: NDVI tracks
      canopy greenness, EVI structural LAI, NDRE (from B05 red-edge) chlorophyll /
      canopy nitrogen — the axis NDVI alone cannot see. The SCL-gated source ensures
      only clear observations enter the harmonic fit.
    compute:
      op: radiometric_indices
      inputs:
        image: { layer: source/s2 }
      params:
        indices: "${settings.indices}"
        reflectance_offset: "${settings.reflectance_offset}"

  compute/phenology:
    type: raster
    description: >
      Per-pixel harmonic decomposition of each index timeseries. harmonic_fit returns
      amplitude and phase for every (pixel, index) pair — the seasonal signature of
      what is growing at each location and when it peaks. Amplitude encodes how much
      the crop greens/matures; phase encodes crop calendar (spring wheat peaks earlier
      than summer corn). With n_harmonics=2 the model captures both a main growing
      season and a secondary flush (double-cropping common in the San Joaquin Valley).
      fit_trend=false removes inter-year drift so clustering sees a stable intra-season
      signature. The stacked (index × {amplitude, phase}) bands are the feature vector
      the clustering head consumes — 3 indices × (amp, phase) = a 6-D per-pixel signature.
    compute:
      op: harmonic_fit
      inputs:
        collection: { layer: compute/indices }
      params:
        n_harmonics: "${settings.n_harmonics}"
        fit_trend: "${settings.fit_trend}"
        period: "${settings.period}"

  compute/crop_cluster:
    type: raster
    description: >
      Per-pixel crop-GROUP raster from UNSUPERVISED k-means over the multi-index
      phenology signature. Each pixel's (index × {amplitude, phase}) feature vector
      is z-scored and clustered into settings.n_clusters groups; pixels with the same
      seasonal fingerprint (same greenness swing, same peak timing across NDVI/EVI/
      NDRE) fall in the same group. This needs NO training labels — the crop groups
      emerge from the phenology structure. Clusters are anonymous integers (0..k-1)
      until named.
      NOTE — clustering head: this step is the sklearn KMeans in examples/
      reference_impl.py, not yet a registry op (that engine-wiring is the remaining
      draft work). When settings.validate_with_cdl and USDA CDL covers the AOI, each
      cluster is named by its majority CDL class (boryan-2011) and cluster↔CDL
      agreement is reported. The SUPERVISED v2 replaces this head with a CDL-trained
      classifier that emits named crop classes directly.
    compute:
      op: kmeans_cluster                 # sklearn KMeans over the harmonic feature stack
      inputs:
        data: { layer: compute/phenology }
      params:
        n_clusters: "${settings.n_clusters}"
        seed: "${settings.cluster_seed}"
        standardize: true                # z-score features before clustering
        output_band: crop_group

  compute/field_group:
    type: table
    description: >
      The crop-type-map product: one row per field polygon from the Fields of the
      World boundary set, with the majority crop-GROUP vote inside the field (plus
      mean/std of the group id as a mixed-field signal). zonal_stats aggregates the
      per-pixel crop_group raster over each @ftw field, using majority to select the
      dominant group within the field. The per-field confidence attribute from @ftw
      is preserved for downstream filtering of low-confidence boundary polygons. Sort
      or filter this table to surface group distribution across the AOI, flag
      mixed-group fields, or (once clusters are named against CDL) join to crop yield
      or irrigation records.
    compute:
      op: zonal_stats
      inputs:
        raster: { layer: compute/crop_cluster }
        zones:  { feature: "${inputs.fields.uri}" }
      params:
        stats: "${settings.field_stats}"
        preserve_attributes: [confidence]  # @ftw field-boundary confidence score

outputs:
  primary:
    - compute/field_group              # geojson — per-field crop-group table (THE product)
  show_your_work:
    - compute/indices                  # zarr (t, index, y, x) — the multi-index stack
    - compute/phenology                # cog (amplitude×index, phase×index) — the fitted signature
    - compute/crop_cluster             # cog — per-pixel crop-group raster behind the aggregation
  provenance:
    - source_scene_ids                 # STAC item id per acquisition timestamp

# ── DASHBOARD ───────────────────────────────────────────────────────────────
dashboard:
  patterns:
    - slug: choropleth-ranked          # fields shaded by majority crop group; categorical
      slot: recipe-manipulation
      layer: compute/field_group
    - slug: legend-as-histogram        # crop-group distribution across the AOI
      slot: recipe-manipulation
      layer: compute/field_group
    - slug: compare-swipe              # swipe the crop-group raster vs a raw index / CDL
      slot: comparison
      layer: compute/crop_cluster

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 }
    # CDL is optional (validation only): USDA CropScape / Cropland Data Layer,
    # public domain — used to NAME clusters, never as a training input in v1.

# Related: crop-monitoring (the single-index vigor/phenology ancestor this recipe
#   extends with a multi-index signature + an unsupervised clustering head);
#   index-timeseries (the source->index skeleton).
# White-space provenance: CDL-style crop mapping (boryan-2011) is the crop-type
#   target the unsupervised clusters map toward; harmonic signature per zhu-2014.
# Method note: v1 is LABEL-FREE (k-means over the phenology signature) — see the
#   compute/crop_cluster NOTE. The supervised CDL-trained classifier is the v2.
