KSvend Claude Happy commited on
Commit
7d3e78e
·
2 Parent(s): 71d4554a392efe

feat: Phase 1 — basemap fix & baseline comparison charts

Browse files

- Bundle 50m Natural Earth shapefiles in Docker for offline basemaps
- Update map renderer to use 50m scale features (sharper coastlines)
- Add dual baseline overlay to charts: monthly band+line for rainfall,
horizontal reference band for 2-point indicators
- Add baseline range data to all 7 applicable indicators

109 tests passing.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

Dockerfile CHANGED
@@ -16,6 +16,12 @@ RUN pip install --no-cache-dir --only-binary :all: \
16
  numpy scipy matplotlib geopandas shapely pyproj rioxarray xarray \
17
  && pip install --no-cache-dir --prefer-binary cartopy
18
 
 
 
 
 
 
 
19
  # Install remaining deps (lightweight, pure-python or small wheels)
20
  RUN pip install --no-cache-dir --prefer-binary \
21
  "fastapi>=0.110.0" \
@@ -43,6 +49,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
43
  # Copy installed packages from builder
44
  COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
45
  COPY --from=builder /usr/local/bin /usr/local/bin
 
46
 
47
  WORKDIR /app
48
 
 
16
  numpy scipy matplotlib geopandas shapely pyproj rioxarray xarray \
17
  && pip install --no-cache-dir --prefer-binary cartopy
18
 
19
+ # Pre-download 50m Natural Earth data so Cartopy works offline in containers
20
+ RUN python -c "\
21
+ import cartopy.io.shapereader as shpreader; \
22
+ [shpreader.natural_earth(resolution='50m', category=cat, name=name) \
23
+ for cat, name in [('physical','land'),('physical','ocean'),('physical','coastline'),('cultural','admin_0_boundary_lines_lake')]]"
24
+
25
  # Install remaining deps (lightweight, pure-python or small wheels)
26
  RUN pip install --no-cache-dir --prefer-binary \
27
  "fastapi>=0.110.0" \
 
49
  # Copy installed packages from builder
50
  COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
51
  COPY --from=builder /usr/local/bin /usr/local/bin
52
+ COPY --from=builder /root/.local/share/cartopy /root/.local/share/cartopy
53
 
54
  WORKDIR /app
55
 
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/lst.py CHANGED
@@ -45,7 +45,7 @@ class LSTIndicator(BaseIndicator):
45
  status = self._classify(abs(z_score))
46
  trend = self._compute_trend(z_score)
47
  confidence = ConfidenceLevel.MODERATE
48
- chart_data = self._build_chart_data(current_temp, baseline_mean, baseline_std, time_range)
49
 
50
  direction = "above" if z_score >= 0 else "below"
51
  abs_z = abs(z_score)
@@ -160,6 +160,7 @@ class LSTIndicator(BaseIndicator):
160
  self._is_placeholder = True
161
  return self._synthetic_lst()
162
 
 
163
  return (
164
  float(np.mean(current_vals)),
165
  float(np.mean(baseline_yearly_means)),
@@ -232,10 +233,16 @@ class LSTIndicator(BaseIndicator):
232
  baseline_mean: float,
233
  baseline_std: float,
234
  time_range: TimeRange,
 
235
  ) -> dict[str, Any]:
236
- return {
237
  "dates": ["baseline", str(time_range.end.year)],
238
  "values": [round(baseline_mean, 1), round(current, 1)],
239
  "baseline_std": round(baseline_std, 1),
240
  "label": "Daily max temperature (°C)",
241
  }
 
 
 
 
 
 
45
  status = self._classify(abs(z_score))
46
  trend = self._compute_trend(z_score)
47
  confidence = ConfidenceLevel.MODERATE
48
+ chart_data = self._build_chart_data(current_temp, baseline_mean, baseline_std, time_range, getattr(self, '_baseline_yearly_means', None))
49
 
50
  direction = "above" if z_score >= 0 else "below"
51
  abs_z = abs(z_score)
 
160
  self._is_placeholder = True
161
  return self._synthetic_lst()
162
 
163
+ self._baseline_yearly_means = baseline_yearly_means
164
  return (
165
  float(np.mean(current_vals)),
166
  float(np.mean(baseline_yearly_means)),
 
233
  baseline_mean: float,
234
  baseline_std: float,
235
  time_range: TimeRange,
236
+ baseline_yearly_means: list[float] | None = None,
237
  ) -> dict[str, Any]:
