# landtrendr-change — per-pixel LandTrendr trajectory segmentation over ANY AOI.
#
# GOVERNED (folia/0.1, ADR-0078) as of v0.2.0: the AOI, period, and algorithm knobs are typed
# `parameters:`, so one recipe serves any bbox/date-range instead of forking a sibling per
# profile. That fork-per-profile workaround is what registry/recipes/supply-shed-change-brazil
# documents ("it exists because the CLI has no parameter-override flag yet"); parameterizing
# here is the first half of removing it. The second half is a CLI/API override surface —
# `bind()` already takes a `supplied` map, the CLI just passes an empty one today.
#
#   folia run registry/recipes/landtrendr-change            # Coast Range checkerboard default
#   folia run registry/recipes/landtrendr-change \          # (once the override flag lands)
#       --param bbox='[-121.55,42.6,-121.2,42.85]' --param temporal='["2013-06-01","2026-09-30"]'
#
# STATUS: runs green on the SDK engine (2 layers, real MPC fetch). imagery_composite resolves
# @usgs/landsat/c2-l2, fetches [N, S2] per year and computes NBR as a normalized difference
# natively (the dataset owns the SR scale/offset), so this uses only ops the engine implements.
# Cloud handling v0: annual median composite. A per-pixel qa_pixel bit gate is a later refinement.
# Forest masking is DEFERRED (see the note below).

spec_version: folia/0.1
name: landtrendr-change
version: "0.2.0"
description: >
  Per-pixel LandTrendr trajectory segmentation over any AOI, defaulting to the Oregon Coast
  Range clearcut checkerboard (Landsat C2 L2 annual NBR). First instance of the detector
  output contract (research/change-detection/emapr-ensemble/CONTRACT.md): the LandTrendr
  layer's `magnitude` and `year` bands map to the contract's `magnitude` / `onset_year`.

parameters:
  bbox:
    type: array
    items: number
    min_items: 4
    max_items: 4
    description: AOI bounding box [w, s, e, n] in EPSG:4326.
    default: [-123.57, 45.72, -123.10, 46.07]   # Coast Range checkerboard (WRS 046/028 interior)

  temporal:
    type: array
    items: string
    min_items: 2
    max_items: 2
    description: >
      Composite window [start, end] as ISO dates. A month-day window repeating each year
      (e.g. Jun 1 - Sep 30) is what `group_by: year` stratifies into annual composites.
      MORE YEARS IS BETTER: the model-selection F-test needs enough observations to fit the
      candidate ladder (see max_segments).
    default: ["2015-06-01", "2026-09-30"]

  collection_uri:
    type: string
    description: Source imagery collection. Landsat is the LandTrendr-native archive (1985-).
    default: "@usgs/landsat/c2-l2"

  limit:
    type: integer
    description: >
      Scene budget PER YEAR BUCKET. This layer composites with `group_by: year`, and each
      year's catalog search asks for `limit` scenes (cheapest-cloud first), so widening the
      range no longer thins the annual composites: a 2015-2026 range and a 2020-2024 range
      both resolve up to `limit` scenes for 2021, and both address the same cached partition
      for that year. Before imagery_composite 3.0.0 the budget was divided across the years,
      which made every annual composite a function of the window shape (one authored 24
      pinned 2 scenes/year over 14 years and 6 over 4 years). The default 6 is the LandTrendr
      per-year density policy (thin stacks admit cloud residue and SLC-off striping into the
      median; this is NOT LandTrendr's `min_observations_needed`, which counts YEARS with a
      valid observation); raise it for denser stacks, at roughly 10-15 MiB of
      extra reads per added scene per year.
    default: 6

  composite_method:
    type: string
    description: Annual reduction across each year's scenes (mean|median|min|max). No medoid yet.
    default: median

  # ── LandTrendr knobs — LT-GEE defaults (lt-rs README, data/gee_truth.json) ──────────────
  # These were 1.25 / (unset -> 0.05) / max_segments 8 before v0.2.0, which is wrong twice over.
  # `best_model_proportion` means OPPOSITE things in the two selection algorithms: the IDL
  # convention this crate implements uses thresh = (2 - bmp) * min_pof with 0.75 as the default,
  # while the Python-era value 1.25 makes that threshold unsatisfiable and collapses the fit.
  # Measured 2026-07-27 over the MTBS Bootleg perimeter, changing ONLY these two values:
  # recall 0.2311 -> 0.5846, kappa 0.1907 -> 0.4927.
  max_segments:
    type: integer
    description: >
      Max trajectory segments. The F-test needs bands > 2*max_segments + 1 for the TOP candidate
      to be defined, so 6 wants >= 14 annual bands; with fewer, the ladder still walks down to a
      defined candidate as long as best_model_proportion is correct. Do not lower this to
      "fit" a short series — measured worse (2,598 vs 3,252 detections at max_segments 3 vs 6).
    default: 6
  spike_threshold:
    type: number
    default: 0.9
  recovery_threshold:
    type: number
    default: 0.25
  best_model_proportion:
    type: number
    description: IDL pick_best_model6 convention — 0.75 is the LT-GEE default. NOT 1.25.
    default: 0.75
  p_value_threshold:
    type: number
    default: 0.05

