From 4582dc97dcc97f22b534cc36c7c9202f476b901b Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Mon, 20 Jul 2026 14:12:04 +0530 Subject: [PATCH 1/4] Wire carbon analytics into API and add impact report endpoint Deforestation runs can now surface carbon loss estimates. Adds an `enable_carbon` flag to POST /api/predict that, for deforestation analyses, embeds a `carbon_estimation` block (carbon_tonnes, hectares_lost, co2_equivalent) computed from the inference pixel count via the existing analytics.carbon module. Adds GET /api/reports/{run_id} returning a structured impact report (hectares_lost, carbon_tonnes, co2_equivalent, confidence_interval, region_bbox). The report recomputes from the stored pixel count so it works whether or not carbon was requested at predict time. Both paths degrade gracefully if the analytics module is unavailable. Adds tests validating the carbon math against hand-computed IPCC values and covering both new endpoints. Closes #14 --- src/climatevision/api/main.py | 140 ++++++++++++++++++++++++++++++++++ tests/test_carbon_api.py | 130 +++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 tests/test_carbon_api.py diff --git a/src/climatevision/api/main.py b/src/climatevision/api/main.py index 590c123..b5db830 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,58 @@ 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"], + } + + # Organization models class CreateOrganizationRequest(BaseModel): name: str = Field(..., min_length=2, max_length=200) @@ -669,6 +733,67 @@ 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" + ) + + try: + import numpy as np + from climatevision.analytics.carbon import CarbonEstimator + + estimator = CarbonEstimator() + estimate = estimator.estimate_from_mask(np.ones(pixels, dtype=np.uint8)) + 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, + "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, + }, + "region_bbox": region_bbox, + "forest_type": estimate.forest_type, + "region": estimate.region, + } + # ===== Prediction Endpoints ===== @app.post("/api/predict") @@ -728,6 +853,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..8a845bc --- /dev/null +++ b/tests/test_carbon_api.py @@ -0,0 +1,130 @@ +"""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_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["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 From 62f25814e1d8927564e56e8fc6d0a1270c3a9c18 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Mon, 20 Jul 2026 19:14:57 +0530 Subject: [PATCH 2/4] Address review: consistent forest_type, labelled CI, strict validation - /api/reports now reuses the forest_type and region chosen at predict time (persisted in the carbon_estimation payload) instead of always assuming tropical_moist, so predict and report agree for a given run. - Label the confidence interval as tCO2e so it can't be misread as a band around carbon_tonnes (off by the 44/12 factor). - Reject an unknown forest_type with 422 instead of silently falling back to a generic biomass density and returning wrong numbers with 200. - Add tests for report/predict consistency, the CI unit, and the 422. --- src/climatevision/api/main.py | 38 +++++++++++++++- tests/test_carbon_api.py | 83 +++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/climatevision/api/main.py b/src/climatevision/api/main.py index b5db830..07df361 100644 --- a/src/climatevision/api/main.py +++ b/src/climatevision/api/main.py @@ -246,9 +246,32 @@ def _compute_carbon_estimation( "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)}" + ), + ) + + # Organization models class CreateOrganizationRequest(BaseModel): name: str = Field(..., min_length=2, max_length=200) @@ -765,11 +788,20 @@ def get_report(run_id: int) -> dict[str, Any]: 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) + try: import numpy as np from climatevision.analytics.carbon import CarbonEstimator - estimator = CarbonEstimator() + estimator = CarbonEstimator(forest_type=forest_type, region=region) estimate = estimator.estimate_from_mask(np.ones(pixels, dtype=np.uint8)) except Exception as exc: logger.exception("Impact report generation failed for run %s", run_id) @@ -788,6 +820,7 @@ def get_report(run_id: int) -> dict[str, Any]: "lower": estimate.ci_lower, "upper": estimate.ci_upper, "uncertainty_pct": estimate.uncertainty_pct, + "unit": "tCO2e", }, "region_bbox": region_bbox, "forest_type": estimate.forest_type, @@ -802,6 +835,9 @@ 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) + created_at = _utc_now_iso() bbox_json = json.dumps(body.bbox) if body.bbox else None diff --git a/tests/test_carbon_api.py b/tests/test_carbon_api.py index 8a845bc..b32e619 100644 --- a/tests/test_carbon_api.py +++ b/tests/test_carbon_api.py @@ -128,3 +128,86 @@ 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 From b648bfa3782a17a1112e2826e69d470cb729b79d Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Mon, 20 Jul 2026 19:19:56 +0530 Subject: [PATCH 3/4] Report from pixel count directly and expose biomass - Add CarbonEstimator.estimate_from_pixel_count so the impact report no longer allocates an np.ones(pixels) array just to sum it back; a real tile can be tens of millions of pixels. estimate_from_mask now delegates to it, so the two paths stay identical. - Surface the already-computed biomass_tonnes in the report response. --- src/climatevision/analytics/carbon.py | 25 ++++++++++++++++++++++++- src/climatevision/api/main.py | 4 ++-- tests/test_carbon_api.py | 13 +++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) 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 07df361..efe5aca 100644 --- a/src/climatevision/api/main.py +++ b/src/climatevision/api/main.py @@ -798,11 +798,10 @@ def get_report(run_id: int) -> dict[str, Any]: _validate_forest_type(forest_type) try: - import numpy as np from climatevision.analytics.carbon import CarbonEstimator estimator = CarbonEstimator(forest_type=forest_type, region=region) - estimate = estimator.estimate_from_mask(np.ones(pixels, dtype=np.uint8)) + estimate = estimator.estimate_from_pixel_count(pixels) except Exception as exc: logger.exception("Impact report generation failed for run %s", run_id) raise HTTPException( @@ -814,6 +813,7 @@ def get_report(run_id: int) -> dict[str, Any]: 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": { diff --git a/tests/test_carbon_api.py b/tests/test_carbon_api.py index b32e619..d56f111 100644 --- a/tests/test_carbon_api.py +++ b/tests/test_carbon_api.py @@ -42,6 +42,18 @@ def test_carbon_math_matches_known_values() -> None: 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: @@ -113,6 +125,7 @@ def test_report_endpoint_returns_impact_report( 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] From 8a6df3f8af23ec14e57d4e49c28d522a047abfea Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Mon, 20 Jul 2026 20:55:31 +0530 Subject: [PATCH 4/4] Reject unknown region with 422, symmetric with forest_type An unmatched region (wrong case, trailing space) previously fell back to a 1.0 adjustment factor and returned a confident but wrong figure. Since these numbers are meant to be cited, validate region against the known keys and fail loudly at both /api/predict and /api/reports, mirroring the existing forest_type guard. --- src/climatevision/api/main.py | 24 ++++++++++++++++++++++++ tests/test_carbon_api.py | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/climatevision/api/main.py b/src/climatevision/api/main.py index efe5aca..23d12e1 100644 --- a/src/climatevision/api/main.py +++ b/src/climatevision/api/main.py @@ -272,6 +272,28 @@ def _validate_forest_type(forest_type: Optional[str]) -> None: ) +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) @@ -796,6 +818,7 @@ def get_report(run_id: int) -> dict[str, Any]: 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 @@ -837,6 +860,7 @@ async def predict_json( """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 diff --git a/tests/test_carbon_api.py b/tests/test_carbon_api.py index d56f111..d20aa1d 100644 --- a/tests/test_carbon_api.py +++ b/tests/test_carbon_api.py @@ -224,3 +224,25 @@ def test_predict_rejects_unknown_forest_type( 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