238
+ result: dict[str, Any] = {
239
  "dates": ["baseline", str(time_range.end.year)],
240
  "values": [round(baseline_mean, 1), round(current, 1)],
241
  "baseline_std": round(baseline_std, 1),
242
  "label": "Daily max temperature (°C)",
243
  }
244
+ if baseline_yearly_means and len(baseline_yearly_means) >= 2:
245
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_means)), 1)
246
+ result["baseline_range_min"] = round(float(min(baseline_yearly_means)), 1)
247
+ result["baseline_range_max"] = round(float(max(baseline_yearly_means)), 1)
248
+ return result
app/indicators/nightlights.py CHANGED
@@ -52,7 +52,7 @@ class NightlightsIndicator(BaseIndicator):
52
  status = self._classify(ratio)
53
  trend = self._compute_trend(ratio)
54
  confidence = self._compute_confidence(spatial)
55
- chart_data = self._build_chart_data(current_radiance, baseline_radiance, time_range)
56
 
57
  if ratio >= 0.9:
58
  headline = f"Nighttime light intensity at {pct:.0f}% of baseline — normal activity"
@@ -213,6 +213,7 @@ class NightlightsIndicator(BaseIndicator):
213
  return None
214
 
215
  baseline_mean = float(np.mean(baseline_vals))
 
216
 
217
  # Build spatial data from whichever year we read
218
  spatial = None
@@ -338,6 +339,7 @@ class NightlightsIndicator(BaseIndicator):
338
  return None
339
 
340
  baseline_mean = float(np.mean(baseline_vals))
 
341
  # NASA source provides scalar means only (no 2D spatial data due to sinusoidal tiling)
342
  return current_mean, baseline_mean, None
343
 
@@ -379,10 +381,18 @@ class NightlightsIndicator(BaseIndicator):
379
 
380
  @staticmethod
381
  def _build_chart_data(
382
- current: float, baseline: float, time_range: TimeRange
 
 
 
383
  ) -> dict[str, Any]:
384
- return {
385
  "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
386
  "values": [round(baseline, 4), round(current, 4)],
387
  "label": "Mean VIIRS DNB radiance (nW·cm⁻²·sr⁻¹)",
388
  }
 
 
 
 
 
 
52
  status = self._classify(ratio)
53
  trend = self._compute_trend(ratio)
54
  confidence = self._compute_confidence(spatial)
55
+ chart_data = self._build_chart_data(current_radiance, baseline_radiance, time_range, getattr(self, '_baseline_yearly_vals', None))
56
 
57
  if ratio >= 0.9:
58
  headline = f"Nighttime light intensity at {pct:.0f}% of baseline — normal activity"
 
213
  return None
214
 
215
  baseline_mean = float(np.mean(baseline_vals))
216
+ self._baseline_yearly_vals = baseline_vals
217
 
218
  # Build spatial data from whichever year we read
219
  spatial = None
 
339
  return None
340
 
341
  baseline_mean = float(np.mean(baseline_vals))
342
+ self._baseline_yearly_vals = baseline_vals
343
  # NASA source provides scalar means only (no 2D spatial data due to sinusoidal tiling)
344
  return current_mean, baseline_mean, None
345
 
 
381
 
382
  @staticmethod
383
  def _build_chart_data(
384
+ current: float,
385
+ baseline: float,
386
+ time_range: TimeRange,
387
+ baseline_yearly_vals: list[float] | None = None,
388
  ) -> dict[str, Any]:
389
+ result: dict[str, Any] = {
390
  "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
391
  "values": [round(baseline, 4), round(current, 4)],
392
  "label": "Mean VIIRS DNB radiance (nW·cm⁻²·sr⁻¹)",
393
  }
394
+ if baseline_yearly_vals and len(baseline_yearly_vals) >= 2:
395
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_vals)), 4)
396
+ result["baseline_range_min"] = round(float(min(baseline_yearly_vals)), 4)
397
+ result["baseline_range_max"] = round(float(max(baseline_yearly_vals)), 4)
398
+ return result
app/indicators/no2.py CHANGED
@@ -39,7 +39,7 @@ class NO2Indicator(BaseIndicator):
39
  status = self._classify(abs(z_score))
40
  trend = self._compute_trend(z_score)
41
  confidence = ConfidenceLevel.MODERATE
42
- chart_data = self._build_chart_data(current_no2, baseline_mean, baseline_std, time_range)
43
 
44
  direction = "above" if z_score >= 0 else "below"
45
  abs_z = abs(z_score)
