# Damoah Hazard Detector — LEAD PILOT alert recipe (docs/spec/ALERTS.md §2.2, §10;
# .tickets/impl/alerts-v1/10-recipe-damoah-hazard-detector.md).
#
# Benjamin Damoah's (USM) funded "Hazard Detector" for Gulf Coast coastal resilience,
# parameterized as a subscribable alert layer. Reconstructed from the partner brief
# (docs/funding/nasa-roses-innovate/partners/damoah-usm.md) and the ms-wetlands framing;
# live extraction of the exact inputs/thresholds/cadence from the `@benjamin.damoah/
# ms-wetlands` workspace is the OPEN seam (ticket T001), bounded by the 2026-09-05 cutoff.
# Every threshold below therefore says where its number came from — see the parameter
# blocks. None of them is fitted against Gulf Coast truth, and each says so.

spec_version: folia/0.1
name: hazard-detector
version: "0.2.0"
description: >
  Wetland and forest CHANGE over a Gulf Coast county, delivered as a subscribable alert
  and ranked by community vulnerability. Two Sentinel-2 composites per AOI — a baseline
  window and a current window — give NDVI (vegetation vigour) and MNDWI (open water /
  wetland inundation). The bitemporal deltas fire two independent arms: vegetation LOSS
  when NDVI drops past a threshold, wetland CHANGE when MNDWI moves either direction past
  a threshold. A cell fires if either arm crosses, and carries both deltas plus which arm
  drove it, so a reader sees "NDVI 0.75 -> 0.35 (-0.40) => forest loss" rather than a bare
  boolean.

  What the incumbent self-serve alert surfaces never combine is the second half: each
  firing is joined to its CDC/ATSDR Social Vulnerability Index tract and carries that
  tract's percentile, so county planners and emergency managers (MEMA / Gulf Coast
  counties, the named end-users) can triage by who is least able to absorb the change.
  The SVI RANKS delivery. It never gates detection — the fire set is identical at every
  svi_floor.

  The detector is not novel science and does not try to beat GFW or DIST-ALERT at
  detection. What it demonstrates is subscription + vulnerability-contextualized delivery
  to non-developer end-users, composed entirely from shipped ops (imagery_composite +
  stack_concat + raster_calc). No new op.

# ── Parameters: every knob a subscriber can turn, typed and defaulted ─────────────────
#
# These ARE the input contract. `layer_params` on a subscription supplies them (ALERTS
# §2.2); `folia run -p name=value` supplies them locally. Anything not listed here is a
# decision this recipe made on the reader's behalf, and the comments say why.

