diff --git a/src/climatevision/analytics/carbon.py b/src/climatevision/analytics/carbon.py index 6e63efa..978f2d4 100644 --- a/src/climatevision/analytics/carbon.py +++ b/src/climatevision/analytics/carbon.py @@ -109,7 +109,30 @@ def estimate_from_mask( Returns: CarbonEstimate with carbon loss and uncertainty bounds """ - deforested_pixels = int(deforestation_mask.sum()) + return self.estimate_from_pixel_count( + int(deforestation_mask.sum()), confidence_map + ) + + def estimate_from_pixel_count( + self, + deforested_pixels: int, + confidence_map: Optional[np.ndarray] = None + ) -> CarbonEstimate: + """ + Estimate carbon loss directly from a deforested-pixel count. + + Equivalent to :meth:`estimate_from_mask` for a mask containing this + many positive pixels, but without materialising the mask array — a + real tile can be tens of millions of pixels. + + Args: + deforested_pixels: Number of deforested pixels + confidence_map: Optional confidence scores per pixel + + Returns: + CarbonEstimate with carbon loss and uncertainty bounds + """ + deforested_pixels = int(deforested_pixels) pixel_area_ha = (self.pixel_size_m ** 2) / 10000 hectares = deforested_pixels * pixel_area_ha diff --git a/src/climatevision/api/main.py b/src/climatevision/api/main.py index 590c123..23d12e1 100644 --- a/src/climatevision/api/main.py +++ b/src/climatevision/api/main.py @@ -133,6 +133,18 @@ class PredictRequest(BaseModel): default=None, description="End date in YYYY-MM-DD format. Must be later than start_date.", ) + enable_carbon: bool = Field( + default=False, + description="If true, include carbon loss estimates for deforestation runs.", + ) + forest_type: Optional[str] = Field( + default=None, + description="Forest type for carbon estimation (e.g. tropical_moist, mangrove).", + ) + region: Optional[str] = Field( + default=None, + description="Region for carbon estimation adjustment (e.g. amazon, congo).", + ) @field_validator("bbox") @classmethod @@ -185,6 +197,103 @@ class ResultRow(BaseModel): created_at: str +# ===== Carbon analytics helpers ===== + + +def _extract_deforested_pixels(payload: dict[str, Any]) -> Optional[int]: + """Pull the deforested-pixel count from a deforestation result payload. + + Returns None when the payload does not carry an inference pixel count. + """ + inference = payload.get("inference") + if not isinstance(inference, dict): + return None + pixels = inference.get("non_forest_pixels") + if pixels is None: + return None + try: + return int(pixels) + except (TypeError, ValueError): + return None + + +def _compute_carbon_estimation( + payload: dict[str, Any], + forest_type: Optional[str] = None, + region: Optional[str] = None, +) -> Optional[dict[str, float]]: + """Deterministic carbon loss estimate for embedding in a predict response. + + Returns None if the carbon module is unavailable or the payload lacks a + pixel count, so the caller can degrade gracefully. + """ + pixels = _extract_deforested_pixels(payload) + if pixels is None: + return None + try: + from climatevision.analytics.carbon import estimate_carbon_loss + + estimate = estimate_carbon_loss( + deforested_pixels=pixels, + forest_type=forest_type or "tropical_moist", + region=region or "default", + ) + except Exception: + logger.exception("Carbon estimation failed") + return None + + return { + "carbon_tonnes": estimate["carbon_tonnes"], + "hectares_lost": estimate["hectares"], + "co2_equivalent": estimate["co2_equivalent"], + "forest_type": forest_type or "tropical_moist", + "region": region or "default", + } + + +def _validate_forest_type(forest_type: Optional[str]) -> None: + """Reject an unknown forest_type instead of silently defaulting. + + A typo such as ``tropical-moist`` (hyphen) would otherwise fall through + to a generic biomass density and return plausible-looking but wrong + numbers with a 200 OK. + """ + if forest_type is None: + return + from climatevision.analytics.carbon import AGB_DENSITY + + if forest_type not in AGB_DENSITY: + raise HTTPException( + status_code=422, + detail=( + f"Unknown forest_type '{forest_type}'. " + f"Valid values: {sorted(AGB_DENSITY)}" + ), + ) + + +def _validate_region(region: Optional[str]) -> None: + """Reject an unknown region instead of silently applying no adjustment. + + An unmatched region (e.g. ``Amazon`` capitalised, or ``amazon `` with a + trailing space) would otherwise fall back to a 1.0 factor and return a + confident-looking number that is wrong by the real regional adjustment. + Since these figures are meant to be cited, fail loudly instead. + """ + if region is None: + return + from climatevision.analytics.carbon import REGIONAL_FACTORS + + if region not in REGIONAL_FACTORS: + raise HTTPException( + status_code=422, + detail=( + f"Unknown region '{region}'. " + f"Valid values: {sorted(REGIONAL_FACTORS)}" + ), + ) + + # Organization models class CreateOrganizationRequest(BaseModel): name: str = Field(..., min_length=2, max_length=200) @@ -669,6 +778,78 @@ def get_run(run_id: int) -> dict[str, Any]: }, } + @app.get("/api/reports/{run_id}") + def get_report(run_id: int) -> dict[str, Any]: + """Structured carbon impact report for a completed deforestation run. + + Recomputes the estimate (with uncertainty bounds) from the stored + pixel count, so it works whether or not carbon was requested at + prediction time. + """ + with get_connection() as conn: + run = conn.execute("SELECT * FROM runs WHERE id = ?", (run_id,)).fetchone() + if run is None: + raise HTTPException(status_code=404, detail="Run not found") + result = conn.execute( + "SELECT * FROM results WHERE run_id = ? ORDER BY id DESC LIMIT 1", (run_id,) + ).fetchone() + + if result is None: + raise HTTPException(status_code=404, detail="No result available for run") + + payload = json.loads(result["payload_json"]) + if payload.get("analysis_type") != "deforestation": + raise HTTPException( + status_code=400, + detail="Impact reports are only available for deforestation runs", + ) + + pixels = _extract_deforested_pixels(payload) + if pixels is None: + raise HTTPException( + status_code=422, detail="Result payload has no pixel count to report on" + ) + + # Reuse the forest_type/region chosen at prediction time so the report + # and the /api/predict response describe the same run. Falling back to + # the estimator defaults keeps reports working for runs saved before + # carbon was requested. + carbon_meta = payload.get("carbon_estimation") or {} + forest_type = carbon_meta.get("forest_type", "tropical_moist") + region = carbon_meta.get("region", "default") + _validate_forest_type(forest_type) + _validate_region(region) + + try: + from climatevision.analytics.carbon import CarbonEstimator + + estimator = CarbonEstimator(forest_type=forest_type, region=region) + estimate = estimator.estimate_from_pixel_count(pixels) + except Exception as exc: + logger.exception("Impact report generation failed for run %s", run_id) + raise HTTPException( + status_code=503, detail="Carbon analytics unavailable" + ) from exc + + region_bbox = json.loads(run["bbox"]) if run["bbox"] else None + + return { + "run_id": run_id, + "hectares_lost": estimate.hectares, + "biomass_tonnes": estimate.biomass_tonnes, + "carbon_tonnes": estimate.carbon_tonnes, + "co2_equivalent": estimate.co2_equivalent, + "confidence_interval": { + "lower": estimate.ci_lower, + "upper": estimate.ci_upper, + "uncertainty_pct": estimate.uncertainty_pct, + "unit": "tCO2e", + }, + "region_bbox": region_bbox, + "forest_type": estimate.forest_type, + "region": estimate.region, + } + # ===== Prediction Endpoints ===== @app.post("/api/predict") @@ -677,6 +858,10 @@ async def predict_json( org: dict[str, Any] = Depends(require_api_key), ) -> dict[str, Any]: """Run prediction using bounding box and date range.""" + if body.enable_carbon: + _validate_forest_type(body.forest_type) + _validate_region(body.region) + created_at = _utc_now_iso() bbox_json = json.dumps(body.bbox) if body.bbox else None @@ -728,6 +913,21 @@ async def predict_json( result_payload["error"] = str(exc) status = "failed" + # Carbon estimation (feature-flagged). Degrades gracefully if the + # analytics module is unavailable or the payload lacks pixel counts. + if ( + body.enable_carbon + and status == "completed" + and body.analysis_type == "deforestation" + ): + carbon = _compute_carbon_estimation( + result_payload, + forest_type=body.forest_type, + region=body.region, + ) + if carbon is not None: + result_payload["carbon_estimation"] = carbon + # Persist result result_created_at = _utc_now_iso() with get_connection() as conn: diff --git a/tests/test_carbon_api.py b/tests/test_carbon_api.py new file mode 100644 index 0000000..d20aa1d --- /dev/null +++ b/tests/test_carbon_api.py @@ -0,0 +1,248 @@ +"""Tests for carbon analytics wiring and the impact report endpoint.""" + +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from climatevision.analytics.carbon import estimate_carbon_loss + +# A 100x100 patch of 10 m pixels = 10,000 px = 100 ha. With tropical_moist +# defaults these values are fully determined: +# hectares = 10000 * (10^2 / 10000) = 100.0 +# agb = 100 * 300.0 * 1.0 = 30000.0 +# bgb = 30000.0 * 0.24 = 7200.0 +# carbon_tonnes = (30000 + 7200) * 0.47 = 17484.0 +# co2_equivalent = 17484.0 * 44 / 12 = 64108.0 +KNOWN_PIXELS = 10_000 +EXPECTED_HECTARES = 100.0 +EXPECTED_CARBON = 17_484.0 +EXPECTED_CO2 = 64_108.0 + + +def _deforestation_payload(pixels: int = KNOWN_PIXELS) -> dict: + return { + "region": {"bbox": [-60.0, -15.0, -45.0, -5.0]}, + "inference": { + "image_size": [256, 256], + "forest_pixels": 55_536, + "non_forest_pixels": pixels, + "forest_percentage": 84.7, + "mean_confidence": 0.91, + }, + "analysis_type": "deforestation", + } + + +def test_carbon_math_matches_known_values() -> None: + """estimate_carbon_loss must reproduce hand-computed IPCC figures.""" + result = estimate_carbon_loss(deforested_pixels=KNOWN_PIXELS) + assert result["hectares"] == EXPECTED_HECTARES + assert result["carbon_tonnes"] == EXPECTED_CARBON + assert result["co2_equivalent"] == EXPECTED_CO2 + + +def test_pixel_count_matches_mask() -> None: + """estimate_from_pixel_count must equal estimate_from_mask without the array.""" + import numpy as np + + from climatevision.analytics.carbon import CarbonEstimator + + estimator = CarbonEstimator() + from_count = estimator.estimate_from_pixel_count(KNOWN_PIXELS) + from_mask = estimator.estimate_from_mask(np.ones(KNOWN_PIXELS, dtype=np.uint8)) + assert from_count == from_mask + + +def test_predict_without_flag_omits_carbon( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """Carbon block is absent unless enable_carbon is set.""" + monkeypatch.setenv("CLIMATEVISION_ALLOW_DEV_KEY", "1") + with patch( + "climatevision.api.main.run_inference_from_gee", + return_value=_deforestation_payload(), + ): + response = client.post( + "/api/predict", + json={ + "bbox": [-60.0, -15.0, -45.0, -5.0], + "analysis_type": "deforestation", + }, + headers={"X-API-Key": "cv_dev"}, + ) + assert response.status_code == 200 + assert "carbon_estimation" not in response.json()["result"] + + +def test_predict_with_flag_includes_carbon( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """enable_carbon adds a carbon_estimation block with correct figures.""" + monkeypatch.setenv("CLIMATEVISION_ALLOW_DEV_KEY", "1") + with patch( + "climatevision.api.main.run_inference_from_gee", + return_value=_deforestation_payload(), + ): + response = client.post( + "/api/predict", + json={ + "bbox": [-60.0, -15.0, -45.0, -5.0], + "analysis_type": "deforestation", + "enable_carbon": True, + }, + headers={"X-API-Key": "cv_dev"}, + ) + assert response.status_code == 200 + carbon = response.json()["result"]["carbon_estimation"] + assert carbon["hectares_lost"] == EXPECTED_HECTARES + assert carbon["carbon_tonnes"] == EXPECTED_CARBON + assert carbon["co2_equivalent"] == EXPECTED_CO2 + + +def test_report_endpoint_returns_impact_report( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """GET /api/reports/{run_id} returns a complete impact report.""" + monkeypatch.setenv("CLIMATEVISION_ALLOW_DEV_KEY", "1") + with patch( + "climatevision.api.main.run_inference_from_gee", + return_value=_deforestation_payload(), + ): + predict = client.post( + "/api/predict", + json={ + "bbox": [-60.0, -15.0, -45.0, -5.0], + "analysis_type": "deforestation", + }, + headers={"X-API-Key": "cv_dev"}, + ) + run_id = predict.json()["run_id"] + + report = client.get(f"/api/reports/{run_id}") + assert report.status_code == 200 + body = report.json() + + assert body["run_id"] == run_id + assert body["hectares_lost"] == EXPECTED_HECTARES + assert body["biomass_tonnes"] > body["carbon_tonnes"] > 0 + assert body["carbon_tonnes"] == EXPECTED_CARBON + assert body["co2_equivalent"] == EXPECTED_CO2 + assert body["region_bbox"] == [-60.0, -15.0, -45.0, -5.0] + + ci = body["confidence_interval"] + assert {"lower", "upper", "uncertainty_pct"} <= ci.keys() + # The Monte Carlo band is computed over CO2-equivalent, so the CO2 point + # estimate must fall within it. + assert ci["lower"] <= body["co2_equivalent"] <= ci["upper"] + + +def test_report_endpoint_unknown_run_returns_404(client: TestClient) -> None: + """A report for a nonexistent run returns 404.""" + response = client.get("/api/reports/999999") + assert response.status_code == 404 + + +def test_report_respects_predict_forest_type( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The report must reuse the forest_type chosen at prediction time. + + Previously the report always assumed tropical_moist, so a mangrove run + produced different carbon numbers in predict vs. report for the same run. + """ + monkeypatch.setenv("CLIMATEVISION_ALLOW_DEV_KEY", "1") + with patch( + "climatevision.api.main.run_inference_from_gee", + return_value=_deforestation_payload(), + ): + predict = client.post( + "/api/predict", + json={ + "bbox": [-60.0, -15.0, -45.0, -5.0], + "analysis_type": "deforestation", + "enable_carbon": True, + "forest_type": "mangrove", + "region": "southeast_asia", + }, + headers={"X-API-Key": "cv_dev"}, + ) + assert predict.status_code == 200 + predict_carbon = predict.json()["result"]["carbon_estimation"] + run_id = predict.json()["run_id"] + + report = client.get(f"/api/reports/{run_id}").json() + + # Report echoes the same forest_type/region and agrees with predict — + # and differs from the old tropical_moist default (17_484.0 t). + assert report["forest_type"] == "mangrove" + assert report["region"] == "southeast_asia" + assert report["carbon_tonnes"] == predict_carbon["carbon_tonnes"] + assert report["carbon_tonnes"] != EXPECTED_CARBON + + +def test_report_confidence_interval_is_labelled_co2e( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The confidence interval must declare its unit (tCO2e), not sit unlabelled.""" + monkeypatch.setenv("CLIMATEVISION_ALLOW_DEV_KEY", "1") + with patch( + "climatevision.api.main.run_inference_from_gee", + return_value=_deforestation_payload(), + ): + predict = client.post( + "/api/predict", + json={ + "bbox": [-60.0, -15.0, -45.0, -5.0], + "analysis_type": "deforestation", + }, + headers={"X-API-Key": "cv_dev"}, + ) + run_id = predict.json()["run_id"] + + ci = client.get(f"/api/reports/{run_id}").json()["confidence_interval"] + assert ci["unit"] == "tCO2e" + + +def test_predict_rejects_unknown_forest_type( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """A typo'd forest_type must 422, not silently fall back to a default.""" + monkeypatch.setenv("CLIMATEVISION_ALLOW_DEV_KEY", "1") + with patch( + "climatevision.api.main.run_inference_from_gee", + return_value=_deforestation_payload(), + ): + response = client.post( + "/api/predict", + json={ + "bbox": [-60.0, -15.0, -45.0, -5.0], + "analysis_type": "deforestation", + "enable_carbon": True, + "forest_type": "tropical-moist", # hyphen typo + }, + headers={"X-API-Key": "cv_dev"}, + ) + assert response.status_code == 422 + + +def test_predict_rejects_unknown_region( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """An unmatched region must 422 rather than silently applying a 1.0 factor.""" + monkeypatch.setenv("CLIMATEVISION_ALLOW_DEV_KEY", "1") + with patch( + "climatevision.api.main.run_inference_from_gee", + return_value=_deforestation_payload(), + ): + response = client.post( + "/api/predict", + json={ + "bbox": [-60.0, -15.0, -45.0, -5.0], + "analysis_type": "deforestation", + "enable_carbon": True, + "region": "Amazon", # wrong case; real key is "amazon" + }, + headers={"X-API-Key": "cv_dev"}, + ) + assert response.status_code == 422