KSvend Claude Sonnet 4.6 commited on
Commit
1e77056
·
1 Parent(s): a2116ac

feat: add baseline range (min/mean/max) to vegetation and cropland chart data

Browse files

Track per-year means during the baseline STAC loop and expose them as
baseline_range_min/mean/max scalars in _build_chart_data when 2+ years
are available. Adds test_indicator_vegetation.py and extends
test_indicator_cropland.py with range assertions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

app/indicators/cropland.py CHANGED
@@ -45,7 +45,7 @@ class CroplandIndicator(BaseIndicator):
45
  else ConfidenceLevel.LOW
46
  )
47
 
48
- chart_data = self._build_chart_data(baseline_mean, current_mean, time_range)
49
 
50
  if abs_change <= 5:
51
  headline = f"Cropland vegetation stable ({current_mean:.0f}% cover, ±{abs_change:.0f}pp vs baseline)"
@@ -219,10 +219,16 @@ class CroplandIndicator(BaseIndicator):
219
 
220
  # Baseline: pool all years by month, then median per month
221
  baseline_pool: dict[int, list[float]] = defaultdict(list)
 
222
  for yr in range(baseline_start_year, current_year):
223
  yr_monthly = await loop.run_in_executor(None, _query_growing_season, yr)
 
224
  for month, vals in yr_monthly.items():
225
  baseline_pool[month].extend(vals)
 
 
 
 
226
 
227
  # Month-matched comparison: only growing-season months with data in BOTH periods
228
  baseline_medians = []
@@ -239,6 +245,7 @@ class CroplandIndicator(BaseIndicator):
239
  self._is_placeholder = True
240
  return self._synthetic()
241
 
 
242
  return (
243
  float(np.mean(baseline_medians)),
244
  float(np.mean(current_medians)),
@@ -271,10 +278,16 @@ class CroplandIndicator(BaseIndicator):
271
 
272
  @staticmethod
273
  def _build_chart_data(
274
- baseline: float, current: float, time_range: TimeRange
 
275
  ) -> dict[str, Any]:
276
- return {
277
  "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
278
  "values": [round(baseline, 1), round(current, 1)],
279
  "label": "Vegetation cover (%)",
280
  }
 
 
 
 
 
 
45
  else ConfidenceLevel.LOW
46
  )
47
 
48
+ chart_data = self._build_chart_data(baseline_mean, current_mean, time_range, getattr(self, '_baseline_yearly_means', None))
49
 
50
  if abs_change <= 5:
51
  headline = f"Cropland vegetation stable ({current_mean:.0f}% cover, ±{abs_change:.0f}pp vs baseline)"
 
219
 
220
  # Baseline: pool all years by month, then median per month
221
  baseline_pool: dict[int, list[float]] = defaultdict(list)
222
+ baseline_yearly_means: list[float] = []
223
  for yr in range(baseline_start_year, current_year):
224
  yr_monthly = await loop.run_in_executor(None, _query_growing_season, yr)
225
+ yr_medians = []
226
  for month, vals in yr_monthly.items():
227
  baseline_pool[month].extend(vals)
228
+ if vals:
229
+ yr_medians.append(float(np.median(vals)))
230
+ if yr_medians:
231
+ baseline_yearly_means.append(float(np.mean(yr_medians)))
232
 
233
  # Month-matched comparison: only growing-season months with data in BOTH periods
234
  baseline_medians = []
 
245
  self._is_placeholder = True
246
  return self._synthetic()
247
 
248
+ self._baseline_yearly_means = baseline_yearly_means
249
  return (
250
  float(np.mean(baseline_medians)),
251
  float(np.mean(current_medians)),
 
278
 
279
  @staticmethod
280
  def _build_chart_data(
281
+ baseline: float, current: float, time_range: TimeRange,
282
+ baseline_yearly_means: list[float] | None = None,
283
  ) -> dict[str, Any]:
284
+ data: dict[str, Any] = {
285
  "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
286
  "values": [round(baseline, 1), round(current, 1)],
287
  "label": "Vegetation cover (%)",
288
  }
289
+ if baseline_yearly_means and len(baseline_yearly_means) >= 2:
290
+ data["baseline_range_mean"] = round(float(np.mean(baseline_yearly_means)), 1)
291
+ data["baseline_range_min"] = round(float(min(baseline_yearly_means)), 1)
292
+ data["baseline_range_max"] = round(float(max(baseline_yearly_means)), 1)
293
+ return data
app/indicators/vegetation.py CHANGED
@@ -40,7 +40,7 @@ class VegetationIndicator(BaseIndicator):
40
  else ConfidenceLevel.MODERATE if n_months >= 3
41
  else ConfidenceLevel.LOW
42
  )