@@ -142,6 +142,7 @@ class NO2Indicator(BaseIndicator):
142
  self._is_placeholder = True
143
  return self._synthetic_no2()
144
 
 
145
  return (
146
  float(np.mean(current_vals)),
147
  float(np.mean(baseline_yearly_means)),
@@ -178,10 +179,16 @@ class NO2Indicator(BaseIndicator):
178
  baseline_mean: float,
179
  baseline_std: float,
180
  time_range: TimeRange,
 
181
  ) -> dict[str, Any]:
182
- return {
183
  "dates": ["baseline", str(time_range.end.year)],
184
  "values": [round(baseline_mean, 1), round(current, 1)],
185
  "baseline_std": round(baseline_std, 1),
186
  "label": "NO2 concentration (µg/m³)",
187
  }
 
 
 
 
 
 
39
  status = self._classify(abs(z_score))
40
  trend = self._compute_trend(z_score)
41
  confidence = ConfidenceLevel.MODERATE
42
+ chart_data = self._build_chart_data(current_no2, baseline_mean, baseline_std, time_range, getattr(self, '_baseline_yearly_means', None))
43
 
44
  direction = "above" if z_score >= 0 else "below"
45
  abs_z = abs(z_score)
 
142
  self._is_placeholder = True
143
  return self._synthetic_no2()
144
 
145
+ self._baseline_yearly_means = baseline_yearly_means
146
  return (
147
  float(np.mean(current_vals)),
148
  float(np.mean(baseline_yearly_means)),
 
179
  baseline_mean: float,
180
  baseline_std: float,
181
  time_range: TimeRange,
182
+ baseline_yearly_means: list[float] | None = None,
183
  ) -> dict[str, Any]:
184
+ result: dict[str, Any] = {
185
  "dates": ["baseline", str(time_range.end.year)],
186
  "values": [round(baseline_mean, 1), round(current, 1)],
187
  "baseline_std": round(baseline_std, 1),
188
  "label": "NO2 concentration (µg/m³)",
189
  }
190
+ if baseline_yearly_means and len(baseline_yearly_means) >= 2:
191
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_means)), 1)
192
+ result["baseline_range_min"] = round(float(min(baseline_yearly_means)), 1)
193
+ result["baseline_range_max"] = round(float(max(baseline_yearly_means)), 1)
194
+ return result
app/indicators/rainfall.py CHANGED
@@ -52,7 +52,10 @@ class RainfallIndicator(BaseIndicator):
52
  status = self._classify(deviation_pct)
53
  trend = self._compute_trend(deviation_pct)
54
  confidence = ConfidenceLevel.HIGH if current_monthly else ConfidenceLevel.LOW
55
- chart_data = self._build_chart_data(current_monthly, baseline_monthly)
 
 
 
56
 
57
  if deviation_pct <= 10:
58
  headline = f"Rainfall within normal range — {deviation_pct:.1f}% below baseline"
@@ -147,6 +150,9 @@ class RainfallIndicator(BaseIndicator):
147
  month_num = month_key.split("-")[1]
148
  baseline_pool[month_num].append(mm)
149
 
 
 
 
150
  # Average each month across baseline years, keyed as current_year-MM
151
  baseline_monthly: dict[str, float] = {}
152
  for month_num, vals in baseline_pool.items():
@@ -255,12 +261,32 @@ class RainfallIndicator(BaseIndicator):
255
 
256
  @staticmethod
257
  def _build_chart_data(
258
- current: dict[str, float], baseline: dict[str, float]
 
 
259
  ) -> dict[str, Any]:
260
  all_keys = sorted(set(list(current.keys()) + list(baseline.keys())))
