name: cams-aerosol-anomaly
version: "0.1"
category: events-hazards
themes: [atmosphere, air-quality]
family: aerosol-air-quality-anomaly-alert
deploy: alert
alert_ready: true

description: >
  Parameterized ALERT LAYER (docs/spec/ALERTS.md §2.2, §10.2 pilot): fire where
  the current CAMS AOD550 near-real-time value exceeds its own recent history by
  more than N sigma over a user-chosen baseline window. The threshold is the
  USER's, not the operator's — this is the gap the incumbent CAMS Aerosol Alerts
  service leaves open (it bakes fixed high / very-high / extreme tiers against a
  2003–2018 climatology; here `sigma` and `baseline_window` are subscription
  parameters).

  The recipe is a TEMPLATE, not a concrete layer. `params: { sigma, baseline_window }`
  are supplied per-subscription via `layer_params` (ALERTS §2.2), so one σ=2
  subscriber and one σ=3 subscriber share the same layer definition without
  materializing `cams-aod-sigma-2` / `cams-aod-sigma-3` variants. Because the op's
  z-score is sigma-independent, a stricter σ always yields a SUBSET of a looser σ's
  firings over the same inputs.

  Method:
    1. Ingest CAMS Global Atmospheric Composition Forecast AOD550 (NRT), windowed
       to the AOI, gridded onto the alert cell system (H3 r6).
    2. Slice `baseline_window` into a per-cell baseline series (the "normal"
       reference); the newest observation is the `current` value being scored.
       The event under test is held OUT of the baseline.
    3. anomaly_score (folia-engine op): per cell compute μ, σ_baseline, and
       z = (current − μ) / σ_baseline; fire where current >= μ + sigma·σ_baseline.
    4. Emit a cell-indexed alert layer whose `flag`/`z_score` is thresholdable by
       the subscription (ALERTS §2 alert-layer convention).

  Separation of concerns (ALERTS §2.2):
    - Layer math       : anomaly_score with the declared parameters
    - Layer instance   : one subscriber's σ=3, another's σ=2 (via layer_params)
    - Subscription taste: when to actually notify ME (§3 trigger.threshold on the
      emitted flag/z_score) — NOT the same knob as sigma.

# ── Alert-layer metadata (ALERTS §2: sensible subscription defaults) ──────────
alert:
  cell_system: h3
  resolution: 6                 # ~36 km avg edge — matches CAMS 0.4° native grid
  value: flag                   # thresholdable boolean; z_score also emitted
  cooldown_default: 24h         # CAMS republishes twice daily; don't re-fire same run
  min_confidence: 0.0           # anomaly is deterministic; no ML confidence tier

# ── User-supplied parameters (ALERTS §2.2 — filled by the subscription) ───────
params:
  sigma:
    type: number
    default: 2
    description: >
      σ multiplier. Fire where AOD550 >= μ + sigma·σ over the baseline window.
      Supplied per-subscription as `layer_params.sigma`.
  baseline_window:
    type: duration
    default: 90d
    description: >
      Rolling window defining the "normal" reference μ/σ. The event being scored
      is held out of this window. Supplied as `layer_params.baseline_window`.

settings:
  # CAMS AOD550 is dimensionless optical depth in [0, ~5]; no scale/offset (the
  # ADS product is delivered in physical units, unlike S2 DN reflectance).
  aod_variable: aod550
  # Baseline sampling cadence — CAMS analysis step used to build the series.
  baseline_step: 12h

inputs:
  aoi:
    # A default AOI over the Sahara→W-Africa dust corridor (the pilot's benchmark
    # region). Examples/subscriptions override with the watched geometry.
    bbox: [-18.0, 10.0, 10.0, 30.0]
    crs_target: EPSG:4326

  date_range:
    end: today
    # The window the `current` value is drawn from (the newest CAMS run).
    window: latest-run

  collection:
    # Canonical CAMS dataset id (registry/datasets/ecmwf/cams/...). The `scheduled`
    # freshness contract on its `ads` distribution drives the dispatcher (see below).
    uri: "@ecmwf/cams/global-atmospheric-forecast"
    assets: [aod550]

layers:

  # ── SOURCE (the CAMS AOD550 NRT adapter — T001) ─────────────────────────────

  source/cams-aod:
    type: raster_timeseries
    uri: "@ecmwf/cams/global-atmospheric-forecast"
    description: >
      CAMS AOD550 NRT field, windowed to the AOI over baseline_window + the latest
      run. Read through the `ads` distribution (ADS CDS-API; api-only, GRIB2). This
      layer IS the CAMS adapter: the recipe binds the canonical dataset; the
      distribution's `freshness: { kind: scheduled }` block is what the
      source-freshness dispatcher (alerts-v1/11) wakes on.
    params:
      aoi: "${inputs.aoi}"
      datetime: "${inputs.date_range}"
      assets: "${inputs.collection.assets}"
      baseline_window: "${params.baseline_window}"
      step: "${settings.baseline_step}"

  # ── COMPUTE (the anomaly alert layer — T002/T003) ───────────────────────────

  alerts/cams-aerosol-anomaly:
    type: raster
    description: >
      Per-cell AOD550 anomaly. Fires where current >= μ + sigma·σ_baseline. Emits
      `flag` (thresholdable boolean), `z_score`, `mean`, `std`, and the concrete
      `threshold_value` (μ + Nσ) — the derived value carries its own inputs so a UI
      can render "AOD 0.94 vs μ 0.21 + 2σ = 0.40 → fired" with no hidden magic.
    compute:
      op: anomaly_score
      inputs:
        aod: { layer: source/cams-aod }
      params:
        sigma: "${params.sigma}"
        window: "${params.baseline_window}"

outputs:
  primary:
    - alerts/cams-aerosol-anomaly     # cell-indexed alert layer (flag + z_score)
  show_your_work:
    - source/cams-aod                 # the windowed AOD550 series — inspectable input
  provenance:
    - cams_run_datetime               # base time of the scored forecast run
    - baseline_window_start
    - baseline_window_end
    - sigma_applied                   # echoes layer_params.sigma into the firing record

cache:
  scope:
    - inputs.aoi
    - inputs.date_range
    - params.sigma
    - params.baseline_window
  storage: ./cache/
  reuse_policy: hash_inputs

license:
  recipe: Apache-2.0
  output_dataset: CC-BY-4.0
  upstream_data:
    - source: "@ecmwf/cams/global-atmospheric-forecast"
      license: "Copernicus licence (licence-to-use-copernicus-products)"

# ── Source-freshness / dispatcher-retrofit seam (alerts-v1/11) ────────────────
# For NOW the trigger is the CAMS `scheduled` publish clock declared on the
# distribution (`freshness: { kind: scheduled, schedule: cron(0 8,20 * * *) }`,
# registry/datasets/ecmwf/cams/global-atmospheric-forecast/distributions/ads.yaml).
# Until alerts-v1/11's dispatcher lands, an ad-hoc cron wakes on that clock, runs
# this recipe, and hands the refreshed `alerts/cams-aerosol-anomaly` layer to the
# gateway evaluator (ALERTS §3.2). When the dispatcher lands it REPLACES the ad-hoc
# cron with zero recipe changes: it reads the same `freshness` block, runs the same
# resolver-diff, and emits the same source-refresh event. No seam in this recipe
# changes — the retrofit is entirely upstream of the source/cams-aod layer.
freshness_ref:
  dataset: "@ecmwf/cams/global-atmospheric-forecast"
  distribution: ads
  kind: scheduled