43
- chart_data = self._build_chart_data(baseline_mean, current_mean, time_range)
44
 
45
  if abs_change <= 5:
46
  headline = f"Vegetation cover stable ({current_mean:.0f}% cover, ±{abs_change:.0f}pp vs baseline)"
@@ -200,10 +200,16 @@ class VegetationIndicator(BaseIndicator):
200
  current_monthly = await loop.run_in_executor(None, _query_monthly, current_year)
201
 
202
  baseline_pool: dict[int, list[float]] = defaultdict(list)
 
203
  for yr in range(baseline_start_year, current_year):
204
  yr_monthly = await loop.run_in_executor(None, _query_monthly, yr)
 
205
  for month, vals in yr_monthly.items():
206
  baseline_pool[month].extend(vals)
 
 
 
 
207
 
208
  baseline_medians = []
209
  current_medians = []
@@ -219,6 +225,7 @@ class VegetationIndicator(BaseIndicator):
219
  self._is_placeholder = True
220
  return self._synthetic()
221
 
 
222
  return (
223
  float(np.mean(baseline_medians)),
224
  float(np.mean(current_medians)),
@@ -250,10 +257,16 @@ class VegetationIndicator(BaseIndicator):
250
 
251
  @staticmethod
252
  def _build_chart_data(
253
- baseline: float, current: float, time_range: TimeRange
 
254
  ) -> dict[str, Any]:
255
- return {
256
  "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
257
  "values": [round(baseline, 1), round(current, 1)],
258
  "label": "Vegetation cover (%)",
259
  }
 
 
 
 
 
 
40
  else ConfidenceLevel.MODERATE if n_months >= 3
41
  else ConfidenceLevel.LOW
42
  )
43
+ chart_data = self._build_chart_data(baseline_mean, current_mean, time_range, getattr(self, '_baseline_yearly_means', None))
44
 
45
  if abs_change <= 5:
46
  headline = f"Vegetation cover stable ({current_mean:.0f}% cover, ±{abs_change:.0f}pp vs baseline)"
 
200
  current_monthly = await loop.run_in_executor(None, _query_monthly, current_year)
201
 
202
  baseline_pool: dict[int, list[float]] = defaultdict(list)
203
+ baseline_yearly_means: list[float] = []
204
  for yr in range(baseline_start_year, current_year):
205
  yr_monthly = await loop.run_in_executor(None, _query_monthly, yr)
206
+ yr_medians = []
207
  for month, vals in yr_monthly.items():
208
  baseline_pool[month].extend(vals)
209
+ if vals:
210
+ yr_medians.append(float(np.median(vals)))
211
+ if yr_medians:
212
+ baseline_yearly_means.append(float(np.mean(yr_medians)))
213
 
214
  baseline_medians = []
215
  current_medians = []
 
225
  self._is_placeholder = True
226
  return self._synthetic()
227
 