261
- return {
262
  "dates": all_keys,
263
  "values": [current.get(k, baseline.get(k, 0.0)) for k in all_keys],
264
  "baseline_values": [baseline.get(k, 0.0) for k in all_keys],
265
  "label": "Monthly rainfall (mm)",
266
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  status = self._classify(deviation_pct)
53
  trend = self._compute_trend(deviation_pct)
54
  confidence = ConfidenceLevel.HIGH if current_monthly else ConfidenceLevel.LOW
55
+ chart_data = self._build_chart_data(
56
+ current_monthly, baseline_monthly,
57
+ getattr(self, '_baseline_per_year', None),
58
+ )
59
 
60
  if deviation_pct <= 10:
61
  headline = f"Rainfall within normal range — {deviation_pct:.1f}% below baseline"
 
150
  month_num = month_key.split("-")[1]
151
  baseline_pool[month_num].append(mm)
152
 
153
+ # Store per-year monthly breakdown for baseline range computation
154
+ self._baseline_per_year = dict(baseline_pool)
155
+
156
  # Average each month across baseline years, keyed as current_year-MM
157
  baseline_monthly: dict[str, float] = {}
158
  for month_num, vals in baseline_pool.items():
 
261
 
262
  @staticmethod
263
  def _build_chart_data(
264
+ current: dict[str, float],
265
+ baseline: dict[str, float],
266
+ baseline_per_year: dict[str, list[float]] | None = None,
267
  ) -> dict[str, Any]:
268
  all_keys = sorted(set(list(current.keys()) + list(baseline.keys())))
269
+ result: dict[str, Any] = {
270
  "dates": all_keys,
271
  "values": [current.get(k, baseline.get(k, 0.0)) for k in all_keys],
272
  "baseline_values": [baseline.get(k, 0.0) for k in all_keys],
273
  "label": "Monthly rainfall (mm)",
274
  }
275
+ if baseline_per_year:
276
+ b_mean, b_min, b_max = [], [], []
277
+ for k in all_keys:
278
+ month_num = k.split("-")[1]
279
+ year_vals = baseline_per_year.get(month_num, [])
280
+ if year_vals:
281
+ b_mean.append(float(np.mean(year_vals)))
282
+ b_min.append(float(min(year_vals)))
283
+ b_max.append(float(max(year_vals)))
284
+ else:
285
+ fallback = baseline.get(k, 0.0)
286
+ b_mean.append(fallback)
287
+ b_min.append(fallback)
288
+ b_max.append(fallback)
289
+ result["baseline_mean"] = b_mean
290
+ result["baseline_min"] = b_min
291
+ result["baseline_max"] = b_max
292
+ return result
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
app/indicators/water.py CHANGED
@@ -43,7 +43,7 @@ class WaterIndicator(BaseIndicator):
43
  else ConfidenceLevel.MODERATE if n_months >= 3
44
  else ConfidenceLevel.LOW
45
  )
46
- chart_data = self._build_chart_data(baseline_mean, current_mean, time_range)
47
 
48
  abs_ctx = f"({baseline_mean:.2f}% → {current_mean:.2f}% cover)"
49
  if change_pct < 10:
@@ -203,10 +203,16 @@ class WaterIndicator(BaseIndicator):
203
  current_monthly = await loop.run_in_executor(None, _query_monthly, current_year)
204
 
205
  baseline_pool: dict[int, list[float]] = defaultdict(list)
 
206
  for yr in range(baseline_start_year, current_year):
207
  yr_monthly = await loop.run_in_executor(None, _query_monthly, yr)
 
208
  for month, vals in yr_monthly.items():
209
  baseline_pool[month].extend(vals)
 
 
 
 
210
 
211
  baseline_medians = []
212
  current_medians = []
@@ -222,6 +228,7 @@ class WaterIndicator(BaseIndicator):
222
  self._is_placeholder = True
223
  return self._synthetic()
224
 
 
225
  return (
226
  float(np.mean(baseline_medians)),
227
  float(np.mean(current_medians)),
@@ -255,10 +262,18 @@ class WaterIndicator(BaseIndicator):
255
 
256
  @staticmethod
257
  def _build_chart_data(
258
- baseline: float, current: float, time_range: TimeRange
 
 
 
259
  ) -> dict[str, Any]:
260
- return {
261
  "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
262
  "values": [round(baseline, 2), round(current, 2)],
263
  "label": "Water body coverage (%)",
264
  }
 
 
 
 
 
 
43
  else ConfidenceLevel.MODERATE if n_months >= 3
44
  else ConfidenceLevel.LOW
45
  )
46
+ chart_data = self._build_chart_data(baseline_mean, current_mean, time_range, getattr(self, '_baseline_yearly_means', None))
47
 
48
  abs_ctx = f"({baseline_mean:.2f}% → {current_mean:.2f}% cover)"
49
  if change_pct < 10:
 
203
  current_monthly = await loop.run_in_executor(None, _query_monthly, current_year)
204
 
205
  baseline_pool: dict[int, list[float]] = defaultdict(list)
206
+ baseline_yearly_means: list[float] = []
207
  for yr in range(baseline_start_year, current_year):
208
  yr_monthly = await loop.run_in_executor(None, _query_monthly, yr)
209
+ yr_medians = []
210
  for month, vals in yr_monthly.items():
211
  baseline_pool[month].extend(vals)