parameters:

  aoi_bbox:
    type: bbox
    description: >
      Watched extent [w, s, e, n] in WGS84. Default covers Hancock/Harrison County on the
      Mississippi Gulf Coast — the ms-wetlands study area. A subscription overrides this
      with the county geometry it watches.
    default: [-89.60, 30.15, -89.00, 30.55]

  baseline_start:
    type: string
    description: Start of the BEFORE reference window.
    default: "2023-01-01"
  baseline_end:
    type: string
    description: >
      End of the BEFORE reference window. A full calendar year by default, so the median
      composite averages over a whole phenological cycle rather than one season — a
      baseline built from summer scenes alone would read every winter current-window
      composite as vegetation loss.
    default: "2023-12-31"

  current_start:
    type: string
    description: Start of the AFTER window under test.
    default: "2026-01-01"
  current_end:
    type: string
    description: >
      End of the AFTER window under test. Shorter than the baseline on purpose: this is
      the recent state, and a subscription's dispatcher advances it as new overpasses
      land (see extensions.folia/freshness-ref).
    default: "2026-06-30"

  veg_loss_threshold:
    type: number
    minimum: 0.0
    maximum: 2.0
    description: >
      NDVI drop that counts as vegetation/forest loss. The veg arm fires where
      (ndvi_baseline - ndvi_current) >= this value.

      WHY 0.15: it is a CONVENTION, not a calibration. Between two annual median
      composites an undisturbed Gulf Coast pine/hardwood stand moves roughly 0.05-0.10 in
      NDVI, and a stand-replacing loss moves 0.3 or more; 0.15 sits above the first and
      below the second. It has NOT been fitted against a labelled Gulf Coast disturbance
      set, because no such set exists in this repo yet (see
      registry/tests/test_hazard_detector.py, which is PLUMBING against authored fixtures,
      not a detection-fidelity benchmark). That uncertainty is exactly why the number is a
      declared parameter a subscriber tunes rather than a constant in an expression.
    default: 0.15

  wet_change_threshold:
    type: number
    minimum: 0.0
    maximum: 2.0
    description: >
      |MNDWI| change that counts as wetland change. The wetland arm fires where
      |mndwi_current - mndwi_baseline| >= this value. Magnitude, not sign: a marsh that
      floods and a marsh that dries are both changes an emergency manager wants.

      WHY 0.15: same standing as the vegetation threshold — a convention, not a fit.
      MNDWI crosses zero at the water/land boundary, median-composite noise over stable
      ground is about +/-0.05, and a full land-to-water flip moves 0.4 or more. 0.15
      clears the noise floor without requiring a complete flip. Uncalibrated for this AOI.
    default: 0.15

  min_affected_area:
    type: integer
    minimum: 1
    description: >
      Minimum contiguous fired cells before the SUBSCRIPTION notifies (it maps to the
      trigger's `min_cells`, ALERTS §3). It suppresses single-cell speckle; it does not
      change what the detector flags.

      WHY 3: an h3 r9 cell is about 0.105 km2, so three contiguous cells is about
      0.32 km2 — a stand, not a compositing artefact at a scene edge. Chosen for that
      physical reading, NOT derived from a measured false-positive rate; deriving one
      needs the labelled set that does not exist yet.
    default: 3

  svi_floor:
    type: number
    minimum: 0.0
    maximum: 1.0
    description: >
      OPTIONAL vulnerability triage on DELIVERY. When greater than 0, only firings whose
      containing tract's SVI percentile (RPL_THEMES) is at or above this value are
      delivered; every delivered firing carries its SVI context either way.

      WHY the default is 0: the SVI is an OVERLAY, and a default that silently withheld
      firings in low-vulnerability tracts would make a ranking signal look like a
      detection rule. At 0 every firing is delivered and the subscriber ranks. The
      invariant that this parameter can never move the fire set is pinned by
      test_svi_does_not_change_the_fire_set.
    default: 0.0

  composite_method:
    type: string
    enum: [median, mean, min, max]
    description: >
      Per-pixel reduction over the scene stack in each window. Median by default because
      it is the reduction that survives residual cloud the SCL mask missed — a mean lets
      one bright cloud edge pull a whole composite.
    default: median

  scene_limit:
    type: integer
    minimum: 1
    description: >
      Scene budget PER YEAR, not per window (imagery_composite stratifies its search by
      year). At 24 the 2023 baseline draws up to 24 scenes and the 2026 current window up
      to 24 more. WHY 24: roughly one clear-ish Sentinel-2 pass per fortnight, which is
      enough for a stable annual median over a cloudy subtropical coast; the op default of
      6 admits visible cloud residue into the median at this latitude.
    default: 24

  target_resolution_m:
    type: number
    minimum: 1
    description: >
      Grid all four composites snap to, in metres.

      WHY 20, stated because leaving it implicit would be the magic: imagery_composite's
      own default is 30 m, which is LANDSAT's grid, not Sentinel-2's. NDVI's pair (N, R)
      is natively 10 m; MNDWI's pair (G, S1) is 10 m and 20 m, because SWIR-1 (B11) is a
      20 m band. 20 m is the coarser of the two pairs, so MNDWI is never upsampled into
      detail B11 does not carry, and all four composites land on ONE grid — which the
      stack_concat pairs below require, since a bitemporal difference across mismatched
      grids is not a difference.
    default: 20

layers:

  # ── SOURCE COMPOSITES ────────────────────────────────────────────────────────────────
  # Four reads, each independently addressable and each a named output below, so the
  # "before" and "after" a firing claims can be looked at directly.
  #
  # A band PAIR in `bands:` makes imagery_composite compute the normalized difference
  # natively (the dataset owns the SR scale/offset), so each index is ONE op call:
  #   NDVI  = [N, R]   (nir, red)     -> vegetation / forest vigour
  #   MNDWI = [G, S1]  (green, swir1) -> open water / wetland inundation
  # Band codes are canonical registry codes, not asset keys; the MPC distribution maps
  # N->B08/nir08, R->B04, G->B03, S1->B11.

  compute/ndvi-baseline:
    type: raster
    description: "Baseline-window S2 NDVI composite — the BEFORE vegetation reference."
    ref: "@esa/sentinel-2/l2a"
    params:
      bands: [N, R]
      bbox: { param: aoi_bbox }
      temporal: [{ param: baseline_start }, { param: baseline_end }]
      method: { param: composite_method }
      limit: { param: scene_limit }
      resolution: { param: target_resolution_m }
    style: { palette: rdylgn, clim: [-0.2, 0.9] }

  compute/ndvi-current:
    type: raster
    description: "Current-window S2 NDVI composite — the AFTER vegetation state."
    ref: "@esa/sentinel-2/l2a"
    params:
      bands: [N, R]
      bbox: { param: aoi_bbox }
      temporal: [{ param: current_start }, { param: current_end }]
      method: { param: composite_method }
      limit: { param: scene_limit }
      resolution: { param: target_resolution_m }
    style: { palette: rdylgn, clim: [-0.2, 0.9] }

  compute/mndwi-baseline:
    type: raster
    description: "Baseline-window S2 MNDWI composite — the BEFORE water/wetland reference."
    ref: "@esa/sentinel-2/l2a"
    params:
      bands: [G, S1]
      bbox: { param: aoi_bbox }
      temporal: [{ param: baseline_start }, { param: baseline_end }]
      method: { param: composite_method }
      limit: { param: scene_limit }
      resolution: { param: target_resolution_m }
    style: { palette: blues, clim: [-0.6, 0.6] }

  compute/mndwi-current:
    type: raster
    description: "Current-window S2 MNDWI composite — the AFTER water/wetland state."
    ref: "@esa/sentinel-2/l2a"
    params:
      bands: [G, S1]
      bbox: { param: aoi_bbox }
      temporal: [{ param: current_start }, { param: current_end }]
      method: { param: composite_method }
      limit: { param: scene_limit }
      resolution: { param: target_resolution_m }
    style: { palette: blues, clim: [-0.6, 0.6] }

  # ── BITEMPORAL DELTAS ───────────────────────────────────────────────────────────────
  # raster_calc reads ONE raster and binds its bands positionally as b1..bN, so two-layer
  # arithmetic composes: stack_concat stacks the ordered pair into one 2-band raster, then
  # raster_calc differences b1 and b2. The stack order IS the meaning — current first,
  # baseline second, so `b1 - b2` is (after - before) and a NEGATIVE dNDVI is loss.

  compute/dndvi-stack:
    type: raster
    description: "Ordered pair [current NDVI, baseline NDVI] as one 2-band raster."
    compute:
      op: stack_concat
      inputs:
        rasters:
          - { layer: compute/ndvi-current }    # b1
          - { layer: compute/ndvi-baseline }   # b2

  compute/dndvi:
    type: raster
    description: "dNDVI = current - baseline. Negative means vegetation/forest loss."
    compute:
      op: raster_calc
      inputs:
        data: { layer: compute/dndvi-stack }
      params:
        expression: "b1 - b2"
        output_band: dndvi

  compute/dmndwi-stack:
    type: raster
    description: "Ordered pair [current MNDWI, baseline MNDWI] as one 2-band raster."
    compute:
      op: stack_concat
      inputs:
        rasters:
          - { layer: compute/mndwi-current }    # b1
          - { layer: compute/mndwi-baseline }   # b2

  compute/dmndwi:
    type: raster
    description: >
      dMNDWI = current - baseline. Large magnitude in EITHER direction is wetland change:
      positive is inundation gain, negative is drying.
    compute:
      op: raster_calc
      inputs:
        data: { layer: compute/dmndwi-stack }
      params:
        expression: "b1 - b2"
        output_band: dmndwi

  # ── THE ALERT LAYER ─────────────────────────────────────────────────────────────────

  alerts/_delta-stack:
    type: raster
    description: "Ordered pair [dNDVI, dMNDWI] as one 2-band raster, for the OR below."
    compute:
      op: stack_concat
      inputs:
        rasters:
          - { layer: compute/dndvi }    # b1 = dNDVI
          - { layer: compute/dmndwi }   # b2 = dMNDWI

  alerts/hazard-detector:
    type: raster
    description: >
      The hazard score. Fires where the vegetation arm (dNDVI <= -veg_loss_threshold) OR
      the wetland arm (|dMNDWI| >= wet_change_threshold) crosses.

      `score` is each arm's exceedance NORMALIZED AGAINST ITS OWN THRESHOLD and clamped at
      1, then the larger of the two. So score is not a probability and not a magnitude in
      index units — it is "how far past the line, in units of the line", and 1.0 means
      "at or past the threshold, i.e. this cell fires". Normalizing this way is what lets
      one number carry two arms measured in different indices; clamping is what stops a
      catastrophic loss from being ranked above a subscriber's own threshold decision.
    compute:
      op: raster_calc
      inputs:
        data: { layer: alerts/_delta-stack }
      params:
        # These two scalars bind INTO the expression by name (raster_calc binds every
        # numeric sibling param by name; unbound identifiers are refused, never zeroed —
        # folia-engine/src/axis.rs `calc_program_and_scalars`), which is how a
        # subscriber's layer_params reach the arithmetic without the thresholds being
        # baked into the string. The `{param: ...}` on the right is resolved by the
        # governed binder BEFORE the op sees it (folia-engine/src/bind.rs), so what
        # arrives here is the number 0.15 (or the subscriber's override), never the
        # parameter DECLARATION mapping — which is exactly the defect the four legacy
        # `params:`-dialect alert recipes still carry (registry/recipes/_plan_skips.yaml).
        #
        # READ THIS IF `folia plan` WARNED YOU: it prints "op `raster_calc` does not
        # declare param(s) [veg_loss_threshold, wet_change_threshold] — nothing in this
        # engine reads them". That line is advisory and, for this op, wrong: the check
        # (folia-engine/src/validate.rs `check_unknown`) compares against the enumerated
        # `params:` block of folia/domains/geo/operations/raster/calc.yaml, while the
        # SAME record's own description sanctions the spelling — "scalar sibling params
        # bind by name" — as does docs/RECIPE_AUTHORING.md §5. The op record enumerates
        # `expression`/`output_band` because the other names are the AUTHOR's, one set
        # per recipe, so there is nothing global to enumerate. Pinned from this side by
        # test_threshold_scalars_are_declared_number_parameters.
        veg_loss_threshold: { param: veg_loss_threshold }
        wet_change_threshold: { param: wet_change_threshold }
        # b1 = dNDVI, b2 = dMNDWI (the stack order above).
        expression: >
          max(min(max(-b1, 0) / veg_loss_threshold, 1),
              min(abs(b2) / wet_change_threshold, 1))
        output_band: score
    style:
      # Explicit stops rather than `palette:`, because the breakpoints are this recipe's,
      # not a catalog ramp's. Since `score` is normalized against the thresholds, 1.0 is a
      # SEGMENT BOUNDARY — the value at which a cell fires — not the top of a smooth
      # range. A named ramp would spread colors evenly across 0..1 and put nothing in
      # particular at the one value a reader needs to see. These stops hold grey below
      # half-threshold, warm through the approach, and go hard red at the firing edge.
      #
      # This is the case registry/colormaps/README.md means by "a ramp specific to one
      # layer": recipe-defined breakpoints, so no catalog entry is owed. Values are
      # absolute, in the layer's own units (a 0..1 score).
      colorRamp:
        - { value: 0.0,  color: "#e8e8e8" }   # no signal
        - { value: 0.5,  color: "#fee08b" }   # half the threshold
        - { value: 0.85, color: "#f46d43" }   # approaching
        - { value: 1.0,  color: "#a50026" }   # threshold crossed — fires
      opacity: 0.85
    extensions:
      # Cell system the firings are indexed on (ALERTS §2). r9 is about 0.105 km2 per
      # cell: coarser than the 20 m grid the detector runs on, because county-scale
      # alerting triages areas, not pixels.
      folia/geo: { cell_system: h3, resolution: 9 }

outputs:

  hazard:
    layer: alerts/hazard-detector
    value_kind: raster
    description: >
      THE hazard output: per-cell change score, thresholdable at 1.0. A subscription
      watches this layer; a crossing becomes a firing carrying the deltas, the driving
      arm, and the affected tract's SVI percentile (extensions.folia/firing-provenance).

  # Show-your-work. Each is a real layer a reader can open on its own, which is what makes
  # the claim behind a firing checkable rather than assertable.
  dndvi:
    layer: compute/dndvi
    value_kind: raster
    description: Bitemporal NDVI change (current - baseline) behind the vegetation arm.
  dmndwi:
    layer: compute/dmndwi
    value_kind: raster
    description: Bitemporal MNDWI change (current - baseline) behind the wetland arm.
  ndvi_baseline:
    layer: compute/ndvi-baseline
    value_kind: raster
    description: The BEFORE vegetation composite.
  ndvi_current:
    layer: compute/ndvi-current
    value_kind: raster
    description: The AFTER vegetation composite.
  mndwi_baseline:
    layer: compute/mndwi-baseline
    value_kind: raster
    description: The BEFORE water/wetland composite.
  mndwi_current:
    layer: compute/mndwi-current
    value_kind: raster
    description: The AFTER water/wetland composite.

# ── Non-executable metadata (excluded from the BoundRecipe projection) ────────────────
# Extension keys are namespaced `owner/name` (FRC-EXT-001). Folia's own metadata lives
# under `folia/*`. Nothing here changes what the engine computes.

extensions:

  folia/category: events-hazards
  folia/themes: [wetlands, forests, change-detection, coastal-resilience]
  folia/family: compound-climate-vulnerability-screening
  folia/deploy: alert
  folia/alert-ready: true

  # Sensible subscription defaults (ALERTS §2). A subscription may override each.
  folia/alert:
    cell_system: h3
    resolution: 9
    # ALERTS §2 offers exactly two spellings for an alert layer's value column —
    # `score` (float 0..1) or `flag` (boolean) — and this layer emits the first: one
    # band, named `score`, normalized against the thresholds and clamped to [0,1].
    # It said `flag` until 2026-08-25, inherited from the legacy draft, and that was
    # a contradiction a reader hit on the very next line: no layer here produces a
    # band called `flag`. Pinned by test_alert_value_names_the_band_the_layer_emits.
    value: score
    # The firing line, stated because `score` is normalized precisely so there IS one:
    # 1.0 means "at or past whichever threshold drove it". A subscription may raise it
    # to take only the deepest changes; it cannot usefully lower it, since below 1.0 no
    # arm has crossed. Same `_default` convention as cooldown_default below (ALERTS §2:
    # the layer carries sensible subscription defaults, the subscription owns the trigger).
    threshold_default: 1.0
    # WHERE THE TWO NON-LAYER PARAMETERS GO. Ten of this recipe's twelve parameters are
    # read by a layer, so `folia plan` proves they arrive. `min_affected_area` and
    # `svi_floor` are not: they act at SUBSCRIPTION time, on ALERTS §1's subscription
    # record, downstream of the raster DAG. Left unstated they read as orphan knobs, so
    # each names its destination here — keyed by the parameter, so a reader scanning
    # `parameters:` above can find where it lands. Pinned by
    # test_every_declared_parameter_has_a_destination, which fails if a parameter is
    # neither read by a layer nor listed here.
    parameter_destinations:
      min_affected_area: trigger.min_cells    # ALERTS §3 — suppresses single-cell speckle
      svi_floor: delivery.svi_floor           # triages delivery; never changes the fire set
    # WHY 168h: Sentinel-2 revisit over the Gulf Coast is about 5 days with both
    # platforms. Seven days is one revisit plus slack, so a single disturbance cannot
    # re-fire on its next overpass and read as a second event.
    cooldown_default: 168h
    # Change here is a deterministic threshold crossing, not a model score, so there is no
    # confidence tier to floor. Stated rather than omitted so its absence is not read as
    # an oversight.
    min_confidence: 0.0

  # The CDC/ATSDR Social Vulnerability Index OVERLAY.
  #
  # WHY THIS IS METADATA AND NOT A LAYER: the SVI is a static tract/county VECTOR, and the
  # join it needs is "which tract does this fired cell fall in" — a point-in-polygon
  # attach performed per FIRING, downstream of the raster DAG, by the gateway's firing
  # enrichment. No shipped engine op does that join, and `zonal_stats` is not it (it would
  # give a per-tract mean score, which is a different claim). Declaring a source layer here
  # would create a pointer nothing reads — a layer that plans green and produces nothing
  # (LAYERS.md#source-spelling). Naming the seam honestly is better than faking the wiring.
  folia/svi-overlay:
    dataset: "@cdc-atsdr/svi"     # registered by this ticket: registry/datasets/cdc-atsdr/svi/
    rank_field: RPL_THEMES        # overall summary ranking, 0-1 percentile
    geography: tract
    role: rank-not-gate           # ranks and triages delivery; never changes the fire set
    applied_at: firing-enrichment

  # What every firing commits, so the reasoning travels with the alert (ALERTS §4).
  folia/firing-provenance:
    - driver                          # veg_loss | wetland_change | both
    - dndvi
    - dmndwi
    - svi_percentile                  # affected tract's RPL_THEMES
    - svi_tract                       # the CDC/ATSDR tract FIPS the firing fell in
    - veg_loss_threshold_applied
    - wet_change_threshold_applied

  folia/cache:
    scope:
      - aoi_bbox
      - baseline_start
      - baseline_end
      - current_start
      - current_end
      - veg_loss_threshold
      - wet_change_threshold
      - target_resolution_m
    storage: ./cache/
    reuse_policy: hash_inputs

  folia/license:
    recipe: Apache-2.0
    output_dataset: CC-BY-4.0
    upstream_data:
      - { source: "@esa/sentinel-2/l2a", license: "Copernicus (free, full, open)" }
      - { source: "@cdc-atsdr/svi", license: "public-domain (US CDC/ATSDR)" }

  # ── Source-freshness / dispatcher seam (alerts-v1/11) ───────────────────────────────
  # One live source: Sentinel-2 L2A via MPC. MPC exposes no queryable created/published,
  # so "what's new" is the shipped STAC poll: query by sensing datetime descending and
  # diff the resolved asset set, deduping on {tile}_{sensing} so a Collection-1
  # reprocessing (new full item id, same scene) does NOT re-fire the alert. The
  # distribution already carries this contract (registry/datasets/esa/sentinel-2/l2a/
  # distributions/microsoft-pc.yaml); the alerts-v1/11 dispatcher reads it and wakes on a
  # new overpass with zero recipe changes. The SVI overlay is static — no freshness clock.
  #
  # WHY poll_collection is plain `sentinel-2-l2a` and NOT `sentinel-2-c1-l2a`: a live
  # check on 2026-07-08 found the C1 collection returning ZERO items for this Gulf Coast
  # AOI while plain l2a was current (docs/solutions/data-sources/mpc-s2-c1-collection-
  # empty.md). Polling C1 here would be a detector that never wakes up. The
  # reprocessing-stability that made C1 attractive is already bought by the tile+sensing
  # dedupe key below, which is where it belongs.
  folia/freshness-ref:
    dataset: "@esa/sentinel-2/l2a"
    distribution: microsoft-pc
    kind: stac-poll
    dedupe_key: tile-sensing
    poll_collection: sentinel-2-l2a

  # ── Standing of the numbers in this file (floor rule) ────────────────────────────────
  # The offline suite (registry/tests/test_hazard_detector.py) runs against AUTHORED
  # fixtures, not a pinned Gulf Coast Sentinel-2 archive. It is PLUMBING: it pins the
  # decision path, the threshold arithmetic, the SVI ranks-not-gates invariant and the
  # STAC-poll dedupe, all of which are deterministic and must agree EXACTLY (the floor is
  # exact set equality, not a score). It says nothing about detection fidelity. Axis-1
  # fidelity against real imagery is pending both a pinned archive and the T001 extraction
  # from `@benjamin.damoah/ms-wetlands`.
  folia/validation:
    status: plumbing
    harness: registry/tests/test_hazard_detector.py
    truth: authored fixtures (registry/recipes/hazard-detector/fixtures/)
    expected_floor: exact agreement — deterministic thresholds, fire set pinned cell by cell
    axis1_pending: no pinned real Gulf Coast S2 archive; awaiting T001 workspace extraction
    ticket: .tickets/impl/alerts-v1/10-recipe-damoah-hazard-detector.md