228
+ self._baseline_yearly_means = baseline_yearly_means
229
  return (
230
  float(np.mean(baseline_medians)),
231
  float(np.mean(current_medians)),
 
257
 
258
  @staticmethod
259
  def _build_chart_data(
260
+ baseline: float, current: float, time_range: TimeRange,
261
+ baseline_yearly_means: list[float] | None = None,
262
  ) -> dict[str, Any]:
263
+ data: dict[str, Any] = {
264
  "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
265
  "values": [round(baseline, 1), round(current, 1)],
266
  "label": "Vegetation cover (%)",
267
  }
268
+ if baseline_yearly_means and len(baseline_yearly_means) >= 2:
269
+ data["baseline_range_mean"] = round(float(np.mean(baseline_yearly_means)), 1)
270
+ data["baseline_range_min"] = round(float(min(baseline_yearly_means)), 1)
271
+ data["baseline_range_max"] = round(float(max(baseline_yearly_means)), 1)
272
+ return data
tests/test_indicator_cropland.py CHANGED
@@ -169,3 +169,48 @@ def test_classify_boundary():
169
  assert ind._classify(-5.1) == StatusLevel.AMBER
170
  assert ind._classify(-15) == StatusLevel.AMBER # boundary
171
  assert ind._classify(-15.1) == StatusLevel.RED
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  assert ind._classify(-5.1) == StatusLevel.AMBER
170
  assert ind._classify(-15) == StatusLevel.AMBER # boundary
171
  assert ind._classify(-15.1) == StatusLevel.RED
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Baseline range in chart data
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def test_build_chart_data_includes_baseline_range():
179
+ from app.indicators.cropland import CroplandIndicator
180
+ from datetime import date
181
+ from app.models import TimeRange
182
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
183
+ result = CroplandIndicator._build_chart_data(
184
+ baseline=40.0, current=42.0, time_range=tr,
185
+ baseline_yearly_means=[38.0, 40.0, 42.0],
186
+ )
187
+ assert "baseline_range_mean" in result
188
+ assert "baseline_range_min" in result
189
+ assert "baseline_range_max" in result
190
+ assert result["baseline_range_min"] == 38.0
191
+ assert result["baseline_range_max"] == 42.0
192
+
193
+
194
+ def test_build_chart_data_no_baseline_range_when_absent():
195
+ from app.indicators.cropland import CroplandIndicator
196
+ from datetime import date
197
+ from app.models import TimeRange
198
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
199
+ result = CroplandIndicator._build_chart_data(
200
+ baseline=40.0, current=42.0, time_range=tr,
201
+ )
202
+ assert "baseline_range_mean" not in result
203
+ assert "baseline_range_min" not in result
204
+ assert "baseline_range_max" not in result
205
+
206
+
207
+ def test_build_chart_data_no_baseline_range_when_single_year():
208
+ from app.indicators.cropland import CroplandIndicator
209
+ from datetime import date
210
+ from app.models import TimeRange
211
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
212
+ result = CroplandIndicator._build_chart_data(
213
+ baseline=40.0, current=42.0, time_range=tr,
214
+ baseline_yearly_means=[40.0],
215
+ )
216
+ assert "baseline_range_mean" not in result
tests/test_indicator_vegetation.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the D2 Vegetation & Forest Cover indicator."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import date
5
+
6
+ import pytest
7
+
8
+ from app.indicators.vegetation import VegetationIndicator
9
+ from app.models import TimeRange
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Baseline range in chart data
14
+ # ---------------------------------------------------------------------------
15
+
16
+ def test_build_chart_data_includes_baseline_range():
17
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
18
+ result = VegetationIndicator._build_chart_data(
19
+ baseline=35.0, current=38.0, time_range=tr,
20
+ baseline_yearly_means=[32.0, 35.0, 38.0, 34.0, 36.0],
21
+ )
22
+ assert "baseline_range_mean" in result
23
+ assert "baseline_range_min" in result
24
+ assert "baseline_range_max" in result
25
+ assert result["baseline_range_min"] == 32.0
26
+ assert result["baseline_range_max"] == 38.0
27
+ assert result["baseline_range_min"] <= result["baseline_range_mean"] <= result["baseline_range_max"]
28
+
29
+
30
+ def test_build_chart_data_no_baseline_range_when_absent():
31
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
32
+ result = VegetationIndicator._build_chart_data(
33
+ baseline=35.0, current=38.0, time_range=tr,
34
+ )
35
+ assert "baseline_range_mean" not in result
36
+ assert "baseline_range_min" not in result
37
+ assert "baseline_range_max" not in result
38
+
39
+
40
+ def test_build_chart_data_no_baseline_range_when_single_year():
41
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
42
+ result = VegetationIndicator._build_chart_data(
43
+ baseline=35.0, current=38.0, time_range=tr,
44
+ baseline_yearly_means=[35.0],
45
+ )
46
+ assert "baseline_range_mean" not in result
47
+
48
+
49
+ def test_build_chart_data_baseline_range_mean_is_rounded():
50
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
51
+ result = VegetationIndicator._build_chart_data(
52
+ baseline=35.0, current=38.0, time_range=tr,
53
+ baseline_yearly_means=[33.33, 36.67],
54
+ )
55
+ assert isinstance(result["baseline_range_mean"], float)
56
+ # Should be rounded to 1 decimal place
57
+ assert result["baseline_range_mean"] == round(result["baseline_range_mean"], 1)
58
+
59
+
60
+ def test_build_chart_data_base_fields_always_present():
61
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
62
+ result = VegetationIndicator._build_chart_data(
63
+ baseline=35.0, current=38.0, time_range=tr,
64
+ )
65
+ assert "dates" in result
66
+ assert "values" in result
67
+ assert "label" in result
68
+ assert result["values"] == [35.0, 38.0]