212
+ if vals:
213
+ yr_medians.append(float(np.median(vals)))
214
+ if yr_medians:
215
+ baseline_yearly_means.append(float(np.mean(yr_medians)))
216
 
217
  baseline_medians = []
218
  current_medians = []
 
228
  self._is_placeholder = True
229
  return self._synthetic()
230
 
231
+ self._baseline_yearly_means = baseline_yearly_means
232
  return (
233
  float(np.mean(baseline_medians)),
234
  float(np.mean(current_medians)),
 
262
 
263
  @staticmethod
264
  def _build_chart_data(
265
+ baseline: float,
266
+ current: float,
267
+ time_range: TimeRange,
268
+ baseline_yearly_means: list[float] | None = None,
269
  ) -> dict[str, Any]:
270
+ result: dict[str, Any] = {
271
  "dates": [str(time_range.start.year - 1), str(time_range.end.year)],
272
  "values": [round(baseline, 2), round(current, 2)],
273
  "label": "Water body coverage (%)",
274
  }
275
+ if baseline_yearly_means and len(baseline_yearly_means) >= 2:
276
+ result["baseline_range_mean"] = round(float(np.mean(baseline_yearly_means)), 2)
277
+ result["baseline_range_min"] = round(float(min(baseline_yearly_means)), 2)
278
+ result["baseline_range_max"] = round(float(max(baseline_yearly_means)), 2)
279
+ return result
app/outputs/charts.py CHANGED
@@ -96,11 +96,42 @@ def render_timeseries_chart(
96
  if use_categorical:
97
  parsed_dates = list(range(len(dates)))
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  ax.plot(
100
  parsed_dates, values,
101
  color=status_color, linewidth=2, marker="o",
102
  markersize=5, markerfacecolor="white",
103
  markeredgecolor=status_color, markeredgewidth=1.5,
 
104
  zorder=3,
105
  )
106
  ax.fill_between(
@@ -108,6 +139,9 @@ def render_timeseries_chart(
108
  alpha=0.15, color=status_color,
109
  )
110
 
 
 
 
111
  # X-axis formatting
112
  if use_categorical:
113
  ax.set_xticks(parsed_dates)
 
96
  if use_categorical:
97
  parsed_dates = list(range(len(dates)))
98
 
99
+ # Baseline overlay (rendered behind current data)
100
+ b_mean = chart_data.get("baseline_mean")
101
+ b_min = chart_data.get("baseline_min")
102
+ b_max = chart_data.get("baseline_max")
103
+
104
+ br_mean = chart_data.get("baseline_range_mean")
105
+ br_min = chart_data.get("baseline_range_min")
106
+ br_max = chart_data.get("baseline_range_max")
107
+
108
+ has_monthly_baseline = (
109
+ isinstance(b_mean, list)
110
+ and len(b_mean) == len(parsed_dates)
111
+ and len(b_mean) > 0
112
+ )
113
+ has_summary_baseline = br_mean is not None
114
+
115
+ if has_monthly_baseline:
116
+ ax.fill_between(
117
+ parsed_dates, b_min, b_max,
118
+ color="#D5D3CE", alpha=0.3, label="Baseline range", zorder=1,
119
+ )
120
+ ax.plot(
121
+ parsed_dates, b_mean,
122
+ color="#9B9B9B", linewidth=1.5, linestyle="--",
123
+ label="Baseline mean", zorder=2,
124
+ )
125
+ elif has_summary_baseline:
126
+ ax.axhspan(br_min, br_max, color="#D5D3CE", alpha=0.3, label="Baseline range", zorder=1)
127
+ ax.axhline(br_mean, color="#9B9B9B", linewidth=1.5, linestyle="--", label="Baseline mean", zorder=2)
128
+
129
  ax.plot(
130
  parsed_dates, values,
131
  color=status_color, linewidth=2, marker="o",
132
  markersize=5, markerfacecolor="white",
133
  markeredgecolor=status_color, markeredgewidth=1.5,
134
+ label="Current",
135
  zorder=3,
136
  )
137
  ax.fill_between(
 
139
  alpha=0.15, color=status_color,
140
  )
141
 
142
+ if has_monthly_baseline or has_summary_baseline:
143
+ ax.legend(fontsize=7, loc="upper left", framealpha=0.8)
144
+
145
  # X-axis formatting
146
  if use_categorical:
147
  ax.set_xticks(parsed_dates)
app/outputs/maps.py CHANGED
@@ -54,9 +54,9 @@ def _base_ax(aoi: AOI):
54
  min_lat - pad_lat, max_lat + pad_lat],
55
  crs=ccrs.PlateCarree(),
56
  )
57
- ax.add_feature(cfeature.LAND, facecolor="#E8E6E0", edgecolor="none")
58
- ax.add_feature(cfeature.OCEAN, facecolor="#D4E6F1", edgecolor="none")
59
- ax.add_feature(cfeature.BORDERS, linewidth=0.5, edgecolor=INK_MUTED)
60
  gl = ax.gridlines(draw_labels=True, linewidth=0.3, color=INK_MUTED, alpha=0.4, linestyle="--")
61
  gl.top_labels = False
62
  gl.right_labels = False
 
54
  min_lat - pad_lat, max_lat + pad_lat],
