Skip to content

Commit 024b9db

Browse files
authored
Wire carbon analytics into API and add impact report endpoint (#124)
* 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 * 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. * 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. * 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.
1 parent 880bff5 commit 024b9db

3 files changed

Lines changed: 472 additions & 1 deletion

File tree

‎src/climatevision/analytics/carbon.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,30 @@ def estimate_from_mask(
109109
Returns:
110110
CarbonEstimate with carbon loss and uncertainty bounds
111111
"""
112-
deforested_pixels = int(deforestation_mask.sum())
112+
return self.estimate_from_pixel_count(
113+
int(deforestation_mask.sum()), confidence_map
114+
)
115+
116+
def estimate_from_pixel_count(
117+
self,
118+
deforested_pixels: int,
119+
confidence_map: Optional[np.ndarray] = None
120+
) -> CarbonEstimate:
121+
"""
122+
Estimate carbon loss directly from a deforested-pixel count.
123+
124+
Equivalent to :meth:`estimate_from_mask` for a mask containing this
125+
many positive pixels, but without materialising the mask array — a
126+
real tile can be tens of millions of pixels.
127+
128+
Args:
129+
deforested_pixels: Number of deforested pixels
130+
confidence_map: Optional confidence scores per pixel
131+
132+
Returns:
133+
CarbonEstimate with carbon loss and uncertainty bounds
134+
"""
135+
deforested_pixels = int(deforested_pixels)
113136
pixel_area_ha = (self.pixel_size_m ** 2) / 10000
114137
hectares = deforested_pixels * pixel_area_ha
115138

‎src/climatevision/api/main.py‎

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ class PredictRequest(BaseModel):
133133
default=None,
134134
description="End date in YYYY-MM-DD format. Must be later than start_date.",
135135
)
136+
enable_carbon: bool = Field(
137+
default=False,
138+
description="If true, include carbon loss estimates for deforestation runs.",
139+
)
140+
forest_type: Optional[str] = Field(
141+
default=None,
142+
description="Forest type for carbon estimation (e.g. tropical_moist, mangrove).",
143+
)
144+
region: Optional[str] = Field(
145+
default=None,
146+
description="Region for carbon estimation adjustment (e.g. amazon, congo).",
147+
)
136148

137149
@field_validator("bbox")
138150
@classmethod
@@ -185,6 +197,103 @@ class ResultRow(BaseModel):
185197
created_at: str
186198

187199

200+
# ===== Carbon analytics helpers =====
201+
202+
203+
def _extract_deforested_pixels(payload: dict[str, Any]) -> Optional[int]:
204+
"""Pull the deforested-pixel count from a deforestation result payload.
205+
206+
Returns None when the payload does not carry an inference pixel count.
207+
"""
208+
inference = payload.get("inference")
209+
if not isinstance(inference, dict):
210+
return None
211+
pixels = inference.get("non_forest_pixels")
212+
if pixels is None:
213+
return None
214+
try:
215+
return int(pixels)
216+
except (TypeError, ValueError):
217+
return None
218+
219+
220+
def _compute_carbon_estimation(
221+
payload: dict[str, Any],
222+
forest_type: Optional[str] = None,
223+
region: Optional[str] = None,
224+
) -> Optional[dict[str, float]]:
225+
"""Deterministic carbon loss estimate for embedding in a predict response.
226+
227+
Returns None if the carbon module is unavailable or the payload lacks a
228+
pixel count, so the caller can degrade gracefully.
229+
"""
230+
pixels = _extract_deforested_pixels(payload)
231+
if pixels is None:
232+
return None
233+
try:
234+
from climatevision.analytics.carbon import estimate_carbon_loss
235+
236+
estimate = estimate_carbon_loss(
237+
deforested_pixels=pixels,
238+
forest_type=forest_type or "tropical_moist",
239+
region=region or "default",
240+
)
241+
except Exception:
242+
logger.exception("Carbon estimation failed")
243+
return None
244+
245+
return {
246+
"carbon_tonnes": estimate["carbon_tonnes"],
247+
"hectares_lost": estimate["hectares"],
248+
"co2_equivalent": estimate["co2_equivalent"],
249+
"forest_type": forest_type or "tropical_moist",
250+
"region": region or "default",
251+
}
252+
253+
254+
def _validate_forest_type(forest_type: Optional[str]) -> None:
255+
"""Reject an unknown forest_type instead of silently defaulting.
256+
257+
A typo such as ``tropical-moist`` (hyphen) would otherwise fall through
258+
to a generic biomass density and return plausible-looking but wrong
259+
numbers with a 200 OK.
260+
"""
261+
if forest_type is None:
262+
return
263+
from climatevision.analytics.carbon import AGB_DENSITY
264+
265+
if forest_type not in AGB_DENSITY:
266+
raise HTTPException(
267+
status_code=422,
268+
detail=(
269+
f"Unknown forest_type '{forest_type}'. "
270+
f"Valid values: {sorted(AGB_DENSITY)}"
271+
),
272+
)
273+
274+
275+
def _validate_region(region: Optional[str]) -> None:
276+
"""Reject an unknown region instead of silently applying no adjustment.
277+
278+
An unmatched region (e.g. ``Amazon`` capitalised, or ``amazon `` with a
279+
trailing space) would otherwise fall back to a 1.0 factor and return a
280+
confident-looking number that is wrong by the real regional adjustment.
281+
Since these figures are meant to be cited, fail loudly instead.
282+
"""
283+
if region is None:
284+
return
285+
from climatevision.analytics.carbon import REGIONAL_FACTORS
286+
287+
if region not in REGIONAL_FACTORS:
288+
raise HTTPException(
289+
status_code=422,
290+
detail=(
291+
f"Unknown region '{region}'. "
292+
f"Valid values: {sorted(REGIONAL_FACTORS)}"
293+
),
294+
)
295+
296+
188297
# Organization models
189298
class CreateOrganizationRequest(BaseModel):
190299
name: str = Field(..., min_length=2, max_length=200)
@@ -669,6 +778,78 @@ def get_run(run_id: int) -> dict[str, Any]:
669778
},
670779
}
671780

781+
@app.get("/api/reports/{run_id}")
782+
def get_report(run_id: int) -> dict[str, Any]:
783+
"""Structured carbon impact report for a completed deforestation run.
784+
785+
Recomputes the estimate (with uncertainty bounds) from the stored
786+
pixel count, so it works whether or not carbon was requested at
787+
prediction time.
788+
"""
789+
with get_connection() as conn:
790+
run = conn.execute("SELECT * FROM runs WHERE id = ?", (run_id,)).fetchone()
791+
if run is None:
792+
raise HTTPException(status_code=404, detail="Run not found")
793+
result = conn.execute(
794+
"SELECT * FROM results WHERE run_id = ? ORDER BY id DESC LIMIT 1", (run_id,)
795+
).fetchone()
796+
797+
if result is None:
798+
raise HTTPException(status_code=404, detail="No result available for run")
799+
800+
payload = json.loads(result["payload_json"])
801+
if payload.get("analysis_type") != "deforestation":
802+
raise HTTPException(
803+
status_code=400,
804+
detail="Impact reports are only available for deforestation runs",
805+
)
806+
807+
pixels = _extract_deforested_pixels(payload)
808+
if pixels is None:
809+
raise HTTPException(
810+
status_code=422, detail="Result payload has no pixel count to report on"
811+
)
812+
813+
# Reuse the forest_type/region chosen at prediction time so the report
814+
# and the /api/predict response describe the same run. Falling back to
815+
# the estimator defaults keeps reports working for runs saved before
816+
# carbon was requested.
817+
carbon_meta = payload.get("carbon_estimation") or {}
818+
forest_type = carbon_meta.get("forest_type", "tropical_moist")
819+
region = carbon_meta.get("region", "default")
820+
_validate_forest_type(forest_type)
821+
_validate_region(region)
822+
823+
try:
824+
from climatevision.analytics.carbon import CarbonEstimator
825+
826+
estimator = CarbonEstimator(forest_type=forest_type, region=region)
827+
estimate = estimator.estimate_from_pixel_count(pixels)
828+
except Exception as exc:
829+
logger.exception("Impact report generation failed for run %s", run_id)
830+
raise HTTPException(
831+
status_code=503, detail="Carbon analytics unavailable"
832+
) from exc
833+
834+
region_bbox = json.loads(run["bbox"]) if run["bbox"] else None
835+
836+
return {
837+
"run_id": run_id,
838+
"hectares_lost": estimate.hectares,
839+
"biomass_tonnes": estimate.biomass_tonnes,
840+
"carbon_tonnes": estimate.carbon_tonnes,
841+
"co2_equivalent": estimate.co2_equivalent,
842+
"confidence_interval": {
843+
"lower": estimate.ci_lower,
844+
"upper": estimate.ci_upper,
845+
"uncertainty_pct": estimate.uncertainty_pct,
846+
"unit": "tCO2e",
847+
},
848+
"region_bbox": region_bbox,
849+
"forest_type": estimate.forest_type,
850+
"region": estimate.region,
851+
}
852+
672853
# ===== Prediction Endpoints =====
673854

674855
@app.post("/api/predict")
@@ -677,6 +858,10 @@ async def predict_json(
677858
org: dict[str, Any] = Depends(require_api_key),
678859
) -> dict[str, Any]:
679860
"""Run prediction using bounding box and date range."""
861+
if body.enable_carbon:
862+
_validate_forest_type(body.forest_type)
863+
_validate_region(body.region)
864+
680865
created_at = _utc_now_iso()
681866
bbox_json = json.dumps(body.bbox) if body.bbox else None
682867

@@ -728,6 +913,21 @@ async def predict_json(
728913
result_payload["error"] = str(exc)
729914
status = "failed"
730915

916+
# Carbon estimation (feature-flagged). Degrades gracefully if the
917+
# analytics module is unavailable or the payload lacks pixel counts.
918+
if (
919+
body.enable_carbon
920+
and status == "completed"
921+
and body.analysis_type == "deforestation"
922+
):
923+
carbon = _compute_carbon_estimation(
924+
result_payload,
925+
forest_type=body.forest_type,
926+
region=body.region,
927+
)
928+
if carbon is not None:
929+
result_payload["carbon_estimation"] = carbon
930+
731931
# Persist result
732932
result_created_at = _utc_now_iso()
733933
with get_connection() as conn:

0 commit comments

Comments
 (0)