Feature Specs & Acceptance Criteria
Conventions: FS-x = feature spec, FR-x.y = functional requirement, AC-x.y = acceptance criterion. Every AC maps to a pytest test or an explicit verified-by-inspection entry in docs/traceability.md. Anchor AOI and windows (used throughout):
- AOI (San Pedro Bay): bbox
[-118.29, 33.68, -117.99, 33.79]; LA River outlet(-118.193, 33.755); San Gabriel River outlet(-118.096, 33.740). - Pre-storm window: 2024-01-10 → 2024-01-28. Post-storm window: 2024-02-05 → 2024-02-24 (following the Feb 4–7, 2024 atmospheric river).
How the feature specs compose:
FS-1 — Sentinel-2 FDI Ingestion
Detect floating-debris candidates in Sentinel-2 L2A imagery using the Floating Debris Index (Biermann et al. 2020).
Stories: US-05, US-07, US-08
Functional requirements
- FR-1.1 Search the
sentinel-2-l2acollection on the Element84 earth-search STAC API by AOI bbox, datetime window, and max cloud cover (default 40%, configurable). - FR-1.2 Read bands B03 (green), B04 (red), B06 (red-edge 2), B08 (NIR), B11 (SWIR1) via windowed COG reads for the AOI only; compute on a 20 m grid.
- FR-1.3 Mask non-water pixels with NDWI = (B03 − B08)/(B03 + B08); keep pixels with NDWI > 0.
- FR-1.4 Compute FDI = R_NIR − R′_NIR, where R′_NIR = R_RE2 + (R_SWIR1 − R_RE2) × ((λ_NIR − λ_RED)/(λ_SWIR1 − λ_RED)) × 10, with λ_NIR = 832.8 nm, λ_RED = 664.6 nm, λ_SWIR1 = 1613.7 nm.
- FR-1.5 Flag anomalies: water pixels with FDI > μ_water + k·σ_water (k default 3.0, scene-adaptive statistics).
- FR-1.6 Cluster flagged pixels (8-connectivity, minimum 2 pixels) into detections with centroid, footprint, area (m²), mean FDI, and a 0–1 score.
- FR-1.7 Emit detections as
Observationrecords via the commonSourceAdapterinterface; retain scene ID, band statistics, and threshold parameters in the raw payload.
Acceptance criteria
| ID | Given / When / Then | Verified by |
|---|---|---|
| AC-1.1 | Given the AOI and post-storm window, when the adapter searches the STAC API, then ≥1 L2A scene within the cloud limit is returned. | pytest (network-marked) |
| AC-1.2 | Given a synthetic 5-band raster with known reflectances, when FDI is computed, then values match the FR-1.4 formula within 1e-6. | pytest |
| AC-1.3 | Given a synthetic raster containing a land block (NDWI ≤ 0) with debris-like spectra, when the pipeline runs, then no detection falls on land pixels. | pytest |
| AC-1.4 | Given a completed scene run, when detections are emitted, then every Observation carries scene ID, capture timestamp, geometry, score, and band provenance. | pytest |
| AC-1.5 | Given the adapter registry, when a new satellite adapter implementing SourceAdapter is registered (e.g., future CDSE), then the pipeline consumes it without changes downstream. | pytest |
Out of scope: ML classification (MARIDA-trained model is post-MVP), atmospheric re-correction, sun-glint correction.
FS-2 — NOAA MDMAP Ingestion
Parse NOAA Marine Debris Monitoring and Assessment Project shoreline-survey exports.
Stories: US-01, US-03
Functional requirements
- FR-2.1 Parse the MDMAP survey export CSV format (one row per survey × item category: site, coordinates, survey date, item category, count).
- FR-2.2 Emit one
Observationper survey with per-category item counts aggregated; geometry from site coordinates. - FR-2.3 Reject malformed rows (missing coordinates/date, non-numeric counts) into a rejection report with row number and reason.
Acceptance criteria
| ID | Given / When / Then | Verified by |
|---|---|---|
| AC-2.1 | Given an MDMAP-format CSV, when ingested, then each survey becomes an Observation with site name, timestamp, geometry, and item counts. | pytest |
| AC-2.2 | Given the same CSV, when ingested, then the sum of item counts in equals the sum stored (no silent loss). | pytest |
| AC-2.3 | Given a CSV containing malformed rows, when ingested, then valid rows load, malformed rows appear in the rejection report with reasons, and the counts reconcile. | pytest |
FS-3 — Debris Tracker Ingestion (unstructured extraction)
Parse Debris Tracker citizen-science exports, including free-text notes, into structured records.
Stories: US-01, US-04
Functional requirements
- FR-3.1 Parse the Debris Tracker export CSV (list/item/material/quantity/lat/lon/timestamp/description).
- FR-3.2 Extract structure from free-text descriptions (quantities, materials, items, event context like "after the storm") via a rule-based extractor; an optional LLM-assist path may refine results when Bedrock is available.
- FR-3.3 Tag every extraction with its method (
rulesorllm) and setneeds_review=Truewhen confidence is low or fields conflict — never silently guess.
Acceptance criteria
| ID | Given / When / Then | Verified by |
|---|---|---|
| AC-3.1 | Given a Debris Tracker export CSV, when ingested, then each row becomes an Observation with item, material, quantity, geometry, and timestamp. | pytest |
| AC-3.2 | Given a note like "about 30 plastic bottles near the jetty after the storm", when extracted, then quantity=30, material=plastic, item=bottles, and storm context are captured with the extraction method recorded. | pytest |
| AC-3.3 | Given an ambiguous note (e.g., "some stuff washed up"), when extracted, then the observation is flagged needs_review=True with no fabricated quantity. | pytest |
FS-4 — Normalization Core
One schema, one store, full provenance — the heart of the Helio engine transfer.
Stories: US-01, US-02, US-03
Functional requirements
- FR-4.1 Define
Observation(pydantic):id,source(enum),observed_at,geometry(GeoJSON point/polygon),category(satellite_detection | shoreline_survey | citizen_report),attributes(typed per category: score/area for detections, item counts for surveys, item/material/quantity for reports),needs_review,raw(verbatim source payload),ingested_at. - FR-4.2 Persist to SQLite with a deterministic natural key per source record; upserts make re-runs idempotent.
- FR-4.3 Query interface: filter by bbox, time range, source, category.
- FR-4.4 Pipeline runner executes all registered adapters and reports per-source counts (loaded / rejected / flagged) plus live-vs-fixture provenance.
Acceptance criteria
| ID | Given / When / Then | Verified by |
|---|---|---|
| AC-4.1 | Given output from all three adapters, when validated, then every record conforms to the Observation schema. | pytest |
| AC-4.2 | Given a completed pipeline run, when the pipeline runs again on the same inputs, then the store contains no duplicates and counts are unchanged. | pytest |
| AC-4.3 | Given any stored observation, when fetched, then its raw source payload and source identity are intact. | pytest |
| AC-4.4 | Given stored observations, when queried by bbox + time range + source, then exactly the matching records return. | pytest |
FS-5 — Accumulation Zone Classification
Cross-reference satellite detections with ground truth to classify zones.
Stories: US-06
Functional requirements
- FR-5.1 Within a time window, cluster satellite detections spatially (DBSCAN-style: 1 km linkage).
- FR-5.2 Classify each cluster: confirmed if ≥1 ground observation (survey or citizen report) lies within 2 km and ±14 days of any member detection; otherwise suspected.
- FR-5.3 Classify ground-only concentrations (≥3 ground observations within 1 km in-window, no satellite cluster) as reported.
- FR-5.4 Emit zones as a GeoJSON FeatureCollection: convex-hull footprint (buffered for point-degenerate cases), status, evidence (detection/ground counts and IDs), window label.
- FR-5.5 Zone computation is strictly window-scoped: only in-window observations participate.
Acceptance criteria
| ID | Given / When / Then | Verified by |
|---|---|---|
| AC-5.1 | Given a detection cluster and a ground observation within 2 km and ±14 days, when zones are computed, then that zone's status is confirmed and its evidence lists both. | pytest |
| AC-5.2 | Given a detection cluster with no ground observation in range, then its status is suspected; given ≥3 ground observations within 1 km and no detections, then status is reported. | pytest |
| AC-5.3 | Given computed zones, when serialized, then the output is valid GeoJSON with status, evidence counts, and window label per feature. | pytest |
| AC-5.4 | Given observations outside the requested window, when zones are computed, then those observations affect nothing. | pytest |
FS-6 — Map-First Web App
MapLibre GL map served by FastAPI; the demo surface.
Stories: US-09, US-10, US-11, US-12
Functional requirements
- FR-6.1
GET /serves the map app;GET /api/observations?window=&source=andGET /api/zones?window=return GeoJSON. - FR-6.2 Layers: zones (fill: confirmed
#F44336, suspected#FF9800, reported#2196F3), satellite detections (graduated circles by score), ground observations (distinct markers per source); river outlet markers for LA River and San Gabriel River. - FR-6.3 Pre/post-storm window toggle re-fetches layers without page reload.
- FR-6.4 Click popups show observation/zone details incl. source, time, evidence, and
needs_reviewflags. - FR-6.5 Legend plus a provenance banner stating which sources are live and which are fixtures in the current dataset (fed by the FR-4.4 run report).
Acceptance criteria
| ID | Given / When / Then | Verified by |
|---|---|---|
| AC-6.1 | Given a populated store, when /api/zones?window=post is called, then valid GeoJSON returns and the map renders zone layers colored by status. | pytest (API) + inspection (render) |
| AC-6.2 | Given the loaded map, when the window toggle is switched, then layers update to the other window without reload. | inspection |
| AC-6.3 | Given a rendered zone or observation, when clicked, then a popup shows its source, time, and evidence. | inspection |
| AC-6.4 | Given the loaded map, then the legend and live-vs-fixture provenance banner are visible and accurate per the latest pipeline run report. | pytest (API) + inspection |
FS-7 — Natural-Language Query
Ask questions over the normalized store; answers must be data-grounded.
Stories: US-13, US-14
Functional requirements
- FR-7.1
POST /api/query {question}→{answer, mode, evidence}. - FR-7.2 LLM mode: Claude via AWS Bedrock with tool-use over defined tools —
count_observations(filters),zone_summary(window, status),compare_windows(metric),top_items(window). The model must answer only from tool results. - FR-7.3 Offline mode: without working Bedrock access, a rule-based handler answers count/summary/comparison questions from the same tools; response is labeled
mode: "offline". - FR-7.4 Questions outside the data's scope return an explicit insufficient-data answer.
Acceptance criteria
| ID | Given / When / Then | Verified by |
|---|---|---|
| AC-7.1 | Given a populated store, when a supported question is posted, then the answer contains figures matching the store and lists the evidence used. | pytest (offline mode) |
| AC-7.2 | Given Bedrock credentials, when a question is posted, then ≥1 tool call is executed and the answer is built from its results; without credentials, the same endpoint answers in labeled offline mode. | pytest (offline) + live check (Bedrock) |
| AC-7.3 | Given a question the data cannot answer (e.g., "how many whales were affected?"), then the response says the data is insufficient and fabricates nothing. | pytest (offline mode) |
FS-8 — Auto-Generated Briefing
One-click post-event situation briefing.
Stories: US-15, US-16
Functional requirements
- FR-8.1
GET /api/briefing?window=postcomposes a briefing: scenes analyzed, detections, zone breakdown by status, top ground-reported items, and deltas vs. the pre-storm window. - FR-8.2 The briefing includes a data-provenance section (live vs. fixture per source, from the FR-4.4 run report).
- FR-8.3 Output is markdown;
scripts/make_briefing.pyrenders it to PDF viatools/pdfgen(LLM narrative polish optional when Bedrock is available, clearly attributed).
Acceptance criteria
| ID | Given / When / Then | Verified by |
|---|---|---|
| AC-8.1 | Given a populated store, when the post-storm briefing is generated, then it contains detection counts, zone breakdown, top items, and pre-vs-post deltas that match the store. | pytest |
| AC-8.2 | Given any briefing, then a provenance section states which sources were live vs. fixture. | pytest |
| AC-8.3 | Given a generated briefing, when rendered via tools/pdfgen, then a PDF is produced. | pytest (script exit + file exists) |
v2 Addendum — App Shell & Chat Refinement (2026-07-06)
Post-review changes requested after the first working demo: desktop-app shell, map clarity, chat UX.
FS-6b — Desktop shell, basemap, and feature detail
Stories: US-09..US-12 (extends FS-6)
- FR-6.6 Left icon rail (Figma-style) hosting panels: Layers, Ask, Briefing, Data; one panel open at a time; map stays full-bleed.
- FR-6.7 Basemap toggle: Chart (Carto light) ↔ Satellite (Esri World Imagery), always visible in a floating top bar beside the window toggle.
- FR-6.8 Clicking any feature opens a right-side detail panel with the actual metrics: per-item counts for surveys, item/material/quantity + note for citizen reports, FDI score/area/scene for detections, evidence numbers + site names for zones — plus Google Maps satellite and Street View deep links for the location.
- FR-6.9 First-open explainer card describing the three data layers; dismissible, remembered via localStorage.
- FR-6.10 Zones carry
ground_sites(names) andcentroidin their GeoJSON properties; observations expose full attribute metrics (incl. per-item counts).
| ID | Given / When / Then | Verified by |
|---|---|---|
| AC-6.5 | Given the map, when the basemap toggle is switched, then tiles change between chart and satellite without reload. | inspection |
| AC-6.6 | Given a clicked survey/report/detection/zone, then the detail panel shows its real metrics and working Google Maps links. | pytest (API metrics) + inspection |
| AC-6.7 | Given zones GeoJSON, then features carry ground_sites and centroid. | pytest |
FS-7b — Chat refinement
Stories: US-13, US-14 (extends FS-7)
- FR-7.5 Chat thread UI with history, suggested questions, disabled input while pending, and a progress indicator showing elapsed time (LLM queries typically run 10–60 s — waiting must be designed).
- FR-7.6
site_breakdowntool exposes per-site names/coordinates/totals so answers can reference real places; offline mode routes "which beach/site" questions to it and phrases answers in plain language (no raw JSON). - FR-7.7 Bedrock calls run at
effort: lowwith reduced max_tokens to cut interactive latency.
| ID | Given / When / Then | Verified by |
|---|---|---|
| AC-7.4 | Given "which beach had the most trash after the storm?", then the answer names the top site with its item total. | pytest (offline) + live check |
| AC-7.5 | Given a pending query, then the UI shows an animated progress state with elapsed seconds and re-enables on completion or error. | inspection |