55
  crs=ccrs.PlateCarree(),
56
  )
57
+ ax.add_feature(cfeature.LAND.with_scale("50m"), facecolor="#E8E6E0", edgecolor="none")
58
+ ax.add_feature(cfeature.OCEAN.with_scale("50m"), facecolor="#D4E6F1", edgecolor="none")
59
+ ax.add_feature(cfeature.BORDERS.with_scale("50m"), linewidth=0.5, edgecolor=INK_MUTED)
60
  gl = ax.gridlines(draw_labels=True, linewidth=0.3, color=INK_MUTED, alpha=0.4, linestyle="--")
61
  gl.top_labels = False
62
  gl.right_labels = False
tests/test_charts.py CHANGED
@@ -37,3 +37,66 @@ def test_render_timeseries_chart_handles_empty_data():
37
  y_label="Fire events",
38
  )
39
  assert os.path.exists(out_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  y_label="Fire events",
38
  )
39
  assert os.path.exists(out_path)
40
+
41
+
42
+ def test_render_timeseries_chart_with_monthly_baseline():
43
+ chart_data = {
44
+ "dates": ["2025-01", "2025-02", "2025-03", "2025-04", "2025-05", "2025-06"],
45
+ "values": [2, 3, 1, 5, 4, 7],
46
+ "baseline_mean": [3.0, 3.5, 2.5, 4.0, 4.5, 5.0],
47
+ "baseline_min": [1.0, 1.5, 0.5, 2.0, 2.5, 3.0],
48
+ "baseline_max": [5.0, 5.5, 4.5, 6.0, 6.5, 7.0],
49
+ }
50
+ with tempfile.TemporaryDirectory() as tmpdir:
51
+ out_path = os.path.join(tmpdir, "monthly_baseline_chart.png")
52
+ render_timeseries_chart(
53
+ chart_data=chart_data,
54
+ indicator_name="NDVI",
55
+ status=StatusLevel.GREEN,
56
+ trend=TrendDirection.IMPROVING,
57
+ output_path=out_path,
58
+ y_label="NDVI value",
59
+ )
60
+ assert os.path.exists(out_path)
61
+ assert os.path.getsize(out_path) > 1000
62
+
63
+
64
+ def test_render_timeseries_chart_with_summary_baseline():
65
+ chart_data = {
66
+ "dates": ["2025-01", "2025-02", "2025-03", "2025-04", "2025-05", "2025-06"],
67
+ "values": [2, 3, 1, 5, 4, 7],
68
+ "baseline_range_mean": 4.0,
69
+ "baseline_range_min": 2.0,
70
+ "baseline_range_max": 6.0,
71
+ }
72
+ with tempfile.TemporaryDirectory() as tmpdir:
73
+ out_path = os.path.join(tmpdir, "summary_baseline_chart.png")
74
+ render_timeseries_chart(
75
+ chart_data=chart_data,
76
+ indicator_name="NDVI",
77
+ status=StatusLevel.AMBER,
78
+ trend=TrendDirection.STABLE,
79
+ output_path=out_path,
80
+ y_label="NDVI value",
81
+ )
82
+ assert os.path.exists(out_path)
83
+ assert os.path.getsize(out_path) > 1000
84
+
85
+
86
+ def test_render_timeseries_chart_no_baseline_still_works():
87
+ chart_data = {
88
+ "dates": ["2025-01", "2025-02", "2025-03", "2025-04", "2025-05", "2025-06"],
89
+ "values": [2, 3, 1, 5, 4, 7],
90
+ }
91
+ with tempfile.TemporaryDirectory() as tmpdir:
92
+ out_path = os.path.join(tmpdir, "no_baseline_chart.png")
93
+ render_timeseries_chart(
94
+ chart_data=chart_data,
95
+ indicator_name="NDVI",
96
+ status=StatusLevel.RED,
97
+ trend=TrendDirection.DETERIORATING,
98
+ output_path=out_path,
99
+ y_label="NDVI value",
100
+ )
101
+ assert os.path.exists(out_path)
102
+ assert os.path.getsize(out_path) > 1000
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_lst.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_build_chart_data_includes_baseline_range():
2
+ from app.indicators.lst import LSTIndicator
3
+ from datetime import date
4
+ from app.models import TimeRange
5
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
6
+ result = LSTIndicator._build_chart_data(
7
+ current=34.0, baseline_mean=32.0, baseline_std=2.5, time_range=tr,
8
+ baseline_yearly_means=[30.0, 31.5, 32.0, 33.0, 33.5],
9
+ )
10
+ assert "baseline_range_mean" in result
11
+ assert result["baseline_range_min"] == 30.0
12
+ assert result["baseline_range_max"] == 33.5
13
+
14
+
15
+ def test_build_chart_data_no_baseline_range_when_omitted():
16
+ from app.indicators.lst import LSTIndicator
17
+ from datetime import date
18
+ from app.models import TimeRange
19
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
20
+ result = LSTIndicator._build_chart_data(
21
+ current=34.0, baseline_mean=32.0, baseline_std=2.5, time_range=tr,
22
+ )
23
+ assert "baseline_range_mean" not in result
24
+ assert "baseline_range_min" not in result
25
+ assert "baseline_range_max" not in result
26
+
27
+
28
+ def test_build_chart_data_no_baseline_range_when_single_value():
29
+ from app.indicators.lst import LSTIndicator
30
+ from datetime import date
31
+ from app.models import TimeRange
32
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
33
+ result = LSTIndicator._build_chart_data(
34
+ current=34.0, baseline_mean=32.0, baseline_std=2.5, time_range=tr,
35
+ baseline_yearly_means=[32.0],
36
+ )
37
+ assert "baseline_range_mean" not in result
tests/test_indicator_nightlights.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_build_chart_data_includes_baseline_range():
2
+ from app.indicators.nightlights import NightlightsIndicator
3
+ from datetime import date
4
+ from app.models import TimeRange
5
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
6
+ result = NightlightsIndicator._build_chart_data(
7
+ current=2.8, baseline=3.2, time_range=tr,
8
+ baseline_yearly_vals=[3.0, 3.2, 3.4],
9
+ )
10
+ assert "baseline_range_mean" in result
11
+ assert result["baseline_range_min"] == 3.0
12
+ assert result["baseline_range_max"] == 3.4
13
+
14
+
15
+ def test_build_chart_data_no_range_when_single_year():
16
+ from app.indicators.nightlights import NightlightsIndicator
17
+ from datetime import date
18
+ from app.models import TimeRange
19
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
20
+ result = NightlightsIndicator._build_chart_data(
21
+ current=2.8, baseline=3.2, time_range=tr,
22
+ baseline_yearly_vals=[3.2],
23
+ )
24
+ assert "baseline_range_mean" not in result
25
+
26
+
27
+ def test_build_chart_data_no_range_when_none():
28
+ from app.indicators.nightlights import NightlightsIndicator
29
+ from datetime import date
30
+ from app.models import TimeRange
31
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
32
+ result = NightlightsIndicator._build_chart_data(
33
+ current=2.8, baseline=3.2, time_range=tr,
34
+ )
35
+ assert "baseline_range_mean" not in result
36
+ assert result["values"] == [3.2, 2.8]
37
+
38
+
39
+ def test_build_chart_data_uses_four_decimal_places():
40
+ from app.indicators.nightlights import NightlightsIndicator
41
+ from datetime import date
42
+ from app.models import TimeRange
43
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
44
+ result = NightlightsIndicator._build_chart_data(
45
+ current=2.8, baseline=3.2, time_range=tr,
46
+ baseline_yearly_vals=[3.0, 3.2, 3.4],
47
+ )
48
+ # mean of [3.0, 3.2, 3.4] = 3.2, check rounding to 4 dp
49
+ assert result["baseline_range_mean"] == 3.2
50
+ assert result["baseline_range_min"] == 3.0
51
+ assert result["baseline_range_max"] == 3.4
tests/test_indicator_no2.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_build_chart_data_includes_baseline_range():
2
+ from app.indicators.no2 import NO2Indicator
3
+ from datetime import date
4
+ from app.models import TimeRange
5
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
6
+ result = NO2Indicator._build_chart_data(
7
+ current=16.5, baseline_mean=15.0, baseline_std=4.0, time_range=tr,
8
+ baseline_yearly_means=[12.0, 15.0, 18.0],
9
+ )
10
+ assert "baseline_range_mean" in result
11
+ assert result["baseline_range_min"] == 12.0
12
+ assert result["baseline_range_max"] == 18.0
13
+
14
+
15
+ def test_build_chart_data_no_range_when_single_year():
16
+ from app.indicators.no2 import NO2Indicator
17
+ from datetime import date
18
+ from app.models import TimeRange
19
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
20
+ result = NO2Indicator._build_chart_data(
21
+ current=16.5, baseline_mean=15.0, baseline_std=4.0, time_range=tr,
22
+ baseline_yearly_means=[15.0],
23
+ )
24
+ assert "baseline_range_mean" not in result
25
+
26
+
27
+ def test_build_chart_data_no_range_when_none():
28
+ from app.indicators.no2 import NO2Indicator
29
+ from datetime import date
30
+ from app.models import TimeRange
31
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
32
+ result = NO2Indicator._build_chart_data(
33
+ current=16.5, baseline_mean=15.0, baseline_std=4.0, time_range=tr,
34
+ )
35
+ assert "baseline_range_mean" not in result
36
+ assert result["baseline_std"] == 4.0
tests/test_indicator_rainfall.py CHANGED
@@ -188,3 +188,25 @@ def test_classify_boundary():
188
  assert ind._classify(25.0) == StatusLevel.AMBER