layers:

  # ── ANNUAL NBR ────────────────────────────────────────────────────────────────
  # imagery_composite does it in one op: resolves the collection, fetches the [N, S2] bands
  # (nir, swir-2) per year, and the band PAIR -> normalized difference = NBR.
  compute/nbr-annual:
    type: raster
    uri: { param: collection_uri }
    params:
      bands: [N, S2]
      bbox: { param: bbox }
      temporal: { param: temporal }
      method: { param: composite_method }
      group_by: year
      limit: { param: limit }
    style:
      palette: rdylgn
      clim: [-0.3, 0.7]

  # ── FOREST MASK — DEFERRED (eMapR "just deforestation") ──────────────────────────
  # Restricting change to forest via Hansen GFC (@umd/hansen/gfc, treecover2000>=10) is
  # DEFERRED: that distribution is a TILE-PATTERN (non-STAC) source, which imagery_composite
  # cannot fetch ("Request URL missing protocol"). Re-add compute/forest-mask + a raster_mask
  # on compute/landtrendr once tile-pattern source materialization lands.

  # ── TRAJECTORY SEGMENTATION ────────────────────────────────────────────────────
  # Output bands (LT-03, 2026-08-19): FOUR, band-major — b1 magnitude (net change),
  # b2 year (segment start), b3 rmse, b4 peak_to_trough (signed disturbance depth).
  # Contract mapping: magnitude -> magnitude, year -> onset_year.
  #   * CLOSED 2026-08-19: "the crate emits ptt as band 3 and the engine slices it off" is no
  #     longer true — all four kernel bands come through, unsliced and in the kernel's order.
  #     `magnitude` (b1) is STILL net change (fitted last - first), NOT the signed
  #     peak-to-trough the burn/disturbance literature thresholds on; that statistic is now
  #     addressable as b4. Measured on THIS recipe over the Bootleg AOI through the release
  #     engine, one run, both bands, -0.20 threshold: b1 recall 0.5292 / precision 0.9631 /
  #     kappa 0.4502 (FAILS the 0.70 recall and 0.50 kappa floors); b4 recall 0.7772 /
  #     precision 0.9474 / kappa 0.6836 (ALL FLOORS PASS). Evidence:
  #     research/change-detection/reproduction-runs/bootleg-mtbs-2026-06-20/scores_engine_lt03.json.
  #     b1 stays the styled band here because it is signed and the palette below is two-sided.
  #   * STILL OPEN: `year` reports the SEGMENT START, one year before the loss is realised: 85.9% within
  #     1 year of LT-rs's own reference distyear, but only 0.4% exact, median delta exactly 1.0.
  #     Re-measured 2026-08-19 on v0.4 (a4b10762) over the Bootleg perimeter: median year 2020
  #     for a 2021 fire, 87.4% within 1 year, 99.5% onset <= 2021 (see `outputs.change`).
  compute/landtrendr:
    type: raster
    compute:
      op: landtrendr
      inputs:
        time_series: { layer: compute/nbr-annual }
      params:
        max_segments: { param: max_segments }
        spike_threshold: { param: spike_threshold }
        recovery_threshold: { param: recovery_threshold }
        best_model_proportion: { param: best_model_proportion }
        p_value_threshold: { param: p_value_threshold }
    # STYLE MOVED TO BAND 4, 2026-08-19 (convergence LT-04, the CNG hero re-mint). LT-03 left
    # `magnitude` styled because the ramp below it was two-sided; the ramp is now one-sided, so
    # the objection is gone and the DISPLAYED band is the one this recipe's own `outputs.change`
    # block tells you to threshold: `peak_to_trough` clears every floor (0.7772 / 0.9474 /
    # 0.6836) where `magnitude` fails two of three (0.5292 / 0.9631 / 0.4502). Showing the
    # weaker statistic while documenting the stronger one is the kind of gap a stage recording
    # freezes for a year.
    #
    # PALETTE: peak-to-trough is NON-POSITIVE BY CONSTRUCTION (a peak-to-trough drop cannot be
    # a rise), so a diverging red/green ramp spends half its range on values that cannot occur
    # and reads "green = recovery" where the data can only mean "no disturbance". This is an
    # inline `colorRamp` (registry/colormaps/README.md, "a ramp that is not here") rather than a
    # catalog slug because no catalog entry is one-sided over [-1, 0]: ColorBrewer Reds,
    # reversed, so 0 = untouched and deeper red = deeper disturbance. CVD-safe (sequential).
    # Style is presentation-only — bind() drops it (LAYER_PRESENTATION_KEYS), so this changes no
    # lock, no layer key and no binding digest; verified against the committed default lock.
    # NON-POSITIVE VERIFIED, not assumed: on the hero run (Bootleg AOI, 967x1000, lock
    # aa01bc80…) band 4 has max EXACTLY 0.0 and ZERO positive pixels across 918,512 finite
    # values, min -1.2237, mean -0.2109; band 1 over the same grid carries 323,126 POSITIVE
    # pixels, which is why IT wants a two-sided ramp and this band does not. The -1.0 stop is a
    # display saturation point, not the data floor: values past it clamp to #67000d.
    style:
      band: peak_to_trough
      clim: [-1.0, 0.0]
      colorRamp:
        - { value: -1.00, color: "#67000d" }   # deepest drop
        - { value: -0.60, color: "#a50f15" }
        - { value: -0.40, color: "#cb181d" }
        - { value: -0.25, color: "#ef3b2c" }   # ~the -0.20 burn threshold sits here
        - { value: -0.15, color: "#fb6a4a" }
        - { value: -0.05, color: "#fcbba1" }
        - { value:  0.00, color: "#fff5f0" }   # no disturbance