189
  assert ind._classify(25.1) == StatusLevel.RED
190
  assert ind._classify(50.0) == StatusLevel.RED
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  assert ind._classify(25.0) == StatusLevel.AMBER
189
  assert ind._classify(25.1) == StatusLevel.RED
190
  assert ind._classify(50.0) == StatusLevel.RED
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Baseline range arrays in chart data
195
+ # ---------------------------------------------------------------------------
196
+
197
+ def test_build_chart_data_includes_baseline_range():
198
+ from app.indicators.rainfall import RainfallIndicator
199
+ current = {"2025-01": 50.0, "2025-02": 60.0, "2025-03": 45.0}
200
+ baseline = {"2025-01": 55.0, "2025-02": 58.0, "2025-03": 50.0}
201
+ baseline_per_year = {
202
+ "01": [50.0, 55.0, 60.0],
203
+ "02": [52.0, 58.0, 64.0],
204
+ "03": [45.0, 50.0, 55.0],
205
+ }
206
+ result = RainfallIndicator._build_chart_data(current, baseline, baseline_per_year)
207
+ assert "baseline_mean" in result
208
+ assert "baseline_min" in result
209
+ assert "baseline_max" in result
210
+ assert len(result["baseline_mean"]) == len(result["dates"])
211
+ for i in range(len(result["dates"])):
212
+ assert result["baseline_min"][i] <= result["baseline_mean"][i] <= result["baseline_max"][i]
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]
tests/test_indicator_water.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def test_build_chart_data_includes_baseline_range():
2
+ from app.indicators.water import WaterIndicator
3
+ from datetime import date
4
+ from app.models import TimeRange
5
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
6
+ result = WaterIndicator._build_chart_data(
7
+ baseline=5.0, current=4.5, time_range=tr,
8
+ baseline_yearly_means=[4.5, 5.0, 5.5],
9
+ )
10
+ assert "baseline_range_mean" in result
11
+ assert result["baseline_range_min"] == 4.5
12
+ assert result["baseline_range_max"] == 5.5
13
+
14
+
15
+ def test_build_chart_data_no_baseline_range_when_omitted():
16
+ from app.indicators.water import WaterIndicator
17
+ from datetime import date
18
+ from app.models import TimeRange
19
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
20
+ result = WaterIndicator._build_chart_data(
21
+ baseline=5.0, current=4.5, time_range=tr,
22
+ )
23
+ assert "baseline_range_mean" not in result
24
+ assert "baseline_range_min" not in result
25
+ assert "baseline_range_max" not in result
26
+
27
+
28
+ def test_build_chart_data_no_baseline_range_when_single_value():
29
+ from app.indicators.water import WaterIndicator
30
+ from datetime import date
31
+ from app.models import TimeRange
32
+ tr = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
33
+ result = WaterIndicator._build_chart_data(
34
+ baseline=5.0, current=4.5, time_range=tr,
35
+ baseline_yearly_means=[5.0],
36
+ )
37
+ assert "baseline_range_mean" not in result