outputs:
  change:
    layer: compute/landtrendr
    value_kind: raster
    description: >
      FOUR bands (LT-03, 2026-08-19; was two) — magnitude (NET change, fitted last minus
      first), year (segment-start disturbance onset), rmse, and peak_to_trough (the signed
      disturbance DEPTH the burn literature thresholds on). The first two map to the
      detector contract's `magnitude` / `onset_year`.
      WHICH BAND TO THRESHOLD, MEASURED (2026-08-19, this recipe over the MTBS Bootleg
      perimeter on the release engine, one run, both bands read from the same GeoTIFF,
      burn threshold -0.20): band 4 `peak_to_trough` scores recall 0.7772 / precision
      0.9474 / kappa 0.6836 and clears every floor; band 1 `magnitude` scores
      0.5292 / 0.9631 / 0.4502 and fails the recall and kappa floors. Use band 4 for
      disturbance detection; band 1 is a signed whole-period trend, which is a different
      question.
      YEAR OFFSET, MEASURED (2026-08-19, kernel crate:landtrendr@a4b10762, this recipe run
      over the MTBS Bootleg perimeter): median reported year 2020 for the 2021 fire, i.e.
      a -1 year offset; 87.4% of detected burn pixels land within 1 year of 2021 and 99.5%
      report an onset on or before it. The offset is the segment-START convention, not an
      error, and v0.4 narrowed it: the same statistic on the cached-composite path went
      from 0.42 to 0.89 within-1-year with the v0.4 vertex fix (d488654). Replaces the
      earlier prose claim "currently one year early".
  nbr_annual:
    layer: compute/nbr-annual
    value_kind: raster
    description: >
      The annual NBR cube the segmentation ran on, one band per year (show-your-work,
      and the artifact worth caching — it is ~20x smaller than the scenes behind it).

views:
  - name: LandTrendr change
    layers: [compute/landtrendr]

extensions:
  folia/category: change-detection
  folia/themes: [vegetation, forest, disturbance]
  folia/family: trajectory-segmentation
  folia/cites_papers: ["kennedy-2010"]   # Kennedy, Yang & Cohen 2010
  # `dashboard:` is NOT a governed top-level field (AuthorRecipe is deny_unknown_fields), so it
  # rides here. The browser readers (registry/recipes/_runner/runner.js,
  # packages/render/src/element/folia-view.ts) still look for a TOP-LEVEL `dashboard:` — they
  # need to fall back to extensions["folia/dashboard"] before this recipe renders its pixel
  # inspector again. First governed recipe to carry one; no prior art to copy.
  folia/dashboard:
    patterns:
      - slug: pixel-trajectory-landtrendr
        slot: temporal
        layer: compute/nbr-annual
        element: folia-pixel-